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,167 @@
"use client";
import { useRouter } from "next/navigation";
import { useActionState, useEffect } from "react";
import type { OperatorSettings } from "@/lib/global-settings";
import type { SchedulerStatus } from "@/lib/scheduler-status";
import {
saveOperatorSettingsAction,
sendTestAlertAction,
type SaveOperatorSettingsState,
type SendTestAlertState,
} from "./actions";
const saveInitialState: SaveOperatorSettingsState = { error: null, saved: false };
const testInitialState: SendTestAlertState = { result: null };
function Toggle({ name, label, hint, defaultChecked }: { name: string; label: string; hint?: string; defaultChecked: boolean }) {
return (
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="mt-0.5" />
<span>
{label}
{hint && <span className="block text-xs text-muted">{hint}</span>}
</span>
</label>
);
}
export function OperatorSettingsForm({ initial, status }: { initial: OperatorSettings; status: SchedulerStatus }) {
const router = useRouter();
const [saveState, saveAction, isSaving] = useActionState(saveOperatorSettingsAction, saveInitialState);
const [testState, testAction, isTesting] = useActionState(sendTestAlertAction, testInitialState);
useEffect(() => {
if (saveState.saved) router.refresh();
}, [saveState, router]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Monitoring &amp; scheduler</h2>
{initial.alertEmail ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Alerts on</span>
) : (
<span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Alerts off</span>
)}
</div>
<p className="text-sm text-muted">
The Manager calls every business&apos;s notification cron, checks its <span className="font-mono">/api/health</span> and runs scheduled
backups itself. Alerts are emailed with the SMTP settings above; a business must fail two checks in a row before you hear about it.
</p>
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
<div>
<label htmlFor="alertEmail" className="block text-sm font-medium text-muted">
Operator alert email
</label>
<input
id="alertEmail"
name="alertEmail"
type="email"
defaultValue={initial.alertEmail}
placeholder="you@example.com — blank turns alerts off"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<Toggle name="alertOnHealth" label="Email when a business goes degraded or down" defaultChecked={initial.alertOnHealth} />
<Toggle name="alertOnRecovery" label="Email when it recovers" defaultChecked={initial.alertOnRecovery} />
<Toggle name="dailyReminder" label="Remind me daily while it is still bad" defaultChecked={initial.dailyReminder} />
<Toggle name="alertOnBackupFailure" label="Email when a scheduled backup fails" defaultChecked={initial.alertOnBackupFailure} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="notificationsIntervalSeconds" className="block text-sm font-medium text-muted">
Notification cron every (seconds)
</label>
<input
id="notificationsIntervalSeconds"
name="notificationsIntervalSeconds"
type="number"
min={10}
max={3600}
required
defaultValue={initial.notificationsIntervalSeconds}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="healthIntervalMinutes" className="block text-sm font-medium text-muted">
Health check every (minutes)
</label>
<input
id="healthIntervalMinutes"
name="healthIntervalMinutes"
type="number"
min={1}
max={60}
required
defaultValue={initial.healthIntervalMinutes}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
</div>
{saveState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{saveState.error}
</p>
)}
{saveState.saved && !saveState.error && <p className="text-sm text-green-700">Saved.</p>}
<div className="flex flex-wrap gap-3 pt-2">
<button
type="submit"
formAction={saveAction}
disabled={isSaving}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isSaving ? "Saving…" : "Save Changes"}
</button>
<button
type="submit"
formAction={testAction}
disabled={isTesting || !initial.alertEmail}
title={!initial.alertEmail ? "Save an alert email first" : "Sends to the saved address"}
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
>
{isTesting ? "Sending…" : "Send test alert"}
</button>
</div>
{testState.result && (
<p role="status" className={`text-sm ${testState.result.ok ? "text-green-700" : "font-medium text-danger"}`}>
{testState.result.message}
</p>
)}
</form>
<div className="rounded-md border border-line bg-surface p-5">
<h3 className="text-sm font-semibold">Scheduler status</h3>
<p className="text-xs text-muted">
Last tick: {status.heartbeatAt ? new Date(status.heartbeatAt).toLocaleString() : "never (scheduler not running?)"}
</p>
{status.rows.length > 0 && (
<table className="mt-3 w-full text-left text-xs">
<thead className="text-muted">
<tr>
<th className="py-1 pr-3 font-medium">Task</th>
<th className="py-1 pr-3 font-medium">Last run</th>
<th className="py-1 font-medium">Result</th>
</tr>
</thead>
<tbody>
{status.rows.map(({ key, state }) => (
<tr key={key} className="border-t border-line">
<td className="py-1 pr-3 font-mono">{key}</td>
<td className="py-1 pr-3">{state.lastFinishedAt ? new Date(state.lastFinishedAt).toLocaleString() : "running / never"}</td>
<td className={`py-1 ${state.lastOk === false ? "text-danger" : ""}`}>{state.lastMessage ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}