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:
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,9 +41,20 @@ export default async function BusinessDetailPage({
|
|||||||
<h1 className="text-xl font-bold">{stack.name ?? stack.slug}</h1>
|
<h1 className="text-xl font-bold">{stack.name ?? stack.slug}</h1>
|
||||||
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
|
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
|
||||||
</div>
|
</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
|
Edit
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
<BusinessActions slug={stack.slug} />
|
<BusinessActions slug={stack.slug} />
|
||||||
|
|||||||
@@ -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>;
|
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();
|
const stacks = await listBusinessStacks();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold">Businesses</h1>
|
<h1 className="text-xl font-bold">Businesses</h1>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ export async function composeUp(stackDir: string, options: { forceRecreate?: boo
|
|||||||
await runDockerCompose(stackDir, args);
|
await runDockerCompose(stackDir, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function composeDown(stackDir: string): Promise<void> {
|
export async function composeDown(stackDir: string, options: { removeVolumes?: boolean } = {}): Promise<void> {
|
||||||
await runDockerCompose(stackDir, ["down"]);
|
const args = ["down"];
|
||||||
|
if (options.removeVolumes) args.push("-v");
|
||||||
|
await runDockerCompose(stackDir, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function composeRestart(stackDir: string): Promise<void> {
|
export async function composeRestart(stackDir: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
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 path from "node:path";
|
||||||
import mariadb from "mariadb";
|
import mariadb, { type Connection } from "mariadb";
|
||||||
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
|
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 { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
|
||||||
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks";
|
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks";
|
||||||
|
|
||||||
@@ -45,20 +45,13 @@ async function writeCaddySiteAndReload(slug: string, domain: string): Promise<vo
|
|||||||
await reloadCaddy();
|
await reloadCaddy();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
|
async function connectAsRoot(): Promise<Connection> {
|
||||||
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 host = process.env.MYSQL_HOST ?? "next-chair-mysql";
|
const host = process.env.MYSQL_HOST ?? "next-chair-mysql";
|
||||||
const port = process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306;
|
const port = process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306;
|
||||||
const rootPassword = process.env.MYSQL_ROOT_PASSWORD;
|
const rootPassword = process.env.MYSQL_ROOT_PASSWORD;
|
||||||
if (!rootPassword) throw new Error("MYSQL_ROOT_PASSWORD must be set.");
|
if (!rootPassword) throw new Error("MYSQL_ROOT_PASSWORD must be set.");
|
||||||
|
|
||||||
const conn = await mariadb.createConnection({
|
return mariadb.createConnection({
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
user: "root",
|
user: "root",
|
||||||
@@ -72,6 +65,17 @@ async function createDatabaseAndUser(databaseName: string, databaseUser: string,
|
|||||||
// available client side" before this option was added here.
|
// available client side" before this option was added here.
|
||||||
allowPublicKeyRetrieval: true,
|
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 {
|
try {
|
||||||
// Identifiers are backtick-quoted AND already validated against
|
// Identifiers are backtick-quoted AND already validated against
|
||||||
// SAFE_DB_IDENTIFIER above — belt and braces, since a SQL identifier
|
// 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 {
|
export interface ProvisionBusinessInput {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -227,3 +246,60 @@ export async function updateBusiness(input: UpdateBusinessInput): Promise<Update
|
|||||||
|
|
||||||
return { slug, domain, restarted: domainChanged };
|
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 };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user