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
+245 -18

No files matched your search

+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 };
}