CI / ci (push) Canceled after 0s
Built No-show flag and block: Two new numbers under Booking Rules (blank means off). A symbol appears on the clients list, client page, calendar entries and appointment pages. Online booking is refused at the block number, and customers are told at the details step. Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button. The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile. Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning. Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email. WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting. Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business. Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
209 lines
8.9 KiB
TypeScript
209 lines
8.9 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { performBackup } from "./backup";
|
|
import { BACKUP_MEMBERS, checkMigrationCompatibility, parseManifest, type BackupManifest } from "./backup-manifest";
|
|
import { backupFilePath } from "./backup-store";
|
|
import { generateComposeYaml } from "./compose-template";
|
|
import { composeCreate, composeDown, composeUp, listImageMigrations } from "./docker";
|
|
import { mediaRestoreCommand, mysqlImportCommand } from "./docker-commands";
|
|
import { readEnvValue } from "./env-file";
|
|
import { waitForHealth } from "./health";
|
|
import { uploadsDir } from "./manager-paths";
|
|
import { assertDomainAvailable, recreateDatabaseAndUser, writeCaddySiteAndReload } from "./provisioning";
|
|
import { runPipeline } from "./process";
|
|
import { sevenZipExtractToStdoutCommand, sevenZipReadMember, sevenZipTest } from "./sevenzip";
|
|
import { stackDir, stackExists } from "./stacks";
|
|
|
|
/**
|
|
* Restore a backup archive as a business — onto an existing business
|
|
* (replace) or onto a fresh server where it never existed. The step order
|
|
* is the contract (and is asserted in tests):
|
|
*
|
|
* verify → check → safety backup (replace only) → stop → recreate DB
|
|
* → import dump → write config + Caddy → restore media → start → health
|
|
*
|
|
* The database password comes from the backed-up .env (so that .env keeps
|
|
* working untouched); the dump already contains the "installed" row, so
|
|
* the setup wizard doesn't appear — the final step insists on
|
|
* `installed: true`. Like performBackup this does not take the business
|
|
* lock; the restore job holds it.
|
|
*/
|
|
|
|
export const RESTORE_STEPS = [
|
|
{ id: "verify", label: "Verify the archive" },
|
|
{ id: "check", label: "Check for conflicts and schema version" },
|
|
{ id: "safety", label: "Take a safety backup of the current business" },
|
|
{ id: "stop", label: "Stop the current stack" },
|
|
{ id: "database", label: "Recreate the database and user" },
|
|
{ id: "import", label: "Import the database dump" },
|
|
{ id: "config", label: "Write configuration and the Caddy site" },
|
|
{ id: "media", label: "Restore media files" },
|
|
{ id: "start", label: "Start the stack" },
|
|
{ id: "health", label: "Wait for the app to report installed" },
|
|
] as const;
|
|
|
|
export type RestoreSource = { kind: "backup"; slug: string; fileName: string } | { kind: "upload"; uploadId: string };
|
|
|
|
const UPLOAD_ID_PATTERN = /^[a-f0-9]{16}$/;
|
|
|
|
export function resolveArchivePath(source: RestoreSource): string {
|
|
if (source.kind === "backup") return backupFilePath(source.slug, source.fileName);
|
|
if (!UPLOAD_ID_PATTERN.test(source.uploadId)) throw new Error("Invalid upload reference.");
|
|
return path.join(uploadsDir(), `${source.uploadId}.7z`);
|
|
}
|
|
|
|
export interface RestorePlan {
|
|
manifest: BackupManifest;
|
|
/** A stack with this slug already exists — restoring needs explicit "replace". */
|
|
slugExists: boolean;
|
|
/** Another business already uses the backup's domain. */
|
|
domainConflict: string | null;
|
|
schema: { ok: true } | { ok: false; reason: string };
|
|
}
|
|
|
|
/** Fast: reads manifest.json from the archive. Does not verify the payload. */
|
|
export async function readArchiveManifest(archivePath: string): Promise<BackupManifest> {
|
|
return parseManifest(await sevenZipReadMember(archivePath, BACKUP_MEMBERS.manifest));
|
|
}
|
|
|
|
/** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive. */
|
|
export async function inspectArchive(archivePath: string): Promise<BackupManifest> {
|
|
await sevenZipTest(archivePath);
|
|
return readArchiveManifest(archivePath);
|
|
}
|
|
|
|
/** Read-only conflict/version report shown on the Restore page before the operator confirms. */
|
|
export async function planRestore(manifest: BackupManifest): Promise<RestorePlan> {
|
|
const slugExists = await stackExists(manifest.slug);
|
|
let domainConflict: string | null = null;
|
|
try {
|
|
await assertDomainAvailable(manifest.domain, manifest.slug);
|
|
} catch (error) {
|
|
domainConflict = error instanceof Error ? error.message : "Domain conflict.";
|
|
}
|
|
let schema: RestorePlan["schema"];
|
|
try {
|
|
schema = checkMigrationCompatibility(manifest.lastMigration, await listImageMigrations());
|
|
} catch (error) {
|
|
schema = { ok: false, reason: `Couldn't read the app image's migrations: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
return { manifest, slugExists, domainConflict, schema };
|
|
}
|
|
|
|
export interface RestoreContext {
|
|
log(line: string): void;
|
|
step<T>(stepId: string, fn: () => Promise<T>): Promise<T>;
|
|
skip(stepId: string, detail?: string): void;
|
|
}
|
|
|
|
export interface RestoreResult {
|
|
slug: string;
|
|
domain: string;
|
|
safetyBackup: string | null;
|
|
}
|
|
|
|
export async function performRestore(params: {
|
|
archivePath: string;
|
|
/** Operator confirmed overwriting an existing business with the same slug. */
|
|
replace: boolean;
|
|
ctx: RestoreContext;
|
|
}): Promise<RestoreResult> {
|
|
const { archivePath, replace, ctx } = params;
|
|
|
|
const { manifest, envContent } = await ctx.step("verify", async () => {
|
|
ctx.log("Testing the archive (7zz t)…");
|
|
const parsed = await inspectArchive(archivePath);
|
|
const env = await sevenZipReadMember(archivePath, BACKUP_MEMBERS.env);
|
|
ctx.log(`Backup of "${parsed.slug}" (${parsed.domain}) taken ${parsed.createdAt}.`);
|
|
|
|
// The .env inside the archive must agree with the manifest — the
|
|
// password for the new database user comes from it.
|
|
const envDb = readEnvValue(env, "DATABASE_NAME");
|
|
const envUser = readEnvValue(env, "DATABASE_USER");
|
|
const envPassword = readEnvValue(env, "DATABASE_PASSWORD");
|
|
if (envDb !== parsed.databaseName || envUser !== parsed.databaseUser || !envPassword) {
|
|
throw new Error("The archive's .env doesn't match its manifest (database name/user/password) — refusing to restore it.");
|
|
}
|
|
return { manifest: parsed, envContent: env };
|
|
});
|
|
|
|
const slug = manifest.slug;
|
|
const dir = stackDir(slug);
|
|
const exists = await stackExists(slug);
|
|
|
|
await ctx.step("check", async () => {
|
|
if (exists && !replace) {
|
|
throw new Error(`A business named "${slug}" already exists. Confirm "replace" to overwrite it (a safety backup is taken first).`);
|
|
}
|
|
await assertDomainAvailable(manifest.domain, slug);
|
|
const compatibility = checkMigrationCompatibility(manifest.lastMigration, await listImageMigrations());
|
|
if (!compatibility.ok) throw new Error(compatibility.reason);
|
|
ctx.log("No conflicts; the backup's schema is compatible with the running app image.");
|
|
});
|
|
|
|
let safetyBackup: string | null = null;
|
|
if (exists) {
|
|
safetyBackup = await ctx.step("safety", async () => {
|
|
ctx.log(`"${slug}" exists — taking a safety backup before replacing it…`);
|
|
const result = await performBackup(slug, "safety", { log: ctx.log });
|
|
ctx.log(`Safety backup: ${result.fileName}`);
|
|
return result.fileName;
|
|
});
|
|
} else {
|
|
ctx.skip("safety", "No existing business to protect.");
|
|
}
|
|
|
|
if (exists) {
|
|
await ctx.step("stop", async () => {
|
|
// -v: the old media volume goes too, so files deleted since the backup don't linger.
|
|
await composeDown(dir, { removeVolumes: true });
|
|
});
|
|
} else {
|
|
ctx.skip("stop", "Nothing running under this slug.");
|
|
}
|
|
|
|
await ctx.step("database", async () => {
|
|
await recreateDatabaseAndUser(manifest.databaseName, manifest.databaseUser, readEnvValue(envContent, "DATABASE_PASSWORD")!);
|
|
});
|
|
|
|
await ctx.step("import", async () => {
|
|
ctx.log("Importing db.sql (streamed from the archive)…");
|
|
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName));
|
|
});
|
|
|
|
await ctx.step("config", async () => {
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(path.join(dir, ".env"), envContent, { encoding: "utf8", mode: 0o600 });
|
|
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
|
|
await writeCaddySiteAndReload(slug, manifest.domain);
|
|
ctx.log("Wrote .env and compose.yaml, and reloaded Caddy.");
|
|
});
|
|
|
|
await ctx.step("media", async () => {
|
|
// Create (don't start) the container so the named volume exists with compose's labels before it's filled.
|
|
await composeCreate(dir);
|
|
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.media), mediaRestoreCommand(slug));
|
|
ctx.log("Media restored and handed to uid 1001.");
|
|
});
|
|
|
|
await ctx.step("start", async () => {
|
|
// The entrypoint runs `prisma migrate deploy`, so an older dump upgrades itself here.
|
|
await composeUp(dir);
|
|
});
|
|
|
|
await ctx.step("health", async () => {
|
|
// Log only when the answer changes — polling every few seconds would otherwise bury the log tail.
|
|
let lastSeen = "";
|
|
await waitForHealth(slug, {
|
|
requireInstalled: true,
|
|
onAttempt: (s) => {
|
|
const seen = `HTTP ${s.httpStatus ?? "no response"}, installed=${String(s.installed)}`;
|
|
if (seen !== lastSeen) ctx.log(`health: ${seen}`);
|
|
lastSeen = seen;
|
|
},
|
|
});
|
|
});
|
|
|
|
return { slug, domain: manifest.domain, safetyBackup };
|
|
}
|