Files
NextChair/manager/src/lib/stacks.ts
T
Stefan Lazic 3b07124d7e
CI / ci (push) Canceled after 0s
Edit Business flow
New files:

manager/src/lib/env-file.ts — a shared, surgical .env reader/updater. It only ever touches the specific keys you tell it to (never regenerates the file), so anything hand-edited into a business's .env since provisioning is preserved exactly.
manager/src/app/dashboard/[slug]/edit/ — the edit page, its form component, and its server action.

Modified:

provisioning.ts gained updateBusiness() alongside the existing provisionBusiness(), reusing the same Caddy-site-writing and container-recreation logic (refactored into a shared writeCaddySiteAndReload helper).
stacks.ts/compose-template.ts — extended to track a BUSINESS_NAME field (previously collected on the Add Business form but silently discarded — that gap is now fixed too, so newly created businesses get their name persisted from the start).
The detail page and dashboard list now show the business name, and the detail page has an Edit button.

Per the requirements:

Slug is read-only in the edit form (disabled input, never accepted by the server action — it's bound in from the route, not form data).
Domain uniqueness is validated against every other business (and correctly self-excludes the business being edited).
A domain change rewrites the Caddy site file, reloads Caddy, and force-recreates the container; a name-only change does neither.
No secrets (DATABASE_PASSWORD, AUTH_SECRET, etc.) are ever read into the edit UI — only name/domain/databaseName/status are exposed.
Clear success banners ("Changes saved" vs. "...the container was restarted...") and inline error display, matching the existing Add Business pattern.
2026-09-05 17:05:33 +01:00

71 lines
2.2 KiB
TypeScript

import { readdir, access } from "node:fs/promises";
import path from "node:path";
import { composeStatus, type StackRunState } from "./docker";
import { readEnvFile, readEnvValue } from "./env-file";
import { RESERVED_SLUGS } from "./slug";
export { isValidSlug, suggestSlug } from "./slug";
export interface BusinessStackSummary {
slug: string;
/** BUSINESS_NAME from the business's own .env — null for a business provisioned before this field existed, until it's next edited. */
name: string | null;
domain: string | null;
databaseName: string | null;
containerName: string;
status: StackRunState;
}
export function stacksRoot(): string {
const dir = process.env.STACKS_DIR;
if (!dir) throw new Error("STACKS_DIR must be set.");
return dir;
}
export function stackDir(slug: string): string {
return path.join(stacksRoot(), slug);
}
export async function listBusinessStacks(): Promise<BusinessStackSummary[]> {
const root = stacksRoot();
let entries: string[];
try {
entries = await readdir(root);
} catch {
return [];
}
const stacks: BusinessStackSummary[] = [];
for (const slug of entries) {
if (RESERVED_SLUGS.has(slug)) continue;
const dir = path.join(root, slug);
const composePath = path.join(dir, "compose.yaml");
try {
await access(composePath);
} catch {
continue;
}
// Only ever reads specific known-safe fields — never the rest of a
// business's .env (database password, AUTH_SECRET, etc.) — see
// env-file.ts's readEnvValue.
const envContent = await readEnvFile(path.join(dir, ".env"));
const name = envContent ? readEnvValue(envContent, "BUSINESS_NAME") : null;
const domain = envContent ? readEnvValue(envContent, "DOMAIN") : null;
const databaseName = envContent ? readEnvValue(envContent, "DATABASE_NAME") : null;
const status = await composeStatus(dir);
stacks.push({ slug, name, domain, databaseName, containerName: `next-chair-${slug}`, status });
}
return stacks.sort((a, b) => a.slug.localeCompare(b.slug));
}
export async function stackExists(slug: string): Promise<boolean> {
try {
await access(path.join(stackDir(slug), "compose.yaml"));
return true;
} catch {
return false;
}
}