Updates 1
CI / ci (push) Canceled after 0s

The session built:

Appointments page: a single list, newest first.
Block Time: edit and delete.
Customer CSV export: with barber notes excluded.
Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs.

It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
This commit is contained in:
Stefan Lazic committed 2026-10-17 22:29:00 +01:00
1 parent 9f981173f4
commit 17eee5b932
95 files changed
+7955 -150

No files matched your search

@@ -0,0 +1,104 @@
"use server";
import { ensureBackupPassphrase, hasBackupPassphrase, readBackupPassphrase } from "@/lib/global-settings";
import { BusinessLockedError } from "@/lib/locks";
import { startRestoreJob } from "@/lib/operations";
import { planRestore, readArchiveManifest, resolveArchivePath, type RestoreSource } from "@/lib/restore";
/** "backup:<slug>:<fileName>" for a stored archive, "upload:<id>" for one just uploaded. Parsed strictly; resolveArchivePath re-validates both forms. */
function parseSourceKey(key: string): RestoreSource {
if (key.startsWith("upload:")) return { kind: "upload", uploadId: key.slice("upload:".length) };
const match = /^backup:([^:]+):([^:]+)$/.exec(key);
if (!match) throw new Error("Choose a backup to restore.");
return { kind: "backup", slug: match[1]!, fileName: match[2]! };
}
export interface RestorePreview {
slug: string;
name: string | null;
domain: string;
createdAt: string;
kind: string;
lastMigration: string | null;
slugExists: boolean;
domainConflict: string | null;
schemaProblem: string | null;
}
export interface InspectRestoreState {
error: string | null;
preview: RestorePreview | null;
}
async function passphraseFor(input: string): Promise<string> {
const passphrase = input.trim() || (await readBackupPassphrase()) || "";
if (!passphrase) throw new Error("Enter the backup passphrase (this server has no stored one).");
return passphrase;
}
/** Opens only the encrypted header (fast) and reports what restoring would do — nothing is changed. A wrong passphrase fails here. */
export async function inspectRestoreAction(sourceKey: string, passphraseInput: string): Promise<InspectRestoreState> {
try {
const source = parseSourceKey(sourceKey);
const manifest = await readArchiveManifest(resolveArchivePath(source), await passphraseFor(passphraseInput));
const plan = await planRestore(manifest);
return {
error: null,
preview: {
slug: manifest.slug,
name: manifest.name,
domain: manifest.domain,
createdAt: manifest.createdAt,
kind: manifest.kind,
lastMigration: manifest.lastMigration,
slugExists: plan.slugExists,
domainConflict: plan.domainConflict,
schemaProblem: plan.schema.ok ? null : plan.schema.reason,
},
};
} catch (error) {
// A wrong passphrase makes 7zz fail with a generic message; say so plainly instead of passing its output through.
const message = error instanceof Error ? error.message : "";
const looksLikePassphrase = /wrong password|can not open encrypted|data error|headers error|exited with code 2/i.test(message);
return {
error: looksLikePassphrase ? "Couldn't open the archive — wrong passphrase, or the file isn't a valid NextChair backup." : message || "Couldn't read that backup.",
preview: null,
};
}
}
export interface StartRestoreState {
error: string | null;
jobId: string | null;
newPassphrase: string | null;
}
export async function startRestoreAction(
sourceKey: string,
passphraseInput: string,
replace: boolean,
confirmSlug: string,
): Promise<StartRestoreState> {
const fail = (error: string): StartRestoreState => ({ error, jobId: null, newPassphrase: null });
try {
const source = parseSourceKey(sourceKey);
const passphrase = await passphraseFor(passphraseInput);
const manifest = await readArchiveManifest(resolveArchivePath(source), passphrase);
// Re-checked here, not trusted from the preview the browser holds.
const plan = await planRestore(manifest);
if (plan.domainConflict) return fail(plan.domainConflict);
if (!plan.schema.ok) return fail(plan.schema.reason);
if (plan.slugExists && (!replace || confirmSlug !== manifest.slug)) {
return fail(`"${manifest.slug}" already exists. Tick "replace" and type its slug to confirm overwriting it.`);
}
// Replacing takes a safety backup, which needs a stored passphrase; mint one (shown once) if this server has none yet.
const newPassphrase = plan.slugExists && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
const { jobId } = await startRestoreJob({ source, passphrase, replace });
return { error: null, jobId, newPassphrase };
} catch (error) {
if (error instanceof BusinessLockedError) return fail(error.message);
return fail(error instanceof Error ? error.message : "Couldn't start the restore.");
}
}
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import { listAllBackups } from "@/lib/backup-store";
import { hasBackupPassphrase } from "@/lib/global-settings";
import { RestoreWizard } from "./restore-wizard";
export const metadata: Metadata = { title: "Restore" };
export const dynamic = "force-dynamic";
export default async function RestorePage({ searchParams }: { searchParams: Promise<{ slug?: string; file?: string }> }) {
const { slug, file } = await searchParams;
const [backups, hasPassphrase] = await Promise.all([listAllBackups(), hasBackupPassphrase()]);
const preselected = slug && file ? `backup:${slug}:${file}` : null;
const configuredMax = Number(process.env.BACKUP_UPLOAD_MAX_BYTES);
const maxUploadBytes = configuredMax > 0 ? configuredMax : 4 * 1024 * 1024 * 1024;
return (
<div className="max-w-2xl space-y-6">
<div>
<Link href="/dashboard" className="text-sm text-muted hover:text-ink">
← Businesses
</Link>
<h1 className="mt-2 text-xl font-bold">Restore a business</h1>
<p className="text-sm text-muted">
Restores a backup onto the business it came from, or onto a fresh server where it never existed. Pick a stored
backup or upload a <span className="font-mono">.7z</span>, enter the passphrase, check what it contains, then
confirm.
</p>
</div>
<RestoreWizard
backups={backups.map((b) => ({
key: `backup:${b.slug}:${b.fileName}`,
slug: b.slug,
fileName: b.fileName,
createdAt: b.createdAt,
kind: b.kind,
sizeBytes: b.sizeBytes,
}))}
preselected={preselected}
hasStoredPassphrase={hasPassphrase}
maxUploadBytes={maxUploadBytes}
/>
</div>
);
}
@@ -0,0 +1,296 @@
"use client";
import Link from "next/link";
import { useRef, useState, useTransition } from "react";
import { JobProgress } from "../job-progress";
import { PassphraseNotice } from "../passphrase-notice";
import { inspectRestoreAction, startRestoreAction, type RestorePreview } from "./actions";
interface BackupOption {
key: string;
slug: string;
fileName: string;
createdAt: string;
kind: string;
sizeBytes: number;
}
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export function RestoreWizard({
backups,
preselected,
hasStoredPassphrase,
maxUploadBytes,
}: {
backups: BackupOption[];
preselected: string | null;
hasStoredPassphrase: boolean;
maxUploadBytes: number;
}) {
const [mode, setMode] = useState<"stored" | "upload">(backups.length === 0 ? "upload" : "stored");
const [selected, setSelected] = useState(preselected ?? backups[0]?.key ?? "");
const [uploadKey, setUploadKey] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [uploadName, setUploadName] = useState<string | null>(null);
const [passphrase, setPassphrase] = useState("");
const [preview, setPreview] = useState<RestorePreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [replace, setReplace] = useState(false);
const [confirmSlug, setConfirmSlug] = useState("");
const [jobId, setJobId] = useState<string | null>(null);
const [restoredSlug, setRestoredSlug] = useState<string | null>(null);
const [newPassphrase, setNewPassphrase] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const xhrRef = useRef<XMLHttpRequest | null>(null);
const sourceKey = mode === "upload" ? uploadKey : selected;
const resetPreview = () => {
setPreview(null);
setError(null);
};
function upload(file: File) {
resetPreview();
setUploadKey(null);
if (file.size > maxUploadBytes) {
setError(`That file is ${formatBytes(file.size)}, over the ${formatBytes(maxUploadBytes)} upload limit. Copy it into the server's backups folder instead.`);
return;
}
setUploadName(file.name);
setUploadProgress(0);
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.open("POST", "/api/backups/upload");
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 100));
};
xhr.onload = () => {
setUploadProgress(null);
try {
const body = JSON.parse(xhr.responseText) as { uploadId?: string; error?: string };
if (xhr.status === 200 && body.uploadId) setUploadKey(`upload:${body.uploadId}`);
else setError(body.error ?? "Upload failed.");
} catch {
setError("Upload failed.");
}
};
xhr.onerror = () => {
setUploadProgress(null);
setError("Upload failed — the connection dropped.");
};
xhr.send(file);
}
function inspect() {
if (!sourceKey) return;
resetPreview();
startTransition(async () => {
const result = await inspectRestoreAction(sourceKey, passphrase);
if (result.error) setError(result.error);
setPreview(result.preview);
});
}
function start() {
if (!sourceKey || !preview) return;
setError(null);
startTransition(async () => {
const result = await startRestoreAction(sourceKey, passphrase, replace, confirmSlug);
if (result.error) {
setError(result.error);
return;
}
setNewPassphrase(result.newPassphrase);
setRestoredSlug(preview.slug);
setJobId(result.jobId);
});
}
const blocked = preview ? Boolean(preview.domainConflict || preview.schemaProblem) : false;
const replaceConfirmed = !preview?.slugExists || (replace && confirmSlug === preview.slug);
if (jobId) {
return (
<div className="space-y-4">
{newPassphrase && <PassphraseNotice passphrase={newPassphrase} />}
<JobProgress jobId={jobId}>
{restoredSlug && (
<Link href={`/dashboard/${restoredSlug}`} className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
Open {restoredSlug}
</Link>
)}
</JobProgress>
</div>
);
}
return (
<div className="space-y-6">
<fieldset className="space-y-3">
<legend className="text-sm font-semibold">1. Choose the backup</legend>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="radio"
checked={mode === "stored"}
onChange={() => {
setMode("stored");
resetPreview();
}}
disabled={backups.length === 0}
/>
Stored on this server
</label>
<label className="flex items-center gap-2">
<input
type="radio"
checked={mode === "upload"}
onChange={() => {
setMode("upload");
resetPreview();
}}
/>
Upload a .7z
</label>
</div>
{mode === "stored" ? (
<select
value={selected}
onChange={(e) => {
setSelected(e.target.value);
resetPreview();
}}
className="w-full rounded-md border border-line px-3 py-2 font-mono text-xs"
aria-label="Stored backup"
>
{backups.map((b) => (
<option key={b.key} value={b.key}>
{b.slug} — {new Date(b.createdAt).toLocaleString()} — {formatBytes(b.sizeBytes)} — {b.kind}
</option>
))}
</select>
) : (
<div className="space-y-2">
<input
type="file"
accept=".7z"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload(file);
}}
className="block w-full text-sm"
/>
<p className="text-xs text-muted">Up to {formatBytes(maxUploadBytes)}. Streamed to the server, not held in memory.</p>
{uploadProgress !== null && (
<div className="h-2 w-full overflow-hidden rounded bg-stone" role="progressbar" aria-valuenow={uploadProgress} aria-valuemin={0} aria-valuemax={100}>
<div className="h-full bg-accent" style={{ width: `${uploadProgress}%` }} />
</div>
)}
{uploadKey && <p className="text-xs text-green-700">Uploaded {uploadName}.</p>}
</div>
)}
</fieldset>
<div className="space-y-2">
<label htmlFor="passphrase" className="block text-sm font-semibold">
2. Passphrase
</label>
<input
id="passphrase"
type="password"
autoComplete="off"
value={passphrase}
onChange={(e) => {
setPassphrase(e.target.value);
resetPreview();
}}
placeholder={hasStoredPassphrase ? "Leave blank to use this server's stored passphrase" : "Required on a fresh server"}
className="w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
/>
<button
type="button"
onClick={inspect}
disabled={isPending || !sourceKey || (!passphrase && !hasStoredPassphrase)}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending && !preview ? "Checking…" : "Check this backup"}
</button>
</div>
{error && (
<p role="alert" className="text-sm font-medium text-danger">
{error}
</p>
)}
{preview && (
<div className="space-y-4">
<h2 className="text-sm font-semibold">3. Review and confirm</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-md border border-line bg-surface p-4 text-sm">
<dt className="text-muted">Business</dt>
<dd>{preview.name ?? preview.slug}</dd>
<dt className="text-muted">Slug</dt>
<dd className="font-mono">{preview.slug}</dd>
<dt className="text-muted">Domain</dt>
<dd className="font-mono">{preview.domain}</dd>
<dt className="text-muted">Backup taken</dt>
<dd>{new Date(preview.createdAt).toLocaleString()}</dd>
<dt className="text-muted">Schema</dt>
<dd className="font-mono text-xs">{preview.lastMigration ?? "unknown"}</dd>
</dl>
{preview.domainConflict && (
<p role="alert" className="text-sm font-medium text-danger">
{preview.domainConflict}
</p>
)}
{preview.schemaProblem && (
<p role="alert" className="text-sm font-medium text-danger">
{preview.schemaProblem}
</p>
)}
{preview.slugExists ? (
<div className="space-y-2 rounded-md border border-danger/30 bg-danger/5 p-4 text-sm">
<p className="font-semibold text-danger">
&ldquo;{preview.slug}&rdquo; already exists on this server. Restoring REPLACES its database, media and configuration.
</p>
<p className="text-muted">A safety backup of the current state is taken first.</p>
<label className="flex items-center gap-2">
<input type="checkbox" checked={replace} onChange={(e) => setReplace(e.target.checked)} />
Replace the existing business
</label>
<input
type="text"
value={confirmSlug}
onChange={(e) => setConfirmSlug(e.target.value)}
placeholder={`Type ${preview.slug} to confirm`}
autoComplete="off"
spellCheck={false}
className="w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
/>
</div>
) : (
<p className="text-sm text-muted">No business named &ldquo;{preview.slug}&rdquo; exists here — it will be created from the backup.</p>
)}
<button
type="button"
onClick={start}
disabled={isPending || blocked || !replaceConfirmed}
className="rounded-md bg-danger px-4 py-2 text-sm font-semibold text-white disabled:opacity-40"
>
{isPending ? "Starting…" : preview.slugExists ? "Replace and restore" : "Restore"}
</button>
</div>
)}
</div>
);
}