Delete Business
CI / ci (push) Canceled after 0s

A Delete button now sits next to Edit on each business's detail page, styled in red to distinguish it from the safe actions.
It opens a dedicated confirmation page (matching the Add/Edit page pattern) that clearly lists exactly what gets destroyed — container + media volume, the database by name, the Caddy site/domain, and the stack directory — and requires typing the business's exact slug before the Delete button even becomes clickable. This is a stronger guard than a plain "are you sure?" popup, given what we just learned about how easy it is to lose data here.
deleteBusiness() in provisioning.ts reuses the same primitives as create/edit (composeDown, reloadCaddy, the shared MySQL root connection), and is deliberately best-effort past the first step — if a piece is already gone (matching exactly the kind of half-broken state we just fixed for blake-heritage), it still finishes removing the rest and reports what it couldn't clean up automatically, rather than getting stuck.
Verified live: provisioned a real test business, confirmed its container/database/user/volume/Caddy site all existed, deleted it, and confirmed every single one was actually gone — including checking Caddy's running config (not just the file on disk) to prove the reload took effect. Also confirmed the confirm-button correctly stays disabled for a wrong slug and only enables on an exact match. Your real production database was never touched throughout.
This commit is contained in:
Stefan Lazic committed 2026-09-05 17:41:38 +01:00
1 parent 2e739c9a26
commit ba46334179
7 files changed
+243 -16

No files matched your search

@@ -0,0 +1,35 @@
"use server";
import { redirect } from "next/navigation";
import { deleteBusiness } from "@/lib/provisioning";
export interface DeleteBusinessState {
error: string | null;
}
/**
* `slug` is bound in by the page, the same way updateBusinessAction takes
* it — the operator only ever types a *confirmation* string, never the
* slug being acted on as a submittable field.
*/
export async function deleteBusinessAction(
slug: string,
_prevState: DeleteBusinessState,
formData: FormData,
): Promise<DeleteBusinessState> {
const confirmation = String(formData.get("confirmation") ?? "");
if (confirmation !== slug) {
return { error: `Type "${slug}" exactly to confirm deletion.` };
}
let result: { slug: string; warnings: string[] };
try {
result = await deleteBusiness(slug);
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to delete this business." };
}
const params = new URLSearchParams({ justDeleted: result.slug });
if (result.warnings.length > 0) params.set("warnings", result.warnings.join("; "));
redirect(`/dashboard?${params.toString()}`);
}
@@ -0,0 +1,46 @@
"use client";
import { useActionState, useState } from "react";
import { deleteBusinessAction, type DeleteBusinessState } from "./actions";
const initialState: DeleteBusinessState = { error: null };
export function DeleteBusinessForm({ slug }: { slug: string }) {
const boundAction = deleteBusinessAction.bind(null, slug);
const [state, formAction, isPending] = useActionState(boundAction, initialState);
const [confirmation, setConfirmation] = useState("");
const canDelete = confirmation === slug;
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="confirmation" className="block text-sm font-medium text-muted">
Type <span className="font-mono font-semibold text-ink">{slug}</span> to confirm
</label>
<input
id="confirmation"
name="confirmation"
type="text"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
/>
</div>
{state.error && (
<p role="alert" className="text-sm font-medium text-danger">
{state.error}
</p>
)}
<button
type="submit"
disabled={!canDelete || isPending}
className="w-full rounded-md bg-danger py-2 text-sm font-semibold text-white disabled:opacity-40"
>
{isPending ? "Deleting…" : "Delete Business"}
</button>
</form>
);
}
@@ -0,0 +1,43 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { listBusinessStacks } from "@/lib/stacks";
import { DeleteBusinessForm } from "./delete-business-form";
export const dynamic = "force-dynamic";
export default async function DeleteBusinessPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const stacks = await listBusinessStacks();
const stack = stacks.find((s) => s.slug === slug);
if (!stack) notFound();
return (
<div className="max-w-lg space-y-6">
<div>
<Link href={`/dashboard/${slug}`} className="text-sm text-muted hover:text-ink">
← Back
</Link>
<h1 className="mt-2 text-xl font-bold text-danger">Delete {stack.name ?? slug}</h1>
</div>
<div className="space-y-2 rounded-md border border-danger/30 bg-danger/5 p-4 text-sm">
<p className="font-semibold text-danger">This permanently deletes:</p>
<ul className="list-inside list-disc space-y-1 text-muted">
<li>The running container and its media uploads volume</li>
<li>
The <span className="font-mono">{stack.databaseName ?? "business"}</span> database — every customer,
appointment, and setting in it
</li>
<li>
Its Caddy site — {stack.domain ?? "its domain"} will stop resolving to this business immediately
</li>
<li>The stack directory and its configuration on disk</li>
</ul>
<p className="font-semibold">This cannot be undone.</p>
</div>
<DeleteBusinessForm slug={slug} />
</div>
);
}
+12 -1
View File
@@ -41,9 +41,20 @@ export default async function BusinessDetailPage({
<h1 className="text-xl font-bold">{stack.name ?? stack.slug}</h1>
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
</div>
<Link href={`/dashboard/${stack.slug}/edit`} className="rounded-md border border-line px-3 py-1.5 text-sm font-medium">
<div className="flex gap-2">
<Link
href={`/dashboard/${stack.slug}/edit`}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium"
>
Edit
</Link>
<Link
href={`/dashboard/${stack.slug}/delete`}
className="rounded-md border border-danger/30 px-3 py-1.5 text-sm font-medium text-danger"
>
Delete
</Link>
</div>
</div>
<BusinessActions slug={stack.slug} />
+15 -1
View File
@@ -12,11 +12,25 @@ function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) {
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>{status}</span>;
}
export default async function DashboardPage() {
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ justDeleted?: string; warnings?: string }>;
}) {
const { justDeleted, warnings } = await searchParams;
const stacks = await listBusinessStacks();
return (
<div className="space-y-6">
{justDeleted && (
<div className="rounded-md border border-line bg-surface px-4 py-3 text-sm">
<p className="font-medium">
<span className="font-mono">{justDeleted}</span> was deleted.
</p>
{warnings && <p className="mt-1 text-xs text-danger">Some cleanup steps need a manual look: {warnings}</p>}
</div>
)}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">Businesses</h1>
<Link
+4 -2
View File
@@ -36,8 +36,10 @@ export async function composeUp(stackDir: string, options: { forceRecreate?: boo
await runDockerCompose(stackDir, args);
}
export async function composeDown(stackDir: string): Promise<void> {
await runDockerCompose(stackDir, ["down"]);
export async function composeDown(stackDir: string, options: { removeVolumes?: boolean } = {}): Promise<void> {
const args = ["down"];
if (options.removeVolumes) args.push("-v");
await runDockerCompose(stackDir, args);
}
export async function composeRestart(stackDir: string): Promise<void> {
+88 -12
View File
@@ -1,9 +1,9 @@
import { randomBytes } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import mariadb from "mariadb";
import mariadb, { type Connection } from "mariadb";
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
import { composeUp, reloadCaddy, runMigrations } from "./docker";
import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks";
@@ -45,20 +45,13 @@ async function writeCaddySiteAndReload(slug: string, domain: string): Promise<vo
await reloadCaddy();
}
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
throw new Error("Refusing to create a database/user from an unsafe identifier.");
}
if (!SAFE_GENERATED_SECRET.test(databasePassword)) {
throw new Error("Refusing to create a user with an unexpected password format.");
}
async function connectAsRoot(): Promise<Connection> {
const host = process.env.MYSQL_HOST ?? "next-chair-mysql";
const port = process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306;
const rootPassword = process.env.MYSQL_ROOT_PASSWORD;
if (!rootPassword) throw new Error("MYSQL_ROOT_PASSWORD must be set.");
const conn = await mariadb.createConnection({
return mariadb.createConnection({
host,
port,
user: "root",
@@ -72,6 +65,17 @@ async function createDatabaseAndUser(databaseName: string, databaseUser: string,
// available client side" before this option was added here.
allowPublicKeyRetrieval: true,
});
}
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
throw new Error("Refusing to create a database/user from an unsafe identifier.");
}
if (!SAFE_GENERATED_SECRET.test(databasePassword)) {
throw new Error("Refusing to create a user with an unexpected password format.");
}
const conn = await connectAsRoot();
try {
// Identifiers are backtick-quoted AND already validated against
// SAFE_DB_IDENTIFIER above — belt and braces, since a SQL identifier
@@ -85,6 +89,21 @@ async function createDatabaseAndUser(databaseName: string, databaseUser: string,
}
}
/** Irreversible — only ever called from deleteBusiness, itself gated behind a type-the-slug confirmation in the UI. */
async function dropDatabaseAndUser(databaseName: string, databaseUser: string): Promise<void> {
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
throw new Error("Refusing to drop a database/user from an unsafe identifier.");
}
const conn = await connectAsRoot();
try {
await conn.query(`DROP DATABASE IF EXISTS \`${databaseName}\``);
await conn.query(`DROP USER IF EXISTS '${databaseUser}'@'%'`);
} finally {
await conn.end();
}
}
export interface ProvisionBusinessInput {
name: string;
slug: string;
@@ -227,3 +246,60 @@ export async function updateBusiness(input: UpdateBusinessInput): Promise<Update
return { slug, domain, restarted: domainChanged };
}
export interface DeleteBusinessResult {
slug: string;
/** Non-fatal problems along the way (e.g. the container was already gone) — the deletion still completes, but the operator should know what didn't clean up on its own. */
warnings: string[];
}
/**
* Irreversible: removes the container, its media volume, its database and
* database user, its Caddy site, and its entire stack directory. The UI
* gates this behind typing the exact slug to confirm (see
* dashboard/[slug]/delete/) — this function itself does no further
* confirmation, so any new caller must provide its own.
*
* Deliberately best-effort past the first step: a stack can be deleted
* from a half-broken state (container already removed by hand, database
* already dropped, no Caddy site ever configured — exactly the kind of
* state updateBusiness's own guard now prevents going forward, but this
* needs to clean up businesses that got into that state before the fix
* existed) — one missing piece shouldn't block removing the rest.
*/
export async function deleteBusiness(slug: string): Promise<DeleteBusinessResult> {
const normalizedSlug = slug.trim().toLowerCase();
if (!isValidSlug(normalizedSlug) || !(await stackExists(normalizedSlug))) {
throw new Error(`No such business: ${normalizedSlug}`);
}
const dir = stackDir(normalizedSlug);
const warnings: string[] = [];
try {
await composeDown(dir, { removeVolumes: true });
} catch (error) {
warnings.push(`Couldn't stop/remove its container: ${error instanceof Error ? error.message : String(error)}`);
}
const currentEnv = await readEnvFile(path.join(dir, ".env"));
const databaseName = currentEnv ? readEnvValue(currentEnv, "DATABASE_NAME") : null;
if (databaseName) {
try {
await dropDatabaseAndUser(databaseName, databaseName);
} catch (error) {
warnings.push(`Couldn't drop its database: ${error instanceof Error ? error.message : String(error)}`);
}
}
try {
await rm(path.join(stacksRoot(), "infra", "sites", `${normalizedSlug}.caddy`), { force: true });
await reloadCaddy();
} catch (error) {
warnings.push(`Couldn't remove/reload its Caddy site: ${error instanceof Error ? error.message : String(error)}`);
}
await rm(dir, { recursive: true, force: true });
return { slug: normalizedSlug, warnings };
}