Remaining features
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”.
This commit is contained in:
Stefan Lazic committed 2026-10-17 23:10:18 +01:00
1 parent 17eee5b932
commit 68a62e02a4
116 files changed
+3056 -733

No files matched your search

+6 -2
View File
@@ -53,10 +53,14 @@ INSTALLATION_TOKEN="replace-me"
# generate with: openssl rand -hex 32. Without something actually calling # generate with: openssl rand -hex 32. Without something actually calling
# those on a schedule, queued notifications (staff invites, reminders, # those on a schedule, queued notifications (staff invites, reminders,
# etc.) never get sent — see the root README's "Notification delivery" # etc.) never get sent — see the root README's "Notification delivery"
# section and stacks/infra/scripts/process-cron.sh for the one-time # section: the Manager's built-in scheduler normally does this (the host
# crontab setup this requires. # crontab script is only a fallback).
CRON_SECRET="replace-me" CRON_SECRET="replace-me"
# Where the app emails unhandled server errors (one email per distinct error per hour, max 20 a day).
# The Manager fills this in from Settings > Monitoring. Blank = no error emails.
OPERATOR_ALERT_EMAIL=""
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ── # ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
# Platform-level account with every Owner permission for THIS business. # Platform-level account with every Owner permission for THIS business.
# Runs automatically on every container start (see docker/entrypoint.sh) # Runs automatically on every container start (see docker/entrypoint.sh)
+46 -38
View File
@@ -138,54 +138,62 @@ this for you), then finish its setup by visiting
Repeat this step for each additional business, whenever you want one. Repeat this step for each additional business, whenever you want one.
### 6. Notification delivery (required once, covers every business) ### 6. Notification delivery (handled by the Manager)
Staff invitation emails, appointment reminders, loyalty reward emails, Staff invitation emails, appointment reminders, push notifications,
and Google Reviews sync are **not sent automatically** — the app only loyalty reward emails, WhatsApp-reminder nudges and Google Reviews sync are
queues them; something has to actually trigger delivery on a schedule by **not sent by the app itself** — it only queues them; something has to
calling two `CRON_SECRET`-gated endpoints trigger delivery on a schedule by calling two `CRON_SECRET`-gated
(`/api/cron/process-notifications`, `/api/cron/sync-google-reviews` — see endpoints (`/api/cron/process-notifications`,
their own route files for the full reasoning). Skip this step and every `/api/cron/sync-google-reviews` — see their route files for the reasoning).
business's queued notifications just sit as `PENDING` forever — the UI Without a trigger, every business's queued notifications sit as `PENDING`
still says "an invitation email has been sent" the moment it's queued, forever, even though the UI says "sent" the moment something is queued.
regardless of whether anything has actually delivered it yet.
[stacks/infra/scripts/process-cron.sh](stacks/infra/scripts/process-cron.sh) **The Manager's built-in scheduler does this for you** — nothing to
calls both endpoints for **every** business under `STACKS_DIR` in one install on the host. Once the Manager is running it calls every
run, so a single crontab entry covers businesses added later too — no business's notification cron every 30 seconds (adjustable at Settings →
per-business cron line to maintain. Monitoring & scheduler, 10 s – 1 hour) and the reviews sync once a day,
including for businesses added later. That page lists the last run of each
task per business, so you can confirm it's working.
Many minimal cloud VPS images (including some Debian ones) don't ship Each business's `/api/health` also reports whether notifications are
`cron` at all — `crontab -e` failing with `command not found` means this actually going out (`status: "degraded"` plus the reasons, when the
first: processor hasn't run for 10 minutes, deliveries are stuck, or every delivery
in the last day failed), and the Manager emails you when that happens.
#### Removing the old crontab
If you set up the host crontab from an earlier version of this guide, remove
it once the Manager scheduler is confirmed working — running both is safe
(deliveries are claimed one at a time, so nothing is sent twice) but
pointless:
1. Open Settings → Monitoring & scheduler in the Manager and check that
"Process notifications" shows a recent successful run for each business.
2. On the VPS, as the user that owns `/opt/nextchair`, run `crontab -e` and
delete the two `process-cron.sh` lines (notifications and reviews).
3. Optionally delete `/opt/nextchair/nextchair-cron.log`.
#### Fallback: host crontab
Only if you run with `MANAGER_SCHEDULER_DISABLED=1`, or want an independent
trigger. [stacks/infra/scripts/process-cron.sh](stacks/infra/scripts/process-cron.sh)
calls both endpoints for **every** business under `STACKS_DIR` in one run.
Many minimal VPS images don't ship `cron` — install it first
(`sudo apt update && sudo apt install -y cron && sudo systemctl enable --now cron`),
then add this with `crontab -e` as the user that owns `/opt/nextchair`
(**not** `sudo crontab -e`, so the log file is writable):
```bash ```bash
sudo apt update && sudo apt install -y cron # Deliver queued notifications every minute (the finest a crontab can do).
sudo systemctl enable --now cron * * * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh notifications >> /opt/nextchair/nextchair-cron.log 2>&1
```
Then add this to the VPS's crontab (`crontab -e`) once — **not** `sudo
crontab -e`: run it as the same non-root user that already owns
`/opt/nextchair` (the one you've been running `git pull`/`docker build`
as), so the log file below is writable without needing root:
```bash
# Deliver queued notifications every 2 minutes.
*/2 * * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh notifications >> /opt/nextchair/nextchair-cron.log 2>&1
# Refresh Google Reviews once a day. # Refresh Google Reviews once a day.
0 3 * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh reviews >> /opt/nextchair/nextchair-cron.log 2>&1 0 3 * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh reviews >> /opt/nextchair/nextchair-cron.log 2>&1
``` ```
(Adjust `STACKS_DIR`/the log path if you cloned this repo somewhere other A healthy run logs `<slug> process-notifications ok` for every business. Use
than `/opt/nextchair`, or don't have write access to it — `/var/log/` only one of the two mechanisms at a time as your normal setup.
itself is **not** a safe default here: it needs root, and a crontab
entry that can't write its own log file just silently produces no file
at all, which looks identical to "the job never ran.") To confirm it's
working, or to flush a notification that's stuck pending right now, run
either line's command by hand and check
`/opt/nextchair/nextchair-cron.log` — a healthy run logs `<slug>
process-notifications ok` for every business.
## Updating after a code change ## Updating after a code change
+2 -2
View File
@@ -157,10 +157,10 @@ npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres
## 19. Known limitations ## 19. Known limitations
- **Working-hours editing has no conflict detection**, on both the self-service (Phase 5) and owner-on-behalf-of-staff (this phase) editors — unlike breaks, time off, business hours, staff deactivation, and service-eligibility removal, all of which do. A pre-existing Phase 5 gap this phase chose not to retroactively fix, given the size of the remaining Phase 6 scope; flagged again here rather than silently carried forward. - **Working-hours editing had no conflict detection** (both the self-service and owner-on-behalf editors) — **closed in Phase 7** ("Working-hours conflict review": `findWorkingHoursConflicts` / `setWorkingHours({ acknowledgeConflicts })` in `staff.service.ts`, the conflict list in `working-hours-form.tsx`, covered by `staff.service.integration.test.ts`). Kept here only as history.
- **~~Owner-editable breaks/whole-day time off for a staff member were not built~~** — *resolved.* Owner-on-behalf-of editing of a staff member's breaks and whole-day time off was built in Phase 7 (Manage → Team → staff → Breaks / Time Off). The one remaining piece, editing and deleting partial-day "Block Time" entries (which `OwnerBlockTimeButton` creates), now also works: they are listed on the same Time Off page, for the owner (`MANAGE_STAFF`) and for staff on their own Time Off settings page, using the same update/delete actions and conflict review. - **~~Owner-editable breaks/whole-day time off for a staff member were not built~~** — *resolved.* Owner-on-behalf-of editing of a staff member's breaks and whole-day time off was built in Phase 7 (Manage → Team → staff → Breaks / Time Off). The one remaining piece, editing and deleting partial-day "Block Time" entries (which `OwnerBlockTimeButton` creates), now also works: they are listed on the same Time Off page, for the owner (`MANAGE_STAFF`) and for staff on their own Time Off settings page, using the same update/delete actions and conflict review.
- **Staff service eligibility is edited only from the Staff Detail page**, not duplicated on the Services & Categories page (the brief mentions "assign eligible staff" under Services too) — one edit surface, avoiding two places that could drift out of sync. - **Staff service eligibility is edited only from the Staff Detail page**, not duplicated on the Services & Categories page (the brief mentions "assign eligible staff" under Services too) — one edit surface, avoiding two places that could drift out of sync.
- **"Requires staff approval to redeem" is informational text only** in the loyalty config UI — redemption is already gated behind the `MANAGE_LOYALTY_STAMPS` permission and there is no customer-initiated redemption request to approve against, so no separate approval-queue workflow exists. - **"Requires staff approval to redeem" and "Apply reward automatically" were removed from the loyalty settings form** — neither did anything (redemption is always staff-initiated and gated by `MANAGE_LOYALTY_STAMPS`). The two `LoyaltyProgramme` columns still exist, unused.
- **A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent**: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (`appt-today-checkedin`, timed via `minutesFromNow` to reliably exercise `selectCurrentAppointment`'s CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does **not** affect a single authoritative run against a freshly seeded database, which is how CI should always run it. - **A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent**: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (`appt-today-checkedin`, timed via `minutesFromNow` to reliably exercise `selectCurrentAppointment`'s CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does **not** affect a single authoritative run against a freshly seeded database, which is how CI should always run it.
- **Notification delivery, payments, deposits, advanced financial reporting, payroll, multi-business/multi-location, PWA, and offline** remain entirely out of scope, per the brief's own boundary. - **Notification delivery, payments, deposits, advanced financial reporting, payroll, multi-business/multi-location, PWA, and offline** remain entirely out of scope, per the brief's own boundary.
+1 -1
View File
@@ -30,7 +30,7 @@ ENV NODE_ENV=production
# stacks/infra/compose.yaml's manager service (docker.sock). # stacks/infra/compose.yaml's manager service (docker.sock).
RUN apk add --no-cache docker-cli docker-cli-compose RUN apk add --no-cache docker-cli docker-cli-compose
# 7-Zip for encrypted backups (`7zz a -mhe=on -p…`, see src/lib/sevenzip.ts). # 7-Zip for backups (`7zz a`, plain 7z — see src/lib/sevenzip.ts).
# Alpine's package is named `7zip` and installs /usr/bin/7zz (checked # Alpine's package is named `7zip` and installs /usr/bin/7zz (checked
# against node:22-alpine, Alpine 3.24: `apk add 7zip` → 7-Zip 26.01). # against node:22-alpine, Alpine 3.24: `apk add 7zip` → 7-Zip 26.01).
RUN apk add --no-cache 7zip RUN apk add --no-cache 7zip
+8 -7
View File
@@ -63,16 +63,17 @@ service. State lives next to `global-settings.json`, under
over; on boot, jobs left "running" are marked failed and their locks over; on boot, jobs left "running" are marked failed and their locks
released. released.
- **Backups** (`src/lib/backup*.ts`, `restore.ts`; UI at a business's - **Backups** (`src/lib/backup*.ts`, `restore.ts`; UI at a business's
*Backups* page and the global *Restore* page): an encrypted `.7z` *Backups* page and the global *Restore* page): a plain `.7z`
(`7zz`, AES-256, encrypted headers) of the database dump, media and (`7zz`, **not password-protected**) of the database dump, media and
config, in `STACKS_DIR/backups/<slug>/`. Written as `.partial`, verified config, in `STACKS_DIR/backups/<slug>/`. Written as `.partial`, verified
with `7zz t`, then renamed; the newest N (default 2, per business) are with `7zz t`, then renamed; the newest N (default 2, per business) are
kept. Safety backups (before an update or a replacing restore) and final kept. Safety backups (before an update or a replacing restore) and final
backups (before a delete) are kept separately and never evict them. One backups (before a delete) are kept separately and never evict them.
passphrase protects everything: generated at the first enable / first Because there is no password, an archive holds the business's secrets in
*Back up now*, stored encrypted (`MANAGER_SETTINGS_ENCRYPTION_KEY`), the clear (its `.env`: database password, auth secret, SMTP password,
**shown once** — save it, a fresh server needs it to restore. Restore VAPID private key) — files are written owner-only (0600) and the
works onto an existing business (after a safety backup) or a server that download route is behind the Manager login; keep any downloaded copy as
private as the server itself. Restore works onto an existing business (after a safety backup) or a server that
never had it; see `RESTORE_STEPS` in `src/lib/restore.ts`. A backup never had it; see `RESTORE_STEPS` in `src/lib/restore.ts`. A backup
whose last migration is newer than the app image's is refused. whose last migration is newer than the app image's is refused.
- **Alerts** (`src/lib/health-watcher.ts`): per-business ok / degraded / - **Alerts** (`src/lib/health-watcher.ts`): per-business ok / degraded /
@@ -6,7 +6,7 @@ import { backupFilePath } from "@/lib/backup-store";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
/** Streams a stored archive as a download — never buffered in memory, since archives can be large. The .7z is encrypted; the download itself exposes nothing without the passphrase. */ /** Streams a stored archive as a download — never buffered in memory, since archives can be large. The .7z is NOT password-protected and holds the business's secrets, so this route sits behind the Manager login. */
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string; file: string }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ slug: string; file: string }> }) {
const { slug, file } = await params; const { slug, file } = await params;
+1 -2
View File
@@ -6,8 +6,7 @@ export const dynamic = "force-dynamic";
/** /**
* Polled by the JobProgress component. Behind the Manager login like * Polled by the JobProgress component. Behind the Manager login like
* every route (see proxy.ts). A job record holds step names, a log tail * every route (see proxy.ts). A job record holds step names, a log tail
* and a result summary — never secrets (the restore passphrase only ever * and a result summary — never secrets.
* lives in the running job's closure).
*/ */
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
+1 -4
View File
@@ -4,7 +4,6 @@ import path from "node:path";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker"; import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
import { upsertEnvValues } from "@/lib/env-file"; import { upsertEnvValues } from "@/lib/env-file";
import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings";
import { setIntentionallyStopped } from "@/lib/health-watcher"; import { setIntentionallyStopped } from "@/lib/health-watcher";
import { fetchInstallationStatus } from "@/lib/installation-status"; import { fetchInstallationStatus } from "@/lib/installation-status";
import { NO_JOB, type JobStartState } from "@/lib/job-state"; import { NO_JOB, type JobStartState } from "@/lib/job-state";
@@ -55,10 +54,8 @@ export async function restartStackAction(slug: string): Promise<void> {
export async function updateStackAction(slug: string, backupFirst: boolean): Promise<JobStartState> { export async function updateStackAction(slug: string, backupFirst: boolean): Promise<JobStartState> {
await assertStack(slug); await assertStack(slug);
try { try {
// "Back up first" needs a passphrase; if this is the very first backup anywhere, it is minted here and shown once.
const newPassphrase = backupFirst && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
const { jobId } = await startUpdateJob(slug, backupFirst); const { jobId } = await startUpdateJob(slug, backupFirst);
return { error: null, jobId, newPassphrase }; return { error: null, jobId };
} catch (error) { } catch (error) {
if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message }; if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message };
return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the update." }; return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the update." };
@@ -3,7 +3,6 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { saveBackupSettings, validateBackupSettingsInput, type BackupFrequency } from "@/lib/backup-settings"; import { saveBackupSettings, validateBackupSettingsInput, type BackupFrequency } from "@/lib/backup-settings";
import { deleteBackup } from "@/lib/backup-store"; import { deleteBackup } from "@/lib/backup-store";
import { ensureBackupPassphrase, hasBackupPassphrase, rotateBackupPassphrase } from "@/lib/global-settings";
import { NO_JOB, type JobStartState } from "@/lib/job-state"; import { NO_JOB, type JobStartState } from "@/lib/job-state";
import { assertNotLocked, BusinessLockedError } from "@/lib/locks"; import { assertNotLocked, BusinessLockedError } from "@/lib/locks";
import { startBackupJob } from "@/lib/operations"; import { startBackupJob } from "@/lib/operations";
@@ -16,8 +15,6 @@ async function assertBusiness(slug: string): Promise<void> {
export interface SaveBackupSettingsState { export interface SaveBackupSettingsState {
error: string | null; error: string | null;
saved: boolean; saved: boolean;
/** Plaintext passphrase — only in the one response that just generated it (first enable anywhere). */
newPassphrase: string | null;
} }
export async function saveBackupSettingsAction( export async function saveBackupSettingsAction(
@@ -35,26 +32,18 @@ export async function saveBackupSettingsAction(
keep: Number(formData.get("keep") ?? 2), keep: Number(formData.get("keep") ?? 2),
}; };
const problem = validateBackupSettingsInput(input); const problem = validateBackupSettingsInput(input);
if (problem) return { error: problem, saved: false, newPassphrase: null }; if (problem) return { error: problem, saved: false };
// Generated at the first enable, encrypted with the Manager's key, shown once.
let newPassphrase: string | null = null;
if (input.enabled) {
const ensured = await ensureBackupPassphrase();
if (ensured.created) newPassphrase = ensured.passphrase;
}
await saveBackupSettings(slug, input); await saveBackupSettings(slug, input);
revalidatePath(`/dashboard/${slug}/backups`); revalidatePath(`/dashboard/${slug}/backups`);
return { error: null, saved: true, newPassphrase }; return { error: null, saved: true };
} }
export async function backupNowAction(slug: string): Promise<JobStartState> { export async function backupNowAction(slug: string): Promise<JobStartState> {
await assertBusiness(slug); await assertBusiness(slug);
try { try {
const newPassphrase = (await hasBackupPassphrase()) ? null : (await ensureBackupPassphrase()).passphrase;
const { jobId } = await startBackupJob(slug, "regular"); const { jobId } = await startBackupJob(slug, "regular");
return { error: null, jobId, newPassphrase }; return { error: null, jobId };
} catch (error) { } catch (error) {
if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message }; if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message };
return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the backup." }; return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the backup." };
@@ -71,12 +60,3 @@ export async function deleteBackupAction(slug: string, fileName: string): Promis
return { error: error instanceof Error ? error.message : "Couldn't delete that backup." }; return { error: error instanceof Error ? error.message : "Couldn't delete that backup." };
} }
} }
/** Replaces the passphrase used for NEW backups. Older archives still need the old one — the UI says so. Returns the new value once. */
export async function rotatePassphraseAction(): Promise<{ error: string | null; newPassphrase: string | null }> {
try {
return { error: null, newPassphrase: await rotateBackupPassphrase() };
} catch (error) {
return { error: error instanceof Error ? error.message : "Couldn't generate a passphrase.", newPassphrase: null };
}
}
@@ -5,11 +5,9 @@ import { useRouter } from "next/navigation";
import { useActionState, useState, useTransition } from "react"; import { useActionState, useState, useTransition } from "react";
import type { BackupEntry } from "@/lib/backup-store"; import type { BackupEntry } from "@/lib/backup-store";
import { JobProgress } from "../../job-progress"; import { JobProgress } from "../../job-progress";
import { PassphraseNotice } from "../../passphrase-notice";
import { import {
backupNowAction, backupNowAction,
deleteBackupAction, deleteBackupAction,
rotatePassphraseAction,
saveBackupSettingsAction, saveBackupSettingsAction,
type SaveBackupSettingsState, type SaveBackupSettingsState,
} from "./actions"; } from "./actions";
@@ -27,7 +25,7 @@ interface SettingsView {
} }
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const initialSaveState: SaveBackupSettingsState = { error: null, saved: false, newPassphrase: null }; const initialSaveState: SaveBackupSettingsState = { error: null, saved: false };
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
@@ -50,16 +48,12 @@ export function BackupsPanel({
slug, slug,
settings, settings,
backups, backups,
hasPassphrase,
passphraseCreatedAt,
activeJobId, activeJobId,
timeZone, timeZone,
}: { }: {
slug: string; slug: string;
settings: SettingsView; settings: SettingsView;
backups: BackupEntry[]; backups: BackupEntry[];
hasPassphrase: boolean;
passphraseCreatedAt: string | null;
activeJobId: string | null; activeJobId: string | null;
timeZone: string; timeZone: string;
}) { }) {
@@ -70,8 +64,6 @@ export function BackupsPanel({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(activeJobId); const [jobId, setJobId] = useState<string | null>(activeJobId);
const [jobRunning, setJobRunning] = useState(activeJobId !== null); const [jobRunning, setJobRunning] = useState(activeJobId !== null);
const [shownPassphrase, setShownPassphrase] = useState<string | null>(null);
const passphraseToShow = shownPassphrase ?? saveState.newPassphrase;
const backupNow = () => { const backupNow = () => {
setError(null); setError(null);
@@ -81,7 +73,6 @@ export function BackupsPanel({
setError(result.error); setError(result.error);
return; return;
} }
if (result.newPassphrase) setShownPassphrase(result.newPassphrase);
setJobId(result.jobId); setJobId(result.jobId);
setJobRunning(true); setJobRunning(true);
}); });
@@ -97,25 +88,8 @@ export function BackupsPanel({
}); });
}; };
const rotate = () => {
if (
!window.confirm(
"Generate a NEW passphrase? New backups will use it. Existing backups keep the old one — you will need both to open all of them.",
)
) {
return;
}
startTransition(async () => {
const result = await rotatePassphraseAction();
if (result.error) setError(result.error);
else setShownPassphrase(result.newPassphrase);
});
};
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{passphraseToShow && <PassphraseNotice passphrase={passphraseToShow} />}
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-lg font-semibold">Schedule</h2> <h2 className="text-lg font-semibold">Schedule</h2>
<form action={saveAction} className="space-y-4 rounded-md border border-line bg-surface p-5"> <form action={saveAction} className="space-y-4 rounded-md border border-line bg-surface p-5">
@@ -251,17 +225,12 @@ export function BackupsPanel({
</section> </section>
<section className="space-y-2 text-sm text-muted"> <section className="space-y-2 text-sm text-muted">
<h2 className="text-lg font-semibold text-ink">Passphrase</h2> <h2 className="text-lg font-semibold text-ink">Handle with care</h2>
<p> <p>
{hasPassphrase Backups are not password-protected. Each one contains this business&apos;s secrets (its <span className="font-mono">.env</span>: database
? `A passphrase protects every backup${passphraseCreatedAt ? ` (created ${new Date(passphraseCreatedAt).toLocaleDateString()})` : ""}. It is stored encrypted here and cannot be shown again — keep your saved copy; a fresh server needs it to restore.` password, sign-in secret, email password, push keys) as well as its data. They are stored owner-only on this server; if you download one,
: "No passphrase yet — one is generated (and shown once) the first time you enable backups or press Back up now."} keep it somewhere as private as the server itself.
</p> </p>
{hasPassphrase && (
<button type="button" onClick={rotate} disabled={isPending} className="font-medium text-ink underline disabled:opacity-60">
Generate a new passphrase…
</button>
)}
</section> </section>
</div> </div>
); );
@@ -3,7 +3,6 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { readBackupSettings } from "@/lib/backup-settings"; import { readBackupSettings } from "@/lib/backup-settings";
import { listBackups } from "@/lib/backup-store"; import { listBackups } from "@/lib/backup-store";
import { backupPassphraseCreatedAt, hasBackupPassphrase } from "@/lib/global-settings";
import { getActiveLock } from "@/lib/locks"; import { getActiveLock } from "@/lib/locks";
import { listBusinessStacks } from "@/lib/stacks"; import { listBusinessStacks } from "@/lib/stacks";
import { BackupsPanel } from "./backups-panel"; import { BackupsPanel } from "./backups-panel";
@@ -17,11 +16,9 @@ export default async function BackupsPage({ params }: { params: Promise<{ slug:
const stack = stacks.find((s) => s.slug === slug); const stack = stacks.find((s) => s.slug === slug);
if (!stack) notFound(); if (!stack) notFound();
const [settings, backups, hasPassphrase, passphraseCreatedAt, lock] = await Promise.all([ const [settings, backups, lock] = await Promise.all([
readBackupSettings(slug), readBackupSettings(slug),
listBackups(slug), listBackups(slug),
hasBackupPassphrase(),
backupPassphraseCreatedAt(),
getActiveLock(slug), getActiveLock(slug),
]); ]);
@@ -56,8 +53,6 @@ export default async function BackupsPage({ params }: { params: Promise<{ slug:
lastMessage: settings.lastMessage, lastMessage: settings.lastMessage,
}} }}
backups={backups} backups={backups}
hasPassphrase={hasPassphrase}
passphraseCreatedAt={passphraseCreatedAt}
activeJobId={lock?.jobId ?? null} activeJobId={lock?.jobId ?? null}
timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone} timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
/> />
@@ -2,7 +2,6 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { JobProgress } from "../job-progress"; import { JobProgress } from "../job-progress";
import { PassphraseNotice } from "../passphrase-notice";
import { import {
fetchLogsAction, fetchLogsAction,
restartStackAction, restartStackAction,
@@ -19,7 +18,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob
const [backupFirst, setBackupFirst] = useState(true); const [backupFirst, setBackupFirst] = useState(true);
const [jobId, setJobId] = useState<string | null>(activeJobId); const [jobId, setJobId] = useState<string | null>(activeJobId);
const [jobRunning, setJobRunning] = useState(activeJobId !== null); const [jobRunning, setJobRunning] = useState(activeJobId !== null);
const [newPassphrase, setNewPassphrase] = useState<string | null>(null);
// Quick actions are refused server-side while a job holds the business; the buttons just mirror that. // Quick actions are refused server-side while a job holds the business; the buttons just mirror that.
const busy = isPending || jobRunning; const busy = isPending || jobRunning;
@@ -44,7 +42,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob
setError(result.error); setError(result.error);
return; return;
} }
setNewPassphrase(result.newPassphrase);
setJobId(result.jobId); setJobId(result.jobId);
setJobRunning(true); setJobRunning(true);
setUpdateOpen(false); setUpdateOpen(false);
@@ -139,7 +136,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob
{error} {error}
</p> </p>
)} )}
{newPassphrase && <PassphraseNotice passphrase={newPassphrase} />}
{jobId && <JobProgress key={jobId} jobId={jobId} onFinished={() => setJobRunning(false)} />} {jobId && <JobProgress key={jobId} jobId={jobId} onFinished={() => setJobRunning(false)} />}
{logs !== null && ( {logs !== null && (
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre> <pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
@@ -1,6 +1,5 @@
"use server"; "use server";
import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings";
import { BusinessLockedError } from "@/lib/locks"; import { BusinessLockedError } from "@/lib/locks";
import { startDeleteJob } from "@/lib/operations"; import { startDeleteJob } from "@/lib/operations";
@@ -8,8 +7,6 @@ export interface DeleteBusinessState {
error: string | null; error: string | null;
/** Set once the delete job has started — the form then follows it with <JobProgress>. */ /** Set once the delete job has started — the form then follows it with <JobProgress>. */
jobId: string | null; jobId: string | null;
/** Plaintext backup passphrase, only in the response that just generated it. */
newPassphrase: string | null;
} }
/** /**
@@ -28,16 +25,15 @@ export async function deleteBusinessAction(
): Promise<DeleteBusinessState> { ): Promise<DeleteBusinessState> {
const confirmation = String(formData.get("confirmation") ?? ""); const confirmation = String(formData.get("confirmation") ?? "");
if (confirmation !== slug) { if (confirmation !== slug) {
return { error: `Type "${slug}" exactly to confirm deletion.`, jobId: null, newPassphrase: null }; return { error: `Type "${slug}" exactly to confirm deletion.`, jobId: null };
} }
const finalBackup = formData.get("finalBackup") === "on"; const finalBackup = formData.get("finalBackup") === "on";
try { try {
const newPassphrase = finalBackup && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
const { jobId } = await startDeleteJob(slug, finalBackup); const { jobId } = await startDeleteJob(slug, finalBackup);
return { error: null, jobId, newPassphrase }; return { error: null, jobId };
} catch (error) { } catch (error) {
if (error instanceof BusinessLockedError) return { error: error.message, jobId: null, newPassphrase: null }; if (error instanceof BusinessLockedError) return { error: error.message, jobId: null };
return { error: error instanceof Error ? error.message : "Failed to delete this business.", jobId: null, newPassphrase: null }; return { error: error instanceof Error ? error.message : "Failed to delete this business.", jobId: null };
} }
} }
@@ -3,10 +3,9 @@
import Link from "next/link"; import Link from "next/link";
import { useActionState, useState } from "react"; import { useActionState, useState } from "react";
import { JobProgress } from "../../job-progress"; import { JobProgress } from "../../job-progress";
import { PassphraseNotice } from "../../passphrase-notice";
import { deleteBusinessAction, type DeleteBusinessState } from "./actions"; import { deleteBusinessAction, type DeleteBusinessState } from "./actions";
const initialState: DeleteBusinessState = { error: null, jobId: null, newPassphrase: null }; const initialState: DeleteBusinessState = { error: null, jobId: null };
export function DeleteBusinessForm({ slug }: { slug: string }) { export function DeleteBusinessForm({ slug }: { slug: string }) {
const boundAction = deleteBusinessAction.bind(null, slug); const boundAction = deleteBusinessAction.bind(null, slug);
@@ -17,7 +16,6 @@ export function DeleteBusinessForm({ slug }: { slug: string }) {
if (state.jobId) { if (state.jobId) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{state.newPassphrase && <PassphraseNotice passphrase={state.newPassphrase} />}
<JobProgress jobId={state.jobId} refreshOnFinish={false}> <JobProgress jobId={state.jobId} refreshOnFinish={false}>
<Link href="/dashboard" className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"> <Link href="/dashboard" className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
Back to businesses Back to businesses
@@ -1,33 +0,0 @@
"use client";
import { useState } from "react";
/** The backup passphrase, shown exactly once (in the response that generated it). Nothing here persists it — the server keeps only an encrypted copy. */
export function PassphraseNotice({ passphrase }: { passphrase: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(passphrase);
setCopied(true);
} catch {
setCopied(false);
}
}
return (
<div role="alert" className="space-y-2 rounded-md border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="font-semibold text-amber-900">Save this backup passphrase now — it won&apos;t be shown again.</p>
<p className="text-amber-900">
Every backup is encrypted with it. On a new server you&apos;ll need to type it to restore; without it the backups
cannot be opened by anyone.
</p>
<div className="flex flex-wrap items-center gap-3">
<code className="select-all rounded bg-white px-3 py-1.5 font-mono text-base font-semibold">{passphrase}</code>
<button type="button" onClick={copy} className="rounded-md border border-line bg-white px-3 py-1.5 text-sm font-medium">
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
);
}
+12 -22
View File
@@ -1,6 +1,5 @@
"use server"; "use server";
import { ensureBackupPassphrase, hasBackupPassphrase, readBackupPassphrase } from "@/lib/global-settings";
import { BusinessLockedError } from "@/lib/locks"; import { BusinessLockedError } from "@/lib/locks";
import { startRestoreJob } from "@/lib/operations"; import { startRestoreJob } from "@/lib/operations";
import { planRestore, readArchiveManifest, resolveArchivePath, type RestoreSource } from "@/lib/restore"; import { planRestore, readArchiveManifest, resolveArchivePath, type RestoreSource } from "@/lib/restore";
@@ -30,17 +29,11 @@ export interface InspectRestoreState {
preview: RestorePreview | null; preview: RestorePreview | null;
} }
async function passphraseFor(input: string): Promise<string> { /** Reads only the manifest (fast) and reports what restoring would do — nothing is changed. */
const passphrase = input.trim() || (await readBackupPassphrase()) || ""; export async function inspectRestoreAction(sourceKey: string): Promise<InspectRestoreState> {
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 { try {
const source = parseSourceKey(sourceKey); const source = parseSourceKey(sourceKey);
const manifest = await readArchiveManifest(resolveArchivePath(source), await passphraseFor(passphraseInput)); const manifest = await readArchiveManifest(resolveArchivePath(source));
const plan = await planRestore(manifest); const plan = await planRestore(manifest);
return { return {
error: null, error: null,
@@ -57,11 +50,13 @@ export async function inspectRestoreAction(sourceKey: string, passphraseInput: s
}, },
}; };
} catch (error) { } catch (error) {
// A wrong passphrase makes 7zz fail with a generic message; say so plainly instead of passing its output through. // 7zz fails with a generic message on a damaged or foreign file; say so plainly instead of passing its output through.
const message = error instanceof Error ? error.message : ""; 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); const looksUnreadable = /wrong password|can not open|data error|headers error|exited with code 2/i.test(message);
return { 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.", error: looksUnreadable
? "Couldn't open that file — it isn't a valid NextChair backup (backups made by an older version with a password can't be opened here)."
: message || "Couldn't read that backup.",
preview: null, preview: null,
}; };
} }
@@ -70,20 +65,17 @@ export async function inspectRestoreAction(sourceKey: string, passphraseInput: s
export interface StartRestoreState { export interface StartRestoreState {
error: string | null; error: string | null;
jobId: string | null; jobId: string | null;
newPassphrase: string | null;
} }
export async function startRestoreAction( export async function startRestoreAction(
sourceKey: string, sourceKey: string,
passphraseInput: string,
replace: boolean, replace: boolean,
confirmSlug: string, confirmSlug: string,
): Promise<StartRestoreState> { ): Promise<StartRestoreState> {
const fail = (error: string): StartRestoreState => ({ error, jobId: null, newPassphrase: null }); const fail = (error: string): StartRestoreState => ({ error, jobId: null });
try { try {
const source = parseSourceKey(sourceKey); const source = parseSourceKey(sourceKey);
const passphrase = await passphraseFor(passphraseInput); const manifest = await readArchiveManifest(resolveArchivePath(source));
const manifest = await readArchiveManifest(resolveArchivePath(source), passphrase);
// Re-checked here, not trusted from the preview the browser holds. // Re-checked here, not trusted from the preview the browser holds.
const plan = await planRestore(manifest); const plan = await planRestore(manifest);
@@ -93,10 +85,8 @@ export async function startRestoreAction(
return fail(`"${manifest.slug}" already exists. Tick "replace" and type its slug to confirm overwriting it.`); 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 { jobId } = await startRestoreJob({ source, replace });
const newPassphrase = plan.slugExists && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null; return { error: null, jobId };
const { jobId } = await startRestoreJob({ source, passphrase, replace });
return { error: null, jobId, newPassphrase };
} catch (error) { } catch (error) {
if (error instanceof BusinessLockedError) return fail(error.message); if (error instanceof BusinessLockedError) return fail(error.message);
return fail(error instanceof Error ? error.message : "Couldn't start the restore."); return fail(error instanceof Error ? error.message : "Couldn't start the restore.");
+2 -5
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { listAllBackups } from "@/lib/backup-store"; import { listAllBackups } from "@/lib/backup-store";
import { hasBackupPassphrase } from "@/lib/global-settings";
import { RestoreWizard } from "./restore-wizard"; import { RestoreWizard } from "./restore-wizard";
export const metadata: Metadata = { title: "Restore" }; export const metadata: Metadata = { title: "Restore" };
@@ -9,7 +8,7 @@ export const dynamic = "force-dynamic";
export default async function RestorePage({ searchParams }: { searchParams: Promise<{ slug?: string; file?: string }> }) { export default async function RestorePage({ searchParams }: { searchParams: Promise<{ slug?: string; file?: string }> }) {
const { slug, file } = await searchParams; const { slug, file } = await searchParams;
const [backups, hasPassphrase] = await Promise.all([listAllBackups(), hasBackupPassphrase()]); const backups = await listAllBackups();
const preselected = slug && file ? `backup:${slug}:${file}` : null; const preselected = slug && file ? `backup:${slug}:${file}` : null;
const configuredMax = Number(process.env.BACKUP_UPLOAD_MAX_BYTES); const configuredMax = Number(process.env.BACKUP_UPLOAD_MAX_BYTES);
const maxUploadBytes = configuredMax > 0 ? configuredMax : 4 * 1024 * 1024 * 1024; const maxUploadBytes = configuredMax > 0 ? configuredMax : 4 * 1024 * 1024 * 1024;
@@ -23,8 +22,7 @@ export default async function RestorePage({ searchParams }: { searchParams: Prom
<h1 className="mt-2 text-xl font-bold">Restore a business</h1> <h1 className="mt-2 text-xl font-bold">Restore a business</h1>
<p className="text-sm text-muted"> <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 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 backup or upload a <span className="font-mono">.7z</span>, check what it contains, then confirm.
confirm.
</p> </p>
</div> </div>
<RestoreWizard <RestoreWizard
@@ -37,7 +35,6 @@ export default async function RestorePage({ searchParams }: { searchParams: Prom
sizeBytes: b.sizeBytes, sizeBytes: b.sizeBytes,
}))} }))}
preselected={preselected} preselected={preselected}
hasStoredPassphrase={hasPassphrase}
maxUploadBytes={maxUploadBytes} maxUploadBytes={maxUploadBytes}
/> />
</div> </div>
@@ -3,7 +3,6 @@
import Link from "next/link"; import Link from "next/link";
import { useRef, useState, useTransition } from "react"; import { useRef, useState, useTransition } from "react";
import { JobProgress } from "../job-progress"; import { JobProgress } from "../job-progress";
import { PassphraseNotice } from "../passphrase-notice";
import { inspectRestoreAction, startRestoreAction, type RestorePreview } from "./actions"; import { inspectRestoreAction, startRestoreAction, type RestorePreview } from "./actions";
interface BackupOption { interface BackupOption {
@@ -24,12 +23,10 @@ function formatBytes(bytes: number): string {
export function RestoreWizard({ export function RestoreWizard({
backups, backups,
preselected, preselected,
hasStoredPassphrase,
maxUploadBytes, maxUploadBytes,
}: { }: {
backups: BackupOption[]; backups: BackupOption[];
preselected: string | null; preselected: string | null;
hasStoredPassphrase: boolean;
maxUploadBytes: number; maxUploadBytes: number;
}) { }) {
const [mode, setMode] = useState<"stored" | "upload">(backups.length === 0 ? "upload" : "stored"); const [mode, setMode] = useState<"stored" | "upload">(backups.length === 0 ? "upload" : "stored");
@@ -37,14 +34,12 @@ export function RestoreWizard({
const [uploadKey, setUploadKey] = useState<string | null>(null); const [uploadKey, setUploadKey] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(null); const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [uploadName, setUploadName] = useState<string | null>(null); const [uploadName, setUploadName] = useState<string | null>(null);
const [passphrase, setPassphrase] = useState("");
const [preview, setPreview] = useState<RestorePreview | null>(null); const [preview, setPreview] = useState<RestorePreview | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [replace, setReplace] = useState(false); const [replace, setReplace] = useState(false);
const [confirmSlug, setConfirmSlug] = useState(""); const [confirmSlug, setConfirmSlug] = useState("");
const [jobId, setJobId] = useState<string | null>(null); const [jobId, setJobId] = useState<string | null>(null);
const [restoredSlug, setRestoredSlug] = useState<string | null>(null); const [restoredSlug, setRestoredSlug] = useState<string | null>(null);
const [newPassphrase, setNewPassphrase] = useState<string | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const xhrRef = useRef<XMLHttpRequest | null>(null); const xhrRef = useRef<XMLHttpRequest | null>(null);
@@ -91,7 +86,7 @@ export function RestoreWizard({
if (!sourceKey) return; if (!sourceKey) return;
resetPreview(); resetPreview();
startTransition(async () => { startTransition(async () => {
const result = await inspectRestoreAction(sourceKey, passphrase); const result = await inspectRestoreAction(sourceKey);
if (result.error) setError(result.error); if (result.error) setError(result.error);
setPreview(result.preview); setPreview(result.preview);
}); });
@@ -101,12 +96,11 @@ export function RestoreWizard({
if (!sourceKey || !preview) return; if (!sourceKey || !preview) return;
setError(null); setError(null);
startTransition(async () => { startTransition(async () => {
const result = await startRestoreAction(sourceKey, passphrase, replace, confirmSlug); const result = await startRestoreAction(sourceKey, replace, confirmSlug);
if (result.error) { if (result.error) {
setError(result.error); setError(result.error);
return; return;
} }
setNewPassphrase(result.newPassphrase);
setRestoredSlug(preview.slug); setRestoredSlug(preview.slug);
setJobId(result.jobId); setJobId(result.jobId);
}); });
@@ -118,7 +112,6 @@ export function RestoreWizard({
if (jobId) { if (jobId) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{newPassphrase && <PassphraseNotice passphrase={newPassphrase} />}
<JobProgress jobId={jobId}> <JobProgress jobId={jobId}>
{restoredSlug && ( {restoredSlug && (
<Link href={`/dashboard/${restoredSlug}`} className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"> <Link href={`/dashboard/${restoredSlug}`} className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
@@ -199,28 +192,13 @@ export function RestoreWizard({
</fieldset> </fieldset>
<div className="space-y-2"> <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 <button
type="button" type="button"
onClick={inspect} onClick={inspect}
disabled={isPending || !sourceKey || (!passphrase && !hasStoredPassphrase)} disabled={isPending || !sourceKey}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60" className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
> >
{isPending && !preview ? "Checking…" : "Check this backup"} {isPending && !preview ? "Checking…" : "2. Check this backup"}
</button> </button>
</div> </div>
+13 -1
View File
@@ -2,6 +2,7 @@
import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout"; import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout";
import { applyGoogleCalendarSettingsToAllBusinesses, type GoogleCalendarRolloutResult } from "@/lib/google-calendar-rollout"; import { applyGoogleCalendarSettingsToAllBusinesses, type GoogleCalendarRolloutResult } from "@/lib/google-calendar-rollout";
import { applyOperatorAlertEmailToAllBusinesses, type OperatorRolloutResult } from "@/lib/operator-rollout";
import { applyPushSettingsToAllBusinesses, type PushRolloutResult } from "@/lib/push-rollout"; import { applyPushSettingsToAllBusinesses, type PushRolloutResult } from "@/lib/push-rollout";
import { import {
generateVapidKeys, generateVapidKeys,
@@ -16,6 +17,7 @@ import {
resolvePushSettings, resolvePushSettings,
saveEmailSettings, saveEmailSettings,
saveGoogleCalendarSettings, saveGoogleCalendarSettings,
readOperatorSettings,
saveOperatorSettings, saveOperatorSettings,
savePushSubject, savePushSubject,
saveSuperAdminSettings, saveSuperAdminSettings,
@@ -367,6 +369,8 @@ export async function reapplyGoogleCalendarSettingsAction(
export interface SaveOperatorSettingsState { export interface SaveOperatorSettingsState {
error: string | null; error: string | null;
saved: boolean; saved: boolean;
/** Set when the alert address changed and was pushed to the businesses (each restarts to pick it up); null when nothing needed rolling out. */
rollout?: OperatorRolloutResult[] | null;
} }
export async function saveOperatorSettingsAction( export async function saveOperatorSettingsAction(
@@ -386,7 +390,9 @@ export async function saveOperatorSettingsAction(
return { error: "Health check interval must be between 1 and 60 minutes.", saved: false }; return { error: "Health check interval must be between 1 and 60 minutes.", saved: false };
} }
let previousAlertEmail = "";
try { try {
previousAlertEmail = (await readOperatorSettings()).alertEmail;
await saveOperatorSettings({ await saveOperatorSettings({
alertEmail, alertEmail,
alertOnHealth: formData.get("alertOnHealth") === "on", alertOnHealth: formData.get("alertOnHealth") === "on",
@@ -399,7 +405,13 @@ export async function saveOperatorSettingsAction(
} catch (error) { } catch (error) {
return { error: error instanceof Error ? error.message : "Couldn't save settings.", saved: false }; return { error: error instanceof Error ? error.message : "Couldn't save settings.", saved: false };
} }
return { error: null, saved: true };
// Businesses read the alert address from their own .env, so a changed address has to be pushed out
// (and each restarted once). An unchanged one isn't touched at all.
let rollout: OperatorRolloutResult[] | null = null;
if (alertEmail !== previousAlertEmail) rollout = await applyOperatorAlertEmailToAllBusinesses(alertEmail);
return { error: null, saved: true, rollout };
} }
export interface SendTestAlertState { export interface SendTestAlertState {
@@ -109,6 +109,18 @@ export function OperatorSettingsForm({ initial, status }: { initial: OperatorSet
</p> </p>
)} )}
{saveState.saved && !saveState.error && <p className="text-sm text-green-700">Saved.</p>} {saveState.saved && !saveState.error && <p className="text-sm text-green-700">Saved.</p>}
{saveState.rollout && saveState.rollout.length > 0 && (
<div className="text-sm">
<p className="text-muted">The alert address was sent to your businesses so they can email you about server errors:</p>
<ul className="mt-1 space-y-0.5">
{saveState.rollout.map((r) => (
<li key={r.slug} className={r.ok ? "text-green-700" : "text-danger"}>
{r.slug}: {r.ok ? (r.unchanged ? "already set" : "updated and restarted") : `failed — ${r.error}`}
</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap gap-3 pt-2"> <div className="flex flex-wrap gap-3 pt-2">
<button <button
+5 -16
View File
@@ -21,15 +21,12 @@ vi.mock("@/lib/sevenzip", () => ({
sevenZipTest: (...a: unknown[]) => sevenZipTestMock(...a), sevenZipTest: (...a: unknown[]) => sevenZipTestMock(...a),
})); }));
const readBackupPassphraseMock = vi.fn();
vi.mock("@/lib/global-settings", () => ({ readBackupPassphrase: () => readBackupPassphraseMock() }));
import { performBackup } from "../backup"; import { performBackup } from "../backup";
import { backupFileName, listBackups } from "../backup-store"; import { backupFileName, listBackups } from "../backup-store";
import { saveBackupSettings } from "../backup-settings"; import { saveBackupSettings } from "../backup-settings";
import { parseManifest } from "../backup-manifest"; import { parseManifest } from "../backup-manifest";
const PASSPHRASE = "test-passphrase-123";
const ENV = [ const ENV = [
'BUSINESS_NAME="Acme Barbers"', 'BUSINESS_NAME="Acme Barbers"',
'DOMAIN="acme.example.com"', 'DOMAIN="acme.example.com"',
@@ -56,7 +53,6 @@ describe("performBackup", () => {
await writeFile(path.join(stacksDir, "infra", "sites", "acme.caddy"), "acme.example.com {\n}\n"); await writeFile(path.join(stacksDir, "infra", "sites", "acme.caddy"), "acme.example.com {\n}\n");
containerExistsMock.mockReset().mockResolvedValue(true); containerExistsMock.mockReset().mockResolvedValue(true);
readBackupPassphraseMock.mockReset().mockResolvedValue(PASSPHRASE);
runCaptureMock.mockReset().mockResolvedValue({ stdout: "20261017180000_add_business_holidays\n", stderr: "" }); runCaptureMock.mockReset().mockResolvedValue({ stdout: "20261017180000_add_business_holidays\n", stderr: "" });
runToFileMock.mockReset().mockImplementation(async (spec: CommandSpec, file: string) => { runToFileMock.mockReset().mockImplementation(async (spec: CommandSpec, file: string) => {
const isDump = spec.args.includes("mysqldump"); const isDump = spec.args.includes("mysqldump");
@@ -103,16 +99,16 @@ describe("performBackup", () => {
slug: "acme", name: "Acme Barbers", domain: "acme.example.com", databaseName: "acme", databaseUser: "acme", slug: "acme", name: "Acme Barbers", domain: "acme.example.com", databaseName: "acme", databaseUser: "acme",
kind: "regular", lastMigration: "20261017180000_add_business_holidays", hasCaddySite: true, createdAt: now.toISOString(), kind: "regular", lastMigration: "20261017180000_add_business_holidays", hasCaddySite: true, createdAt: now.toISOString(),
}); });
expect(staged.manifest).not.toContain("abc123"); // the DB password lives only in config/.env inside the encrypted archive expect(staged.manifest).not.toContain("abc123"); // the DB password lives only in config/.env inside the archive
// 4. encrypted with the stored passphrase, written as .partial first // 4. a plain archive (no password), written as .partial first
const create = sevenZipCreateMock.mock.calls[0]![0] as { archivePath: string; passphrase: string; entries: string[] }; const create = sevenZipCreateMock.mock.calls[0]![0] as { archivePath: string; entries: string[] };
expect(create.passphrase).toBe(PASSPHRASE); expect(create).not.toHaveProperty("passphrase");
expect(create.archivePath.endsWith(`${backupFileName("acme", "regular", now)}.partial`)).toBe(true); expect(create.archivePath.endsWith(`${backupFileName("acme", "regular", now)}.partial`)).toBe(true);
expect(create.entries).toEqual(["manifest.json", "db.sql", "media.tar", "config"]); expect(create.entries).toEqual(["manifest.json", "db.sql", "media.tar", "config"]);
// 5. verified (7zz t) the .partial, THEN renamed // 5. verified (7zz t) the .partial, THEN renamed
expect(sevenZipTestMock).toHaveBeenCalledWith(create.archivePath, PASSPHRASE); expect(sevenZipTestMock).toHaveBeenCalledWith(create.archivePath);
const finalPath = create.archivePath.replace(/\.partial$/, ""); const finalPath = create.archivePath.replace(/\.partial$/, "");
expect((await stat(finalPath)).isFile()).toBe(true); expect((await stat(finalPath)).isFile()).toBe(true);
expect((await stat(finalPath)).mode & 0o777).toBe(0o600); expect((await stat(finalPath)).mode & 0o777).toBe(0o600);
@@ -121,7 +117,6 @@ describe("performBackup", () => {
// no staging leftovers, and nothing secret in the log // no staging leftovers, and nothing secret in the log
expect(await readdir(path.join(stacksDir, "manager", "tmp"))).toEqual([]); expect(await readdir(path.join(stacksDir, "manager", "tmp"))).toEqual([]);
expect(lines.join("\n")).not.toContain(PASSPHRASE);
expect(lines.join("\n")).not.toContain("abc123"); expect(lines.join("\n")).not.toContain("abc123");
}); });
@@ -187,12 +182,6 @@ describe("performBackup", () => {
expect(names.filter((n) => n.includes("__safety"))).toHaveLength(2); expect(names.filter((n) => n.includes("__safety"))).toHaveLength(2);
}); });
it("fails clearly when no passphrase exists yet", async () => {
readBackupPassphraseMock.mockResolvedValue(null);
await expect(performBackup("acme", "regular", logs().ctx)).rejects.toThrow(/No backup passphrase/);
expect(runToFileMock).not.toHaveBeenCalled();
});
it("emits job steps in order when given a step wrapper", async () => { it("emits job steps in order when given a step wrapper", async () => {
const order: string[] = []; const order: string[] = [];
await performBackup("acme", "regular", { log: () => undefined, step: async (id, fn) => (order.push(id), fn()) }); await performBackup("acme", "regular", { log: () => undefined, step: async (id, fn) => (order.push(id), fn()) });
@@ -84,39 +84,40 @@ describe("command construction", () => {
}); });
describe("7zz command construction", () => { describe("7zz command construction", () => {
const passphrase = "correct-horse-battery"; it("create: plain 7z (no password), listed entries, run from the staging dir", () => {
it("create: 7z, encrypted headers, passphrase, listed entries, run from the staging dir", () => {
const spec = sevenZipCreateCommand({ const spec = sevenZipCreateCommand({
archivePath: "/stacks/backups/acme/x.7z.partial", archivePath: "/stacks/backups/acme/x.7z.partial",
cwd: "/stacks/manager/tmp/s1", cwd: "/stacks/manager/tmp/s1",
entries: ["manifest.json", "db.sql", "media.tar", "config"], entries: ["manifest.json", "db.sql", "media.tar", "config"],
passphrase,
}); });
expect(spec.command).toBe("7zz"); expect(spec.command).toBe("7zz");
expect(spec.cwd).toBe("/stacks/manager/tmp/s1"); expect(spec.cwd).toBe("/stacks/manager/tmp/s1");
expect(spec.args).toEqual([ expect(spec.args).toEqual([
"a", "-t7z", "-mx=3", "-mhe=on", `-p${passphrase}`, "-y", "-bd", "a", "-t7z", "-mx=3", "-y", "-bd",
"/stacks/backups/acme/x.7z.partial", "manifest.json", "db.sql", "media.tar", "config", "/stacks/backups/acme/x.7z.partial", "manifest.json", "db.sql", "media.tar", "config",
]); ]);
}); });
it("test and stdout-extract commands", () => { it("never asks 7zz for encryption or a password", () => {
expect(sevenZipTestCommand("/a.7z", passphrase).args).toEqual(["t", "-t7z", `-p${passphrase}`, "-y", "-bd", "/a.7z"]); const all = [
expect(sevenZipExtractToStdoutCommand("/a.7z", passphrase, "db.sql").args).toEqual([ ...sevenZipCreateCommand({ archivePath: "/a.7z", cwd: "/tmp", entries: ["x"] }).args,
"x", "-so", "-t7z", `-p${passphrase}`, "-y", "-bd", "-bso0", "-bsp0", "/a.7z", "db.sql", ...sevenZipTestCommand("/a.7z").args,
]); ...sevenZipExtractToStdoutCommand("/a.7z", "db.sql").args,
];
expect(all.some((arg) => arg.startsWith("-p") || arg.startsWith("-mhe"))).toBe(false);
}); });
it("refuses a short passphrase (an empty -p would hang on a prompt)", () => { it("test and stdout-extract commands", () => {
expect(() => sevenZipTestCommand("/a.7z", "")).toThrow(/at least 8/); expect(sevenZipTestCommand("/a.7z").args).toEqual(["t", "-t7z", "-y", "-bd", "/a.7z"]);
expect(() => sevenZipTestCommand("/a.7z", "short")).toThrow(/at least 8/); expect(sevenZipExtractToStdoutCommand("/a.7z", "db.sql").args).toEqual([
"x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", "/a.7z", "db.sql",
]);
}); });
it("SEVENZIP_BIN overrides the binary", () => { it("SEVENZIP_BIN overrides the binary", () => {
process.env.SEVENZIP_BIN = "/opt/7zz"; process.env.SEVENZIP_BIN = "/opt/7zz";
try { try {
expect(sevenZipTestCommand("/a.7z", passphrase).command).toBe("/opt/7zz"); expect(sevenZipTestCommand("/a.7z").command).toBe("/opt/7zz");
} finally { } finally {
delete process.env.SEVENZIP_BIN; delete process.env.SEVENZIP_BIN;
} }
@@ -11,9 +11,7 @@ const waitForHealthMock = vi.fn();
vi.mock("@/lib/health", () => ({ waitForHealth: (...a: unknown[]) => waitForHealthMock(...a) })); vi.mock("@/lib/health", () => ({ waitForHealth: (...a: unknown[]) => waitForHealthMock(...a) }));
const performBackupMock = vi.fn(); const performBackupMock = vi.fn();
vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBackupMock(...a), BACKUP_STEPS: [] })); vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBackupMock(...a), BACKUP_STEPS: [] }));
const readBackupPassphraseMock = vi.fn();
vi.mock("@/lib/global-settings", () => ({ vi.mock("@/lib/global-settings", () => ({
readBackupPassphrase: () => readBackupPassphraseMock(),
readOperatorSettings: vi.fn(async () => ({ alertEmail: "", alertOnBackupFailure: true })), readOperatorSettings: vi.fn(async () => ({ alertEmail: "", alertOnBackupFailure: true })),
})); }));
vi.mock("@/lib/provisioning", () => ({ deleteBusiness: vi.fn(async () => (calls.push("deleteBusiness"), { slug: "acme", warnings: [] })) })); vi.mock("@/lib/provisioning", () => ({ deleteBusiness: vi.fn(async () => (calls.push("deleteBusiness"), { slug: "acme", warnings: [] })) }));
@@ -35,7 +33,6 @@ describe("Update and Delete jobs", () => {
calls.length = 0; calls.length = 0;
waitForHealthMock.mockReset().mockImplementation(async () => (calls.push("waitForHealth"), undefined)); waitForHealthMock.mockReset().mockImplementation(async () => (calls.push("waitForHealth"), undefined));
performBackupMock.mockReset().mockImplementation(async (_slug: string, kind: string) => (calls.push(`backup:${kind}`), { fileName: `f-${kind}.7z` })); performBackupMock.mockReset().mockImplementation(async (_slug: string, kind: string) => (calls.push(`backup:${kind}`), { fileName: `f-${kind}.7z` }));
readBackupPassphraseMock.mockReset().mockResolvedValue("a-stored-passphrase");
}); });
afterEach(async () => { afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR; if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
@@ -0,0 +1,79 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const composeUpMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/docker", () => ({
composeUp: (...args: unknown[]) => composeUpMock(...args),
composeDown: vi.fn(),
composeStatus: vi.fn().mockResolvedValue("stopped"),
reloadCaddy: vi.fn(),
runMigrations: vi.fn(),
}));
describe("applyOperatorAlertEmailToAllBusinesses", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-operator-rollout-test-"));
process.env.STACKS_DIR = stacksDir;
composeUpMock.mockClear();
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function createStack(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
await writeFile(path.join(dir, ".env"), envContent, "utf8");
return dir;
}
it("writes the alert address into each business and restarts it", async () => {
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
await createStack("acme", 'BUSINESS_NAME="Acme"\nDOMAIN="acme.example.com"\n');
const results = await applyOperatorAlertEmailToAllBusinesses("ops@example.com");
expect(results).toEqual([{ slug: "acme", ok: true }]);
expect(await readFile(path.join(stacksDir, "acme", ".env"), "utf8")).toContain(
'OPERATOR_ALERT_EMAIL="ops@example.com"',
);
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), {
forceRecreate: true,
});
});
it("leaves a business that already has this address alone — no rewrite, no restart", async () => {
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
await createStack("acme", 'BUSINESS_NAME="Acme"\nOPERATOR_ALERT_EMAIL="ops@example.com"\n');
const results = await applyOperatorAlertEmailToAllBusinesses("ops@example.com");
expect(results).toEqual([{ slug: "acme", ok: true, unchanged: true }]);
expect(composeUpMock).not.toHaveBeenCalled();
});
it("an empty address turns alerts off, and one failing business doesn't stop the rest", async () => {
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
await createStack("alpha", 'OPERATOR_ALERT_EMAIL="ops@example.com"\n');
await createStack("beta", 'OPERATOR_ALERT_EMAIL="ops@example.com"\n');
composeUpMock.mockRejectedValueOnce(new Error("docker unavailable"));
const results = await applyOperatorAlertEmailToAllBusinesses("");
expect(results).toHaveLength(2);
expect(results.filter((r) => r.ok)).toHaveLength(1);
expect(results.find((r) => !r.ok)?.error).toContain("docker unavailable");
expect(await readFile(path.join(stacksDir, "beta", ".env"), "utf8")).toContain(
'OPERATOR_ALERT_EMAIL=""',
);
});
});
+17 -19
View File
@@ -45,7 +45,6 @@ vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBack
import { performRestore, RESTORE_STEPS } from "../restore"; import { performRestore, RESTORE_STEPS } from "../restore";
const PASSPHRASE = "restore-passphrase-1";
const DB_PASSWORD = "f".repeat(32); const DB_PASSWORD = "f".repeat(32);
const ARCHIVE = "/stacks/backups/acme/acme__20261017T030000Z__regular.7z"; const ARCHIVE = "/stacks/backups/acme/acme__20261017T030000Z__regular.7z";
const BACKED_UP_ENV = ['BUSINESS_NAME="Acme Barbers"', 'DOMAIN="acme.example.com"', 'DATABASE_NAME="acme"', 'DATABASE_USER="acme"', `DATABASE_PASSWORD="${DB_PASSWORD}"`, 'AUTH_SECRET="s3cret-from-backup"'].join("\n") + "\n"; const BACKED_UP_ENV = ['BUSINESS_NAME="Acme Barbers"', 'DOMAIN="acme.example.com"', 'DATABASE_NAME="acme"', 'DATABASE_USER="acme"', `DATABASE_PASSWORD="${DB_PASSWORD}"`, 'AUTH_SECRET="s3cret-from-backup"'].join("\n") + "\n";
@@ -71,7 +70,7 @@ describe("performRestore", () => {
recreateDatabaseMock.mockReset().mockImplementation(async () => record("recreateDatabase")); recreateDatabaseMock.mockReset().mockImplementation(async () => record("recreateDatabase"));
runPipelineMock.mockReset().mockImplementation(async (source: CommandSpec) => record(`pipeline:${source.args.at(-1)}`)); runPipelineMock.mockReset().mockImplementation(async (source: CommandSpec) => record(`pipeline:${source.args.at(-1)}`));
sevenZipTestMock.mockReset().mockImplementation(async () => record("7zz-test")); sevenZipTestMock.mockReset().mockImplementation(async () => record("7zz-test"));
sevenZipReadMemberMock.mockReset().mockImplementation(async (_a: string, _p: string, member: string) => { sevenZipReadMemberMock.mockReset().mockImplementation(async (_a: string, member: string) => {
record(`read:${member}`); record(`read:${member}`);
return member === "manifest.json" ? manifest() : BACKED_UP_ENV; return member === "manifest.json" ? manifest() : BACKED_UP_ENV;
}); });
@@ -111,7 +110,7 @@ describe("performRestore", () => {
it("fresh server: runs the steps in the planned order and the side effects in the planned order", async () => { it("fresh server: runs the steps in the planned order and the side effects in the planned order", async () => {
const { ctx, steps } = makeCtx(); const { ctx, steps } = makeCtx();
const result = await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx }); const result = await performRestore({ archivePath: ARCHIVE, replace: false, ctx });
expect(steps).toEqual([ expect(steps).toEqual([
"step:verify", "step:check", "skip:safety", "skip:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health", "step:verify", "step:check", "skip:safety", "skip:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health",
@@ -133,7 +132,7 @@ describe("performRestore", () => {
}); });
it("recreates the DB with the password FROM THE BACKED-UP .env, and writes that .env verbatim (0600) plus a regenerated compose.yaml", async () => { it("recreates the DB with the password FROM THE BACKED-UP .env, and writes that .env verbatim (0600) plus a regenerated compose.yaml", async () => {
await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx }); await performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx });
expect(recreateDatabaseMock).toHaveBeenCalledWith("acme", "acme", DB_PASSWORD); expect(recreateDatabaseMock).toHaveBeenCalledWith("acme", "acme", DB_PASSWORD);
const envPath = path.join(stacksDir, "acme", ".env"); const envPath = path.join(stacksDir, "acme", ".env");
@@ -145,11 +144,11 @@ describe("performRestore", () => {
}); });
it("builds the import and media pipelines as argument arrays: 7zz -so → docker, no password in argv", async () => { it("builds the import and media pipelines as argument arrays: 7zz -so → docker, no password in argv", async () => {
await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx }); await performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx });
const [dumpSource, dumpSink] = runPipelineMock.mock.calls[0] as [CommandSpec, CommandSpec]; const [dumpSource, dumpSink] = runPipelineMock.mock.calls[0] as [CommandSpec, CommandSpec];
expect(dumpSource.command).toBe("7zz"); expect(dumpSource.command).toBe("7zz");
expect(dumpSource.args).toEqual(["x", "-so", "-t7z", `-p${PASSPHRASE}`, "-y", "-bd", "-bso0", "-bsp0", ARCHIVE, "db.sql"]); expect(dumpSource.args).toEqual(["x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", ARCHIVE, "db.sql"]);
expect(dumpSink).toMatchObject({ command: "docker" }); expect(dumpSink).toMatchObject({ command: "docker" });
expect(dumpSink.args).toEqual(["exec", "-i", "-e", "MYSQL_PWD", "next-chair-mysql", "mysql", "-uroot", "--default-character-set=utf8mb4", "acme"]); expect(dumpSink.args).toEqual(["exec", "-i", "-e", "MYSQL_PWD", "next-chair-mysql", "mysql", "-uroot", "--default-character-set=utf8mb4", "acme"]);
expect(dumpSink.args.join(" ")).not.toContain("root-pw"); expect(dumpSink.args.join(" ")).not.toContain("root-pw");
@@ -163,7 +162,7 @@ describe("performRestore", () => {
it("existing business + replace: safety backup FIRST, then compose down (with volumes), then everything else", async () => { it("existing business + replace: safety backup FIRST, then compose down (with volumes), then everything else", async () => {
await existingStack(); await existingStack();
const { ctx, steps } = makeCtx(); const { ctx, steps } = makeCtx();
const result = await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: true, ctx }); const result = await performRestore({ archivePath: ARCHIVE, replace: true, ctx });
expect(steps).toEqual([ expect(steps).toEqual([
"step:verify", "step:check", "step:safety", "step:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health", "step:verify", "step:check", "step:safety", "step:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health",
@@ -179,7 +178,7 @@ describe("performRestore", () => {
it("existing business without 'replace': refuses before touching anything", async () => { it("existing business without 'replace': refuses before touching anything", async () => {
await existingStack(); await existingStack();
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already exists.*replace/); await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already exists.*replace/);
expect(performBackupMock).not.toHaveBeenCalled(); expect(performBackupMock).not.toHaveBeenCalled();
expect(recreateDatabaseMock).not.toHaveBeenCalled(); expect(recreateDatabaseMock).not.toHaveBeenCalled();
expect(calls.some((c) => c.startsWith("composeDown"))).toBe(false); expect(calls.some((c) => c.startsWith("composeDown"))).toBe(false);
@@ -187,38 +186,37 @@ describe("performRestore", () => {
it("domain conflict: refuses before any destructive step", async () => { it("domain conflict: refuses before any destructive step", async () => {
assertDomainAvailableMock.mockRejectedValue(new Error('Domain "acme.example.com" is already in use by "other".')); assertDomainAvailableMock.mockRejectedValue(new Error('Domain "acme.example.com" is already in use by "other".'));
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already in use/); await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already in use/);
expect(recreateDatabaseMock).not.toHaveBeenCalled(); expect(recreateDatabaseMock).not.toHaveBeenCalled();
expect(runPipelineMock).not.toHaveBeenCalled(); expect(runPipelineMock).not.toHaveBeenCalled();
}); });
it("version guard: a backup from a newer schema than the image is refused before any change", async () => { it("version guard: a backup from a newer schema than the image is refused before any change", async () => {
sevenZipReadMemberMock.mockImplementation(async (_a: string, _p: string, member: string) => sevenZipReadMemberMock.mockImplementation(async (_a: string, member: string) =>
member === "manifest.json" ? manifest({ lastMigration: "20271231000000_from_the_future" }) : BACKED_UP_ENV, member === "manifest.json" ? manifest({ lastMigration: "20271231000000_from_the_future" }) : BACKED_UP_ENV,
); );
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/newer than the running app image/); await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/newer than the running app image/);
expect(recreateDatabaseMock).not.toHaveBeenCalled(); expect(recreateDatabaseMock).not.toHaveBeenCalled();
}); });
it("refuses an archive whose .env disagrees with its manifest", async () => { it("refuses an archive whose .env disagrees with its manifest", async () => {
sevenZipReadMemberMock.mockImplementation(async (_a: string, _p: string, member: string) => sevenZipReadMemberMock.mockImplementation(async (_a: string, member: string) =>
member === "manifest.json" ? manifest() : BACKED_UP_ENV.replace('DATABASE_NAME="acme"', 'DATABASE_NAME="other"'), member === "manifest.json" ? manifest() : BACKED_UP_ENV.replace('DATABASE_NAME="acme"', 'DATABASE_NAME="other"'),
); );
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/doesn't match its manifest/); await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/doesn't match its manifest/);
expect(recreateDatabaseMock).not.toHaveBeenCalled(); expect(recreateDatabaseMock).not.toHaveBeenCalled();
}); });
it("a bad archive or wrong passphrase fails in the first step", async () => { it("a bad or corrupt archive fails in the first step", async () => {
sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Wrong password")); sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Data error"));
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/Wrong password/); await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/Data error/);
expect(assertDomainAvailableMock).not.toHaveBeenCalled(); expect(assertDomainAvailableMock).not.toHaveBeenCalled();
}); });
it("never logs the passphrase or any .env secret", async () => { it("never logs any .env secret", async () => {
const { ctx, lines } = makeCtx(); const { ctx, lines } = makeCtx();
await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx }); await performRestore({ archivePath: ARCHIVE, replace: false, ctx });
const all = lines.join("\n"); const all = lines.join("\n");
expect(all).not.toContain(PASSPHRASE);
expect(all).not.toContain(DB_PASSWORD); expect(all).not.toContain(DB_PASSWORD);
expect(all).not.toContain("s3cret-from-backup"); expect(all).not.toContain("s3cret-from-backup");
}); });
+3 -10
View File
@@ -6,7 +6,6 @@ import { backupFileName, ensureBackupDir, pruneBackups, removePartialFiles, SAFE
import { containerExists } from "./docker"; import { containerExists } from "./docker";
import { mediaCopyOutCommand, mediaVolumeTarCommand, mysqlQueryCommand, mysqldumpCommand } from "./docker-commands"; import { mediaCopyOutCommand, mediaVolumeTarCommand, mysqlQueryCommand, mysqldumpCommand } from "./docker-commands";
import { readEnvFile, readEnvValue } from "./env-file"; import { readEnvFile, readEnvValue } from "./env-file";
import { readBackupPassphrase } from "./global-settings";
import { stagingRoot } from "./manager-paths"; import { stagingRoot } from "./manager-paths";
import { runCapture, runToFile } from "./process"; import { runCapture, runToFile } from "./process";
import { randomHex } from "./random"; import { randomHex } from "./random";
@@ -93,11 +92,6 @@ export async function performBackup(slug: string, kind: BackupKind, ctx: BackupR
const domain = readEnvValue(envContent, "DOMAIN"); const domain = readEnvValue(envContent, "DOMAIN");
if (!databaseName || !databaseUser || !domain) throw new Error(`"${slug}"'s .env is missing DATABASE_NAME/DATABASE_USER/DOMAIN.`); if (!databaseName || !databaseUser || !domain) throw new Error(`"${slug}"'s .env is missing DATABASE_NAME/DATABASE_USER/DOMAIN.`);
const passphrase = await readBackupPassphrase();
if (!passphrase) {
throw new Error("No backup passphrase exists yet. Enable backups for a business (or use Back up now) from the Backups page so one is generated and shown to you.");
}
const outDir = await ensureBackupDir(slug); const outDir = await ensureBackupDir(slug);
await removePartialFiles(slug); await removePartialFiles(slug);
const finalName = backupFileName(slug, kind, now); const finalName = backupFileName(slug, kind, now);
@@ -157,24 +151,23 @@ export async function performBackup(slug: string, kind: BackupKind, ctx: BackupR
}); });
await runStep(ctx, "archive", async () => { await runStep(ctx, "archive", async () => {
ctx.log("Encrypting and compressing…"); ctx.log("Compressing…");
await rm(partialPath, { force: true }); await rm(partialPath, { force: true });
await sevenZipCreate({ await sevenZipCreate({
archivePath: partialPath, archivePath: partialPath,
cwd: stagingDir, cwd: stagingDir,
entries: [BACKUP_MEMBERS.manifest, BACKUP_MEMBERS.database, BACKUP_MEMBERS.media, "config"], entries: [BACKUP_MEMBERS.manifest, BACKUP_MEMBERS.database, BACKUP_MEMBERS.media, "config"],
passphrase,
}); });
}); });
await runStep(ctx, "verify", async () => { await runStep(ctx, "verify", async () => {
ctx.log("Verifying the archive (7zz t)…"); ctx.log("Verifying the archive (7zz t)…");
await sevenZipTest(partialPath, passphrase); await sevenZipTest(partialPath);
}); });
return await runStep(ctx, "finish", async () => { return await runStep(ctx, "finish", async () => {
await rename(partialPath, finalPath); await rename(partialPath, finalPath);
await chmod(finalPath, 0o600); // 7zz honours the umask; the archive (encrypted or not) should be owner-only await chmod(finalPath, 0o600); // 7zz honours the umask; the archive holds the business's secrets in the clear, so it must be owner-only
const sizeBytes = (await stat(finalPath)).size; const sizeBytes = (await stat(finalPath)).size;
let pruned: string[] = []; let pruned: string[] = [];
if (kind === "regular") { if (kind === "regular") {
+3
View File
@@ -109,6 +109,8 @@ export interface BusinessEnvValues {
superAdmin?: BusinessEnvSuperAdminValues; superAdmin?: BusinessEnvSuperAdminValues;
/** Global platform Google Calendar OAuth client (see src/lib/global-settings.ts) — same pattern as `email`/`push` above. Omitted falls back to today's blank defaults. */ /** Global platform Google Calendar OAuth client (see src/lib/global-settings.ts) — same pattern as `email`/`push` above. Omitted falls back to today's blank defaults. */
googleCalendar?: BusinessEnvGoogleCalendarValues; googleCalendar?: BusinessEnvGoogleCalendarValues;
/** The operator's alert address (Settings > Monitoring) — the app emails unhandled server errors here. Omitted or blank leaves app-side alerts off. */
operatorAlertEmail?: string;
} }
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */ /** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
@@ -167,6 +169,7 @@ GOOGLE_CALENDAR_CLIENT_SECRET=""`;
# screen) — the app itself doesn't read it; its own business name lives # screen) — the app itself doesn't read it; its own business name lives
# in the database, set via /install. # in the database, set via /install.
BUSINESS_NAME="${values.name}" BUSINESS_NAME="${values.name}"
OPERATOR_ALERT_EMAIL="${values.operatorAlertEmail ?? ""}"
DOMAIN="${values.domain}" DOMAIN="${values.domain}"
DATABASE_HOST="next-chair-mysql" DATABASE_HOST="next-chair-mysql"
DATABASE_PORT="3306" DATABASE_PORT="3306"
+1 -59
View File
@@ -2,7 +2,6 @@ import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import webpush from "web-push"; import webpush from "web-push";
import { withMutex } from "./singleton";
import { stacksRoot } from "./stacks"; import { stacksRoot } from "./stacks";
/** /**
@@ -89,19 +88,12 @@ export const DEFAULT_OPERATOR_SETTINGS: OperatorSettings = {
healthIntervalMinutes: 3, healthIntervalMinutes: 3,
}; };
/** Persisted shape — the passphrase only ever holds the encrypted envelope. One passphrase protects every business's backups, so a single value is enough to restore on a fresh server. */
interface StoredBackupSettings {
encryptedPassphrase: EncryptedPayload | null;
createdAt: string | null;
}
interface StoredGlobalSettings { interface StoredGlobalSettings {
email: StoredEmailSettings | null; email: StoredEmailSettings | null;
push: StoredPushSettings | null; push: StoredPushSettings | null;
superAdmin: SuperAdminSettings | null; superAdmin: SuperAdminSettings | null;
googleCalendar: StoredGoogleCalendarSettings | null; googleCalendar: StoredGoogleCalendarSettings | null;
operator: OperatorSettings | null; operator: OperatorSettings | null;
backups: StoredBackupSettings | null;
} }
export interface LastTestResult { export interface LastTestResult {
@@ -194,11 +186,10 @@ async function readStoredSettings(): Promise<StoredGlobalSettings> {
superAdmin: parsed.superAdmin ?? null, superAdmin: parsed.superAdmin ?? null,
googleCalendar: parsed.googleCalendar ?? null, googleCalendar: parsed.googleCalendar ?? null,
operator: parsed.operator ?? null, operator: parsed.operator ?? null,
backups: parsed.backups ?? null,
}; };
} catch { } catch {
// No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render. // No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render.
return { email: null, push: null, superAdmin: null, googleCalendar: null, operator: null, backups: null }; return { email: null, push: null, superAdmin: null, googleCalendar: null, operator: null };
} }
} }
@@ -562,52 +553,3 @@ export async function saveOperatorSettings(input: OperatorSettings): Promise<Ope
await writeStoredSettings({ ...current, operator: next }); await writeStoredSettings({ ...current, operator: next });
return next; return next;
} }
// ── Backup passphrase ────────────────────────────────────────────────────
export async function hasBackupPassphrase(): Promise<boolean> {
const { backups } = await readStoredSettings();
return Boolean(backups?.encryptedPassphrase);
}
/** Server-side only — the decrypted passphrase used for scheduled/manual archives. Null until one has been generated. */
export async function readBackupPassphrase(): Promise<string | null> {
const { backups } = await readStoredSettings();
return backups?.encryptedPassphrase ? decrypt(backups.encryptedPassphrase) : null;
}
export async function backupPassphraseCreatedAt(): Promise<string | null> {
const { backups } = await readStoredSettings();
return backups?.createdAt ?? null;
}
/**
* Returns the stored passphrase, generating (and encrypting) one on first
* use. `created` is true only on that first call — the one moment the UI
* shows the plaintext to the operator so they can save it somewhere safe;
* afterwards it is never displayed again (a fresh server needs it typed
* in at restore time).
*/
export function ensureBackupPassphrase(): Promise<{ passphrase: string; created: boolean }> {
// Mutex: a scheduled backup and a manual one racing on a fresh install must not each mint a different passphrase.
return withMutex("backup-passphrase", async () => {
const existing = await readBackupPassphrase();
if (existing) return { passphrase: existing, created: false };
return { passphrase: await storeNewBackupPassphrase(), created: true };
});
}
/** Replaces the passphrase (older archives still need the old one). Returns the new plaintext, shown once. */
export async function rotateBackupPassphrase(): Promise<string> {
return storeNewBackupPassphrase();
}
async function storeNewBackupPassphrase(): Promise<string> {
const passphrase = randomBytes(18).toString("base64url");
const current = await readStoredSettings();
await writeStoredSettings({
...current,
backups: { encryptedPassphrase: encrypt(passphrase), createdAt: new Date().toISOString() },
});
return passphrase;
}
+1 -3
View File
@@ -2,8 +2,6 @@
export interface JobStartState { export interface JobStartState {
error: string | null; error: string | null;
jobId: string | null; jobId: string | null;
/** Plaintext backup passphrase — present only in the single response that just generated it. Shown once, never stored in a job record. */
newPassphrase: string | null;
} }
export const NO_JOB: JobStartState = { error: null, jobId: null, newPassphrase: null }; export const NO_JOB: JobStartState = { error: null, jobId: null };
+4 -17
View File
@@ -2,7 +2,7 @@ import { performBackup, BACKUP_STEPS } from "./backup";
import type { BackupKind } from "./backup-manifest"; import type { BackupKind } from "./backup-manifest";
import { recordBackupOutcome } from "./backup-settings"; import { recordBackupOutcome } from "./backup-settings";
import { composeUp } from "./docker"; import { composeUp } from "./docker";
import { readBackupPassphrase, readOperatorSettings } from "./global-settings"; import { readOperatorSettings } from "./global-settings";
import { setIntentionallyStopped } from "./health-watcher"; import { setIntentionallyStopped } from "./health-watcher";
import { waitForHealth } from "./health"; import { waitForHealth } from "./health";
import { startJob, type JobContext, type StartedJob } from "./jobs"; import { startJob, type JobContext, type StartedJob } from "./jobs";
@@ -19,12 +19,6 @@ import { isValidSlug, stackDir, stackExists } from "./stacks";
* here is awaited by a request. * here is awaited by a request.
*/ */
async function requirePassphrase(): Promise<string> {
const passphrase = await readBackupPassphrase();
if (!passphrase) throw new Error("No backup passphrase exists yet — open a business's Backups page first so one is generated.");
return passphrase;
}
async function alertBackupFailure(slug: string, message: string): Promise<void> { async function alertBackupFailure(slug: string, message: string): Promise<void> {
try { try {
const settings = await readOperatorSettings(); const settings = await readOperatorSettings();
@@ -37,7 +31,6 @@ async function alertBackupFailure(slug: string, message: string): Promise<void>
export async function startBackupJob(slug: string, kind: BackupKind = "regular"): Promise<StartedJob> { export async function startBackupJob(slug: string, kind: BackupKind = "regular"): Promise<StartedJob> {
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`); if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
await requirePassphrase();
return startJob({ return startJob({
kind: "backup", kind: "backup",
@@ -64,7 +57,6 @@ export async function startBackupJob(slug: string, kind: BackupKind = "regular")
/** Recreates the container against the shared image tag; optionally takes a safety backup first. Rebuilding the image stays a manual step. */ /** Recreates the container against the shared image tag; optionally takes a safety backup first. Rebuilding the image stays a manual step. */
export async function startUpdateJob(slug: string, backupFirst: boolean): Promise<StartedJob> { export async function startUpdateJob(slug: string, backupFirst: boolean): Promise<StartedJob> {
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`); if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
if (backupFirst) await requirePassphrase();
return startJob({ return startJob({
kind: "update", kind: "update",
@@ -107,7 +99,6 @@ export async function startUpdateJob(slug: string, backupFirst: boolean): Promis
/** Takes an optional final backup, then removes the business. If the backup fails the business is left untouched. */ /** Takes an optional final backup, then removes the business. If the backup fails the business is left untouched. */
export async function startDeleteJob(slug: string, finalBackup: boolean): Promise<StartedJob> { export async function startDeleteJob(slug: string, finalBackup: boolean): Promise<StartedJob> {
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`); if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
if (finalBackup) await requirePassphrase();
return startJob({ return startJob({
kind: "delete", kind: "delete",
@@ -133,18 +124,14 @@ export async function startDeleteJob(slug: string, finalBackup: boolean): Promis
export interface RestoreJobInput { export interface RestoreJobInput {
source: RestoreSource; source: RestoreSource;
/** Blank → the stored passphrase. */
passphrase: string;
replace: boolean; replace: boolean;
} }
export async function startRestoreJob(input: RestoreJobInput): Promise<StartedJob> { export async function startRestoreJob(input: RestoreJobInput): Promise<StartedJob> {
const passphrase = input.passphrase.trim() || (await readBackupPassphrase()) || "";
if (!passphrase) throw new Error("Enter the backup passphrase.");
const archivePath = resolveArchivePath(input.source); const archivePath = resolveArchivePath(input.source);
// The lock is per slug and the slug lives in the archive, so read the manifest first (fast: only the encrypted header is opened). // The lock is per slug and the slug lives in the archive, so read the manifest first (fast: only that one file is read).
const manifest = await readArchiveManifest(archivePath, passphrase); const manifest = await readArchiveManifest(archivePath);
return startJob({ return startJob({
kind: "restore", kind: "restore",
@@ -152,7 +139,7 @@ export async function startRestoreJob(input: RestoreJobInput): Promise<StartedJo
title: `Restore ${manifest.slug}`, title: `Restore ${manifest.slug}`,
steps: [...RESTORE_STEPS], steps: [...RESTORE_STEPS],
run: async (ctx) => { run: async (ctx) => {
const result = await performRestore({ archivePath, passphrase, replace: input.replace, ctx }); const result = await performRestore({ archivePath, replace: input.replace, ctx });
await setIntentionallyStopped(result.slug, false); await setIntentionallyStopped(result.slug, false);
return { slug: result.slug, domain: result.domain, safetyBackup: result.safetyBackup }; return { slug: result.slug, domain: result.domain, safetyBackup: result.safetyBackup };
}, },
+51
View File
@@ -0,0 +1,51 @@
import path from "node:path";
import { composeUp } from "./docker";
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
import { listBusinessStacks, stackDir } from "./stacks";
export interface OperatorRolloutResult {
slug: string;
ok: boolean;
/** True when the business already had this address, so nothing was touched and it wasn't restarted. */
unchanged?: boolean;
error?: string;
}
/**
* Gives every business the operator's alert address as OPERATOR_ALERT_EMAIL, so
* the app itself can email an unhandled server error (see the main app's
* src/instrumentation.ts). Same surgical `upsertEnvValues` + force-recreate as
* the other rollouts — but a business that already has this exact value is
* skipped, because restarting every business just to re-write what's there
* would be a pointless interruption. An empty address turns the app-side
* alerts off. Sequential, like the other rollouts.
*/
export async function applyOperatorAlertEmailToAllBusinesses(
alertEmail: string,
): Promise<OperatorRolloutResult[]> {
const businesses = await listBusinessStacks();
const results: OperatorRolloutResult[] = [];
for (const business of businesses) {
try {
const envPath = path.join(stackDir(business.slug), ".env");
const current =
readEnvValue((await readEnvFile(envPath)) ?? "", "OPERATOR_ALERT_EMAIL") ?? "";
if (current === alertEmail) {
results.push({ slug: business.slug, ok: true, unchanged: true });
continue;
}
await upsertEnvValues(envPath, { OPERATOR_ALERT_EMAIL: alertEmail });
await composeUp(stackDir(business.slug), { forceRecreate: true });
results.push({ slug: business.slug, ok: true });
} catch (error) {
results.push({
slug: business.slug,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { pipeline } from "node:stream/promises";
* never execFile, whose maxBuffer would kill a large dump mid-stream. * never execFile, whose maxBuffer would kill a large dump mid-stream.
* *
* Commands are always `{ command, args[] }` (never a shell string), and * Commands are always `{ command, args[] }` (never a shell string), and
* secrets travel in `env` (e.g. MYSQL_PWD) or the 7zz passphrase arg — * secrets travel in `env` (e.g. MYSQL_PWD) —
* never in anything this module logs: errors carry only the command's * never in anything this module logs: errors carry only the command's
* basename and its stderr tail. * basename and its stderr tail.
*/ */
+2
View File
@@ -9,6 +9,7 @@ import {
isGoogleCalendarConfigComplete, isGoogleCalendarConfigComplete,
isPushConfigComplete, isPushConfigComplete,
isSuperAdminConfigComplete, isSuperAdminConfigComplete,
readOperatorSettings,
readSuperAdminSettings, readSuperAdminSettings,
resolveEmailSettings, resolveEmailSettings,
resolveGoogleCalendarSettings, resolveGoogleCalendarSettings,
@@ -231,6 +232,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
push, push,
superAdmin, superAdmin,
googleCalendar, googleCalendar,
operatorAlertEmail: (await readOperatorSettings()).alertEmail,
}), }),
"utf8", "utf8",
); );
+13 -14
View File
@@ -30,7 +30,7 @@ import { stackDir, stackExists } from "./stacks";
*/ */
export const RESTORE_STEPS = [ export const RESTORE_STEPS = [
{ id: "verify", label: "Verify the archive and passphrase" }, { id: "verify", label: "Verify the archive" },
{ id: "check", label: "Check for conflicts and schema version" }, { id: "check", label: "Check for conflicts and schema version" },
{ id: "safety", label: "Take a safety backup of the current business" }, { id: "safety", label: "Take a safety backup of the current business" },
{ id: "stop", label: "Stop the current stack" }, { id: "stop", label: "Stop the current stack" },
@@ -61,15 +61,15 @@ export interface RestorePlan {
schema: { ok: true } | { ok: false; reason: string }; schema: { ok: true } | { ok: false; reason: string };
} }
/** Fast: opens only the (encrypted) header and reads manifest.json. A wrong passphrase fails here. Does not verify the payload. */ /** Fast: reads manifest.json from the archive. Does not verify the payload. */
export async function readArchiveManifest(archivePath: string, passphrase: string): Promise<BackupManifest> { export async function readArchiveManifest(archivePath: string): Promise<BackupManifest> {
return parseManifest(await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.manifest)); return parseManifest(await sevenZipReadMember(archivePath, BACKUP_MEMBERS.manifest));
} }
/** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive or wrong passphrase. */ /** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive. */
export async function inspectArchive(archivePath: string, passphrase: string): Promise<BackupManifest> { export async function inspectArchive(archivePath: string): Promise<BackupManifest> {
await sevenZipTest(archivePath, passphrase); await sevenZipTest(archivePath);
return readArchiveManifest(archivePath, passphrase); return readArchiveManifest(archivePath);
} }
/** Read-only conflict/version report shown on the Restore page before the operator confirms. */ /** Read-only conflict/version report shown on the Restore page before the operator confirms. */
@@ -104,17 +104,16 @@ export interface RestoreResult {
export async function performRestore(params: { export async function performRestore(params: {
archivePath: string; archivePath: string;
passphrase: string;
/** Operator confirmed overwriting an existing business with the same slug. */ /** Operator confirmed overwriting an existing business with the same slug. */
replace: boolean; replace: boolean;
ctx: RestoreContext; ctx: RestoreContext;
}): Promise<RestoreResult> { }): Promise<RestoreResult> {
const { archivePath, passphrase, replace, ctx } = params; const { archivePath, replace, ctx } = params;
const { manifest, envContent } = await ctx.step("verify", async () => { const { manifest, envContent } = await ctx.step("verify", async () => {
ctx.log("Testing the archive (7zz t)…"); ctx.log("Testing the archive (7zz t)…");
const parsed = await inspectArchive(archivePath, passphrase); const parsed = await inspectArchive(archivePath);
const env = await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.env); const env = await sevenZipReadMember(archivePath, BACKUP_MEMBERS.env);
ctx.log(`Backup of "${parsed.slug}" (${parsed.domain}) taken ${parsed.createdAt}.`); ctx.log(`Backup of "${parsed.slug}" (${parsed.domain}) taken ${parsed.createdAt}.`);
// The .env inside the archive must agree with the manifest — the // The .env inside the archive must agree with the manifest — the
@@ -169,7 +168,7 @@ export async function performRestore(params: {
await ctx.step("import", async () => { await ctx.step("import", async () => {
ctx.log("Importing db.sql (streamed from the archive)…"); ctx.log("Importing db.sql (streamed from the archive)…");
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName)); await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName));
}); });
await ctx.step("config", async () => { await ctx.step("config", async () => {
@@ -183,7 +182,7 @@ export async function performRestore(params: {
await ctx.step("media", async () => { await ctx.step("media", async () => {
// Create (don't start) the container so the named volume exists with compose's labels before it's filled. // Create (don't start) the container so the named volume exists with compose's labels before it's filled.
await composeCreate(dir); await composeCreate(dir);
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.media), mediaRestoreCommand(slug)); await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.media), mediaRestoreCommand(slug));
ctx.log("Media restored and handed to uid 1001."); ctx.log("Media restored and handed to uid 1001.");
}); });
+18 -25
View File
@@ -2,55 +2,48 @@ import { runCapture, runQuiet, type CommandSpec } from "./process";
/** /**
* 7-Zip (`7zz`, from Alpine's `7zip` package — see Dockerfile) wrappers. * 7-Zip (`7zz`, from Alpine's `7zip` package — see Dockerfile) wrappers.
* Archives are 7z with AES-256 and encrypted headers (-mhe=on), so even * Archives are plain (unencrypted) 7z: the operator decided backups don't
* the file names inside are hidden without the passphrase. * need a password. That means an archive contains the business's secrets in
* the clear (its .env: database password, auth secret, SMTP password, VAPID
* private key) — which is why files are written owner-only (0600) and the
* download route is behind the Manager login. Treat a downloaded copy like
* the server's own .env files.
* *
* The passphrase is passed as `-p<passphrase>` (7zz has no safer * Every command here runs as an argument array, never through a shell.
* non-interactive channel). It is generated hex/base64url by the Manager
* or typed by the operator, and — like every command here — runs as an
* argument array, never through a shell.
*/ */
function sevenZipBinary(): string { function sevenZipBinary(): string {
return process.env.SEVENZIP_BIN ?? "7zz"; return process.env.SEVENZIP_BIN ?? "7zz";
} }
function assertPassphrase(passphrase: string): void { export function sevenZipCreateCommand(params: { archivePath: string; cwd: string; entries: string[] }): CommandSpec {
// An empty `-p` would make 7zz prompt on a terminal that doesn't exist.
if (passphrase.length < 8) throw new Error("The backup passphrase must be at least 8 characters.");
}
export function sevenZipCreateCommand(params: { archivePath: string; cwd: string; entries: string[]; passphrase: string }): CommandSpec {
assertPassphrase(params.passphrase);
return { return {
command: sevenZipBinary(), command: sevenZipBinary(),
args: ["a", "-t7z", "-mx=3", "-mhe=on", `-p${params.passphrase}`, "-y", "-bd", params.archivePath, ...params.entries], args: ["a", "-t7z", "-mx=3", "-y", "-bd", params.archivePath, ...params.entries],
cwd: params.cwd, cwd: params.cwd,
}; };
} }
export function sevenZipTestCommand(archivePath: string, passphrase: string): CommandSpec { export function sevenZipTestCommand(archivePath: string): CommandSpec {
assertPassphrase(passphrase); return { command: sevenZipBinary(), args: ["t", "-t7z", "-y", "-bd", archivePath] };
return { command: sevenZipBinary(), args: ["t", "-t7z", `-p${passphrase}`, "-y", "-bd", archivePath] };
} }
/** Streams one archive member to stdout (`-so`) — for piping a dump/tar straight into docker without unpacking to disk. */ /** Streams one archive member to stdout (`-so`) — for piping a dump/tar straight into docker without unpacking to disk. */
export function sevenZipExtractToStdoutCommand(archivePath: string, passphrase: string, member: string): CommandSpec { export function sevenZipExtractToStdoutCommand(archivePath: string, member: string): CommandSpec {
assertPassphrase(passphrase); return { command: sevenZipBinary(), args: ["x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", archivePath, member] };
return { command: sevenZipBinary(), args: ["x", "-so", "-t7z", `-p${passphrase}`, "-y", "-bd", "-bso0", "-bsp0", archivePath, member] };
} }
export async function sevenZipCreate(params: Parameters<typeof sevenZipCreateCommand>[0]): Promise<void> { export async function sevenZipCreate(params: Parameters<typeof sevenZipCreateCommand>[0]): Promise<void> {
await runQuiet(sevenZipCreateCommand(params)); await runQuiet(sevenZipCreateCommand(params));
} }
/** Throws if the archive is corrupt or the passphrase is wrong. */ /** Throws if the archive is corrupt. */
export async function sevenZipTest(archivePath: string, passphrase: string): Promise<void> { export async function sevenZipTest(archivePath: string): Promise<void> {
await runQuiet(sevenZipTestCommand(archivePath, passphrase)); await runQuiet(sevenZipTestCommand(archivePath));
} }
/** For SMALL members only (manifest.json, .env) — capped at 1 MiB. */ /** For SMALL members only (manifest.json, .env) — capped at 1 MiB. */
export async function sevenZipReadMember(archivePath: string, passphrase: string, member: string): Promise<string> { export async function sevenZipReadMember(archivePath: string, member: string): Promise<string> {
const { stdout } = await runCapture(sevenZipExtractToStdoutCommand(archivePath, passphrase, member), { maxBytes: 1024 * 1024 }); const { stdout } = await runCapture(sevenZipExtractToStdoutCommand(archivePath, member), { maxBytes: 1024 * 1024 });
return stdout; return stdout;
} }
@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE `ApplicationInstallation` ADD COLUMN `lastCronRunAt` DATETIME(3) NULL;
-- AlterTable
ALTER TABLE `BookingRule` ADD COLUMN `noShowBlockThreshold` INTEGER NULL,
ADD COLUMN `noShowFlagThreshold` INTEGER NULL;
-- AlterTable
ALTER TABLE `BusinessSettings` ADD COLUMN `whatsappReminderLeadHours` INTEGER NOT NULL DEFAULT 24;
-- AlterTable
ALTER TABLE `CustomerProfile` ADD COLUMN `noShowResetAt` DATETIME(3) NULL;
-- AlterTable
ALTER TABLE `Notification` MODIFY `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'STAFF_INVITATION_RECEIVED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE', 'STAFF_WHATSAPP_REMINDERS_DUE') NOT NULL;
-- AlterTable
ALTER TABLE `NotificationPreference` MODIFY `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'STAFF_INVITATION_RECEIVED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE', 'STAFF_WHATSAPP_REMINDERS_DUE') NOT NULL;
-- AlterTable
ALTER TABLE `StaffCalendarConnection` ADD COLUMN `appCreatedCalendar` BOOLEAN NOT NULL DEFAULT false;
+30
View File
@@ -184,6 +184,11 @@ enum NotificationType {
/// for the owner when it observes repeated delivery failures, rather /// for the owner when it observes repeated delivery failures, rather
/// than a domain workflow triggering it directly. See reminder-worker.ts. /// than a domain workflow triggering it directly. See reminder-worker.ts.
OWNER_REPEATED_DELIVERY_FAILURE OWNER_REPEATED_DELIVERY_FAILURE
// Barber-facing (cont.)
/// A nudge that some appointments' WhatsApp reminders are due to send by
/// hand — see whatsapp-reminders.service.ts. Push only by default.
STAFF_WHATSAPP_REMINDERS_DUE
} }
enum NotificationChannel { enum NotificationChannel {
@@ -415,6 +420,10 @@ model BusinessSettings {
bookingTerms String? @db.Text bookingTerms String? @db.Text
privacyPolicyUrl String? privacyPolicyUrl String?
/// How many hours ahead appointments become "due" in the staff WhatsApp
/// reminders queue (/staff/reminders). See whatsapp-reminders.service.ts.
whatsappReminderLeadHours Int @default(24)
/// Who the public Terms of Service / Privacy Policy pages name as "we" — /// Who the public Terms of Service / Privacy Policy pages name as "we" —
/// see src/features/legal/legal-identity.ts. NEXTCHAIR is the default so /// see src/features/legal/legal-identity.ts. NEXTCHAIR is the default so
/// existing businesses keep today's wording. /// existing businesses keep today's wording.
@@ -490,6 +499,14 @@ model BookingRule {
/// payments phase (see docs/implementation-plan.md §6, "later phase"). /// payments phase (see docs/implementation-plan.md §6, "later phase").
requireDeposit Boolean @default(false) requireDeposit Boolean @default(false)
/// Both null = no-show handling is off. A customer's no-show count is
/// derived (see no-show.service.ts), never stored: NO_SHOW appointments
/// since their last attended (COMPLETED) one or since an owner reset.
/// At/above the flag threshold staff see a warning symbol; at/above the
/// block threshold online booking is refused (staff can still book them).
noShowFlagThreshold Int?
noShowBlockThreshold Int?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -646,6 +663,10 @@ model ApplicationInstallation {
failureReason String? @db.Text failureReason String? @db.Text
/// Stamped by /api/cron/process-notifications on every authorised run, so
/// /api/health can tell "the cron stopped" from "nothing to send".
lastCronRunAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -808,6 +829,10 @@ model CustomerProfile {
phone String? phone String?
isGuest Boolean @default(true) isGuest Boolean @default(true)
/// An owner's "forgive" — no-shows before this moment no longer count
/// towards the flag/block thresholds. See no-show.service.ts.
noShowResetAt DateTime?
preferredBarberId String? preferredBarberId String?
preferredBarber StaffProfile? @relation("PreferredBarber", fields: [preferredBarberId], references: [id], onDelete: SetNull) preferredBarber StaffProfile? @relation("PreferredBarber", fields: [preferredBarberId], references: [id], onDelete: SetNull)
@@ -954,6 +979,11 @@ model StaffCalendarConnection {
selectedCalendarId String selectedCalendarId String
selectedCalendarName String selectedCalendarName String
/// True when the connection was made with the narrow calendar.app.created
/// scope: selectedCalendarId is a secondary calendar this app created, and
/// there is no calendar picker (the scope can't list the user's calendars).
/// False = a pre-existing connection with the broader scopes.
appCreatedCalendar Boolean @default(false)
connectedAt DateTime @default(now()) connectedAt DateTime @default(now())
lastSyncAt DateTime? lastSyncAt DateTime?
@@ -16,6 +16,8 @@ import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getAppointmentById } from "@/services/scheduling/appointment.service"; import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization"; import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions"; import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
import { getNoShowStandings } from "@/services/customer/no-show.service";
import { AddStampButton } from "@/features/staff/components/add-stamp-button"; import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button"; import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set"; import { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set";
@@ -64,6 +66,10 @@ export default async function OwnerAppointmentDetailsPage({
throw error; throw error;
} }
const noShow = (await getNoShowStandings(prisma, { businessId: context.businessId, customerIds: [appointment.customerId] })).get(
appointment.customerId,
);
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([ const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
prisma.customerNote.findMany({ prisma.customerNote.findMany({
where: { customerId: appointment.customerId }, where: { customerId: appointment.customerId },
@@ -112,6 +118,7 @@ export default async function OwnerAppointmentDetailsPage({
<Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline"> <Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline">
{appointment.customer.firstName} {appointment.customer.lastName} {appointment.customer.firstName} {appointment.customer.lastName}
</Link> </Link>
{noShow && <NoShowBadge className="ml-2 align-middle" count={noShow.count} flagged={noShow.flagged} blocked={noShow.blocked} />}
<p className="text-muted text-sm"> <p className="text-muted text-sm">
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"} {appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
</p> </p>
+9 -1
View File
@@ -29,6 +29,7 @@ import { ViewSwitch } from "@/features/staff/components/view-switch";
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button"; import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
import { StaffFilterPills } from "@/features/owner/components/staff-filter-pills"; import { StaffFilterPills } from "@/features/owner/components/staff-filter-pills";
import type { StaffFilterOption } from "@/features/owner/components/staff-filter-pills"; import type { StaffFilterOption } from "@/features/owner/components/staff-filter-pills";
import { getNoShowStandings } from "@/services/customer/no-show.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell"; import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain"; import type { AppointmentStatus } from "@/types/domain";
@@ -117,6 +118,11 @@ export default async function OwnerCalendarPage({
customerIds: [...new Set(appointments.map((a) => a.customerId))], customerIds: [...new Set(appointments.map((a) => a.customerId))],
}); });
const noShowStandings = await getNoShowStandings(prisma, {
businessId: context.businessId,
customerIds: appointments.map((a) => a.customerId),
});
const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), businessTimezone) : null; const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), businessTimezone) : null;
const staffToRender = selectedStaff ? [selectedStaff] : allStaff; const staffToRender = selectedStaff ? [selectedStaff] : allStaff;
@@ -162,6 +168,7 @@ export default async function OwnerCalendarPage({
status: STATUS_MAP[a.status] ?? "confirmed", status: STATUS_MAP[a.status] ?? "confirmed",
hasNotes: !!a.notes, hasNotes: !!a.notes,
hasAvailableReward: rewardFlags.get(a.customerId) ?? false, hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
noShowFlagged: noShowStandings.get(a.customerId)?.flagged ?? false,
})); }));
return { workingWindow, blocks, wholeDayOff, staffAppointments }; return { workingWindow, blocks, wholeDayOff, staffAppointments };
@@ -235,7 +242,7 @@ export default async function OwnerCalendarPage({
</div> </div>
<div className="mt-3 px-6"> <div className="mt-3 px-6">
<RemindersLink businessId={context.businessId} timezone={businessTimezone} /> <RemindersLink businessId={context.businessId} />
</div> </div>
<div className="mt-4"> <div className="mt-4">
@@ -283,6 +290,7 @@ export default async function OwnerCalendarPage({
)} )}
hasNotes={!!a.notes} hasNotes={!!a.notes}
hasAvailableReward={rewardFlags.get(a.customerId) ?? false} hasAvailableReward={rewardFlags.get(a.customerId) ?? false}
noShowFlagged={noShowStandings.get(a.customerId)?.flagged ?? false}
actions={ actions={
<Link <Link
href={`/owner/appointments/${a.id}`} href={`/owner/appointments/${a.id}`}
@@ -41,6 +41,8 @@ export default async function OwnerBookingRulesPage() {
customerCanChooseStaff: rule.customerCanChooseStaff, customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking, allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval, requireApproval: rule.requireApproval,
noShowFlagThreshold: rule.noShowFlagThreshold,
noShowBlockThreshold: rule.noShowBlockThreshold,
}} }}
/> />
</SettingsPageShell> </SettingsPageShell>
@@ -55,6 +55,7 @@ export default async function OwnerBusinessDetailsPage() {
cancellationPolicy: business.settings?.cancellationPolicy ?? "", cancellationPolicy: business.settings?.cancellationPolicy ?? "",
legalNameDisplay: business.settings?.legalNameDisplay ?? "NEXTCHAIR", legalNameDisplay: business.settings?.legalNameDisplay ?? "NEXTCHAIR",
legalContactEmail: business.settings?.legalContactEmail ?? "NEXTCHAIR", legalContactEmail: business.settings?.legalContactEmail ?? "NEXTCHAIR",
whatsappReminderLeadHours: business.settings?.whatsappReminderLeadHours ?? 24,
}} }}
/> />
</SettingsPageShell> </SettingsPageShell>
@@ -41,8 +41,6 @@ export default async function OwnerLoyaltyConfigPage() {
rewardLabel: programme.rewardLabel ?? undefined, rewardLabel: programme.rewardLabel ?? undefined,
maxRewardValue: programme.maxRewardValue?.toString(), maxRewardValue: programme.maxRewardValue?.toString(),
freeServiceId: programme.freeServiceId ?? undefined, freeServiceId: programme.freeServiceId ?? undefined,
autoApplyReward: programme.autoApplyReward,
requiresStaffApproval: programme.requiresStaffApproval,
stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined, stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined,
rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined, rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined,
earnOnCancelled: programme.earnOnCancelled, earnOnCancelled: programme.earnOnCancelled,
@@ -63,8 +61,6 @@ export default async function OwnerLoyaltyConfigPage() {
stampsRequired: 10, stampsRequired: 10,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE" as const, rewardType: "PERCENTAGE" as const,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false, earnOnCancelled: false,
earnOnNoShow: false, earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS, cardFont: LoyaltyCardFont.SANS,
@@ -13,6 +13,8 @@ import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getAppointmentById } from "@/services/scheduling/appointment.service"; import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization"; import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions"; import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
import { getNoShowStandings } from "@/services/customer/no-show.service";
import { AddStampButton } from "@/features/staff/components/add-stamp-button"; import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button"; import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button"; import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
@@ -51,6 +53,10 @@ export default async function StaffAppointmentDetailsPage({
throw error; throw error;
} }
const noShow = (await getNoShowStandings(prisma, { businessId: staff.businessId, customerIds: [appointment.customerId] })).get(
appointment.customerId,
);
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([ const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
prisma.customerNote.findMany({ prisma.customerNote.findMany({
where: { customerId: appointment.customerId }, where: { customerId: appointment.customerId },
@@ -101,6 +107,7 @@ export default async function StaffAppointmentDetailsPage({
> >
{appointment.customer.firstName} {appointment.customer.lastName} {appointment.customer.firstName} {appointment.customer.lastName}
</Link> </Link>
{noShow && <NoShowBadge className="ml-2 align-middle" count={noShow.count} flagged={noShow.flagged} blocked={noShow.blocked} />}
<p className="text-muted text-sm"> <p className="text-muted text-sm">
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"} {appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
</p> </p>
@@ -18,6 +18,9 @@ import { getActingBusinessContext } from "@/lib/business/get-acting-business-con
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service"; import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { listAppointments } from "@/services/scheduling/appointment.service"; import { listAppointments } from "@/services/scheduling/appointment.service";
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
import { ResetNoShowsButton } from "@/features/staff/components/reset-no-shows-button";
import { countNoShows, evaluateStanding, getNoShowThresholds } from "@/services/customer/no-show.service";
import { AddStampButton } from "@/features/staff/components/add-stamp-button"; import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button"; import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button";
import { CustomerNotes } from "@/features/staff/components/customer-notes"; import { CustomerNotes } from "@/features/staff/components/customer-notes";
@@ -89,6 +92,13 @@ export default async function StaffCustomerDetailsPage({
getLoyaltySummary(prisma, { customerId: id, businessId: context.businessId }), getLoyaltySummary(prisma, { customerId: id, businessId: context.businessId }),
]); ]);
// Derived from the history already loaded above — no extra query beyond the two rule numbers.
const noShowStanding = evaluateStanding(
countNoShows([...past, ...cancelled], customer.noShowResetAt),
await getNoShowThresholds(prisma, context.businessId),
);
const canResetNoShows = await hasPermission(context.userId, Permission.MANAGE_ALL_APPOINTMENTS);
const loyaltyTransactions = loyalty?.accountId const loyaltyTransactions = loyalty?.accountId
? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 10 }) ? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 10 })
: []; : [];
@@ -174,8 +184,23 @@ export default async function StaffCustomerDetailsPage({
<p className="text-muted text-xs"> <p className="text-muted text-xs">
{customer.isGuest ? "Guest customer" : "Registered customer"} {customer.isGuest ? "Guest customer" : "Registered customer"}
</p> </p>
<NoShowBadge
className="mt-1"
count={noShowStanding.count}
flagged={noShowStanding.flagged}
blocked={noShowStanding.blocked}
/>
</div> </div>
</div> </div>
{noShowStanding.count > 0 && (
<div className="border-line space-y-2 border-t pt-4 text-sm">
<p className="text-muted">
{noShowStanding.count} no-show{noShowStanding.count === 1 ? "" : "s"} since their last visit.
{noShowStanding.blocked && " Online booking is blocked for them — you can still book them in."}
</p>
{canResetNoShows && <ResetNoShowsButton customerId={customer.id} />}
</div>
)}
<div className="border-line space-y-1 border-t pt-4 text-sm"> <div className="border-line space-y-1 border-t pt-4 text-sm">
<p className="text-muted">{customer.email}</p> <p className="text-muted">{customer.email}</p>
+13 -1
View File
@@ -12,6 +12,8 @@ import { getUnreadNotificationCount } from "@/services/notifications/notificatio
import { clientDirectoryWhere } from "@/services/customer/client-directory"; import { clientDirectoryWhere } from "@/services/customer/client-directory";
import { matchesCustomerSearch } from "@/services/customer/customer.service"; import { matchesCustomerSearch } from "@/services/customer/customer.service";
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service"; import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
import { countNoShows, evaluateStanding, getNoShowThresholds } from "@/services/customer/no-show.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell"; import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import { cn } from "@/lib/utils/cn"; import { cn } from "@/lib/utils/cn";
@@ -49,6 +51,7 @@ export default async function StaffClientsPage({
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
noShowResetAt: true,
appointments: { appointments: {
where: { businessId: context.businessId }, where: { businessId: context.businessId },
orderBy: { startAt: "desc" }, orderBy: { startAt: "desc" },
@@ -57,6 +60,8 @@ export default async function StaffClientsPage({
}, },
}); });
const noShowThresholds = await getNoShowThresholds(prisma, context.businessId);
const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, { const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, {
businessId: context.businessId, businessId: context.businessId,
customerIds: customers.map((c) => c.id), customerIds: customers.map((c) => c.id),
@@ -166,6 +171,10 @@ export default async function StaffClientsPage({
(a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now, (a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now,
); );
const loyalty = loyaltySummaries.get(customer.id); const loyalty = loyaltySummaries.get(customer.id);
const noShow = evaluateStanding(
countNoShows(customer.appointments, customer.noShowResetAt),
noShowThresholds,
);
return ( return (
<Link <Link
@@ -178,8 +187,11 @@ export default async function StaffClientsPage({
{customer.lastName.charAt(0)} {customer.lastName.charAt(0)}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold"> <p className="flex items-center gap-2 text-sm font-bold">
<span className="truncate">
{customer.firstName} {customer.lastName} {customer.firstName} {customer.lastName}
</span>
<NoShowBadge count={noShow.count} flagged={noShow.flagged} blocked={noShow.blocked} />
</p> </p>
<p className="text-muted truncate text-xs"> <p className="text-muted truncate text-xs">
{customer.phone ?? customer.email} {customer.phone ?? customer.email}
+9 -1
View File
@@ -24,6 +24,7 @@ import { RemindersLink } from "@/features/staff/components/reminders-link";
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar"; import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { ViewSwitch } from "@/features/staff/components/view-switch"; import { ViewSwitch } from "@/features/staff/components/view-switch";
import { BlockTimeButton } from "@/features/staff/components/block-time-button"; import { BlockTimeButton } from "@/features/staff/components/block-time-button";
import { getNoShowStandings } from "@/services/customer/no-show.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell"; import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain"; import type { AppointmentStatus } from "@/types/domain";
@@ -87,6 +88,11 @@ export default async function StaffCalendarPage({
customerIds: [...new Set(appointments.map((a) => a.customerId))], customerIds: [...new Set(appointments.map((a) => a.customerId))],
}); });
const noShowStandings = await getNoShowStandings(prisma, {
businessId: staff.businessId,
customerIds: appointments.map((a) => a.customerId),
});
const workingWindow = const workingWindow =
workingHours && workingHours &&
!workingHours.isOff && !workingHours.isOff &&
@@ -123,6 +129,7 @@ export default async function StaffCalendarPage({
status: STATUS_MAP[a.status] ?? "confirmed", status: STATUS_MAP[a.status] ?? "confirmed",
hasNotes: !!a.notes, hasNotes: !!a.notes,
hasAvailableReward: rewardFlags.get(a.customerId) ?? false, hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
noShowFlagged: noShowStandings.get(a.customerId)?.flagged ?? false,
})); }));
const currentTimeMinutes = const currentTimeMinutes =
@@ -180,7 +187,7 @@ export default async function StaffCalendarPage({
</div> </div>
<div className="mt-3 px-6"> <div className="mt-3 px-6">
<RemindersLink businessId={staff.businessId} timezone={staff.business.timezone} staffId={staff.id} /> <RemindersLink businessId={staff.businessId} staffId={staff.id} />
</div> </div>
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6"> <div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
@@ -213,6 +220,7 @@ export default async function StaffCalendarPage({
formattedTime={formatRange(a.startMinutes, a.endMinutes)} formattedTime={formatRange(a.startMinutes, a.endMinutes)}
hasNotes={a.hasNotes} hasNotes={a.hasNotes}
hasAvailableReward={a.hasAvailableReward} hasAvailableReward={a.hasAvailableReward}
noShowFlagged={a.noShowFlagged}
actions={ actions={
<Link <Link
href={`/staff/appointments/${a.id}`} href={`/staff/appointments/${a.id}`}
+71 -13
View File
@@ -10,8 +10,12 @@ import { hasPermission } from "@/lib/auth/permissions";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessCountry } from "@/lib/business/get-business-country"; import { getBusinessCountry } from "@/lib/business/get-business-country";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { addDays, todayIn } from "@/lib/time/business-time"; import { addDays, calendarDateOf, todayIn } from "@/lib/time/business-time";
import { listReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service"; import {
getWhatsAppLeadHours,
listDueReminderCandidates,
listReminderCandidates,
} from "@/services/scheduling/whatsapp-reminders.service";
import { DateStrip } from "@/features/staff/components/date-strip"; import { DateStrip } from "@/features/staff/components/date-strip";
import { WhatsAppSendLink } from "@/features/staff/components/whatsapp-send-link"; import { WhatsAppSendLink } from "@/features/staff/components/whatsapp-send-link";
import { reminderWhatsAppUrl } from "@/features/staff/lib/whatsapp-reminder-link"; import { reminderWhatsAppUrl } from "@/features/staff/lib/whatsapp-reminder-link";
@@ -29,29 +33,46 @@ export default async function WhatsAppRemindersPage({
const timezone = context.businessTimezone; const timezone = context.businessTimezone;
const { date: dateParam } = await searchParams; const { date: dateParam } = await searchParams;
// Tomorrow by default — the day this exists to prepare for. // Default view is "due now": what should be sent right away, soonest first. A date in the URL
const date = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : addDays(todayIn(timezone), 1); // switches to browsing one calendar day ahead.
const browsingDate = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : null;
const dueNow = browsingDate === null;
const date = browsingDate ?? addDays(todayIn(timezone), 1);
const leadHours = await getWhatsAppLeadHours(prisma, context.businessId);
// A barber works their own list; an owner (anyone who can see all // A barber works their own list; an owner (anyone who can see all
// appointments) works the whole shop's, with the barber named on each row. // appointments) works the whole shop's, with the barber named on each row.
const canSeeEveryone = await hasPermission(context.userId, Permission.VIEW_ALL_STAFF_APPOINTMENTS); const canSeeEveryone = await hasPermission(context.userId, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
const staffFilter = canSeeEveryone ? undefined : (context.staffId ?? undefined);
const [appointments, country] = await Promise.all([ const [appointments, country] = await Promise.all([
canSeeEveryone || context.staffId canSeeEveryone || context.staffId
? listReminderCandidates(prisma, { ? dueNow
? listDueReminderCandidates(prisma, { businessId: context.businessId, leadHours, staffId: staffFilter })
: listReminderCandidates(prisma, {
businessId: context.businessId, businessId: context.businessId,
date, date,
businessTimezone: timezone, businessTimezone: timezone,
staffId: canSeeEveryone ? undefined : (context.staffId ?? undefined), staffId: staffFilter,
}) })
: Promise.resolve([]), : Promise.resolve([]),
getBusinessCountry(context.businessId), getBusinessCountry(context.businessId),
]); ]);
const today = todayIn(timezone);
const tomorrow = addDays(today, 1);
const rows = appointments.map((appointment) => { const rows = appointments.map((appointment) => {
const startDate = calendarDateOf(appointment.startAt, timezone);
const dayLabel =
startDate === today
? "Today"
: startDate === tomorrow
? "Tomorrow"
: appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone });
const serviceNames = appointment.services.map((s) => s.service.name).join(" + "); const serviceNames = appointment.services.map((s) => s.service.name).join(" + ");
const staffName = appointment.staff.user.name ?? "your barber"; const staffName = appointment.staff.user.name ?? "your barber";
return { return {
id: appointment.id, id: appointment.id,
dayLabel,
time: appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone }), time: appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone }),
customerName: `${appointment.customer.firstName} ${appointment.customer.lastName}`.trim(), customerName: `${appointment.customer.firstName} ${appointment.customer.lastName}`.trim(),
customerId: appointment.customerId, customerId: appointment.customerId,
@@ -76,12 +97,33 @@ export default async function WhatsAppRemindersPage({
return ( return (
<div className="pb-12"> <div className="pb-12">
<TopNav title="WhatsApp Reminders" onBack sticky className="bg-stone" /> <TopNav title="WhatsApp Reminders" onBack sticky className="bg-stone" />
<DateStrip selectedDate={date} businessTimezone={timezone} basePath="/staff/reminders" /> {!dueNow && <DateStrip selectedDate={date} businessTimezone={timezone} basePath="/staff/reminders" />}
<PageContainer size="narrow" className="space-y-4"> <PageContainer size="narrow" className="space-y-4">
<div className="flex items-center justify-between gap-3">
{dueNow ? (
<p className="text-muted text-sm">
Due now: starting within the next <strong className="text-ink">{leadHours} hours</strong>
</p>
) : (
<Link href="/staff/reminders" className="text-sm font-bold underline">
← Due now
</Link>
)}
{dueNow && (
<Link
href={`/staff/reminders?date=${addDays(todayIn(timezone), 1)}`}
className="text-muted shrink-0 text-xs font-bold underline"
>
Browse by day
</Link>
)}
</div>
{rows.length > 0 && ( {rows.length > 0 && (
<p className="text-muted text-sm"> <p className="text-muted text-sm">
<strong className="text-ink">{toSend}</strong> to send · {sentCount} sent <strong className="text-ink">{toSend}</strong> to send
{!dueNow && ` · ${sentCount} sent`}
{noPhoneCount > 0 && ` · ${noPhoneCount} with no usable phone number`} {noPhoneCount > 0 && ` · ${noPhoneCount} with no usable phone number`}
</p> </p>
)} )}
@@ -89,14 +131,30 @@ export default async function WhatsAppRemindersPage({
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyState <EmptyState
icon={MessageCircle} icon={MessageCircle}
title="No confirmed appointments this day" title={dueNow ? "You're all caught up" : "No confirmed appointments this day"}
description="Confirmed bookings show up here, ready to remind." description={
dueNow
? `Nothing is due to remind right now. Reminders appear here ${leadHours} hours before each appointment.`
: "Confirmed bookings show up here, ready to remind."
}
/> />
) : ( ) : (
<ul className="space-y-3"> <ul className="space-y-3">
{rows.map((row) => ( {rows.map((row, index) => (
<li key={row.id} className="border-line flex items-center gap-4 rounded-md border bg-white p-4"> <li
<p className="w-14 shrink-0 text-base font-bold tabular-nums">{row.time}</p> key={row.id}
className={`flex items-center gap-4 rounded-md border bg-white p-4 ${
dueNow && index === 0 ? "border-accent ring-accent/30 ring-2" : "border-line"
}`}
>
<div className="w-20 shrink-0">
<p className="text-base font-bold tabular-nums">{row.time}</p>
{dueNow && (
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
{index === 0 ? `Next · ${row.dayLabel}` : row.dayLabel}
</p>
)}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link href={`/staff/clients/${row.customerId}`} className="block truncate text-sm font-bold"> <Link href={`/staff/clients/${row.customerId}`} className="block truncate text-sm font-bold">
{row.customerName} {row.customerName}
@@ -39,6 +39,7 @@ export default async function StaffCalendarSettingsPage() {
connected: true, connected: true,
accountEmail: connection.accountEmail, accountEmail: connection.accountEmail,
selectedCalendarName: connection.selectedCalendarName, selectedCalendarName: connection.selectedCalendarName,
appCreatedCalendar: connection.appCreatedCalendar,
lastSyncAt: connection.lastSyncAt?.toISOString(), lastSyncAt: connection.lastSyncAt?.toISOString(),
lastSyncError: connection.lastSyncError, lastSyncError: connection.lastSyncError,
} }
@@ -22,6 +22,7 @@ const STAFF_GROUPS: NotificationPreferenceGroup[] = [
{ type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED, label: "Appointment rescheduled" }, { type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED, label: "Appointment rescheduled" },
{ type: NotificationType.STAFF_APPOINTMENT_CANCELLED, label: "Appointment cancelled" }, { type: NotificationType.STAFF_APPOINTMENT_CANCELLED, label: "Appointment cancelled" },
{ type: NotificationType.STAFF_APPOINTMENT_REMINDER, label: "Upcoming appointment reminders" }, { type: NotificationType.STAFF_APPOINTMENT_REMINDER, label: "Upcoming appointment reminders" },
{ type: NotificationType.STAFF_WHATSAPP_REMINDERS_DUE, label: "WhatsApp reminders ready to send" },
{ type: NotificationType.STAFF_SCHEDULE_CHANGED, label: "Your schedule changed" }, { type: NotificationType.STAFF_SCHEDULE_CHANGED, label: "Your schedule changed" },
{ type: NotificationType.STAFF_INVITATION_RECEIVED, label: "Team invitations" }, { type: NotificationType.STAFF_INVITATION_RECEIVED, label: "Team invitations" },
]; ];
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { isInstalledForRequest } from "@/lib/installation/installation-state"; import { INSTALLATION_SINGLETON_ID, isInstalledForRequest } from "@/lib/installation/installation-state";
import { secretsMatch } from "@/lib/security/secrets-match"; import { secretsMatch } from "@/lib/security/secrets-match";
import { expireOverdueRewards } from "@/services/loyalty/loyalty.service"; import { expireOverdueRewards } from "@/services/loyalty/loyalty.service";
import { nudgeStaffWhatsAppReminders } from "@/services/scheduling/whatsapp-reminders.service";
import { getEmailProvider, getPushProvider } from "@/services/notifications/providers"; import { getEmailProvider, getPushProvider } from "@/services/notifications/providers";
import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker"; import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker";
@@ -48,6 +49,11 @@ export async function POST(request: Request) {
return NextResponse.json({ skipped: "not_installed" }); return NextResponse.json({ skipped: "not_installed" });
} }
// Proof of life for /api/health: "the cron stopped" must be distinguishable from "nothing to send".
await prisma.applicationInstallation
.updateMany({ where: { id: INSTALLATION_SINGLETON_ID }, data: { lastCronRunAt: new Date() } })
.catch(() => {});
const result = await processDueNotificationDeliveries(prisma, { const result = await processDueNotificationDeliveries(prisma, {
emailProvider: getEmailProvider(), emailProvider: getEmailProvider(),
pushProvider: getPushProvider(), pushProvider: getPushProvider(),
@@ -55,5 +61,12 @@ export async function POST(request: Request) {
// Piggybacks the loyalty reward expiry sweep on the same scheduled trigger // Piggybacks the loyalty reward expiry sweep on the same scheduled trigger
// rather than standing up a second cron endpoint for one small job. // rather than standing up a second cron endpoint for one small job.
await expireOverdueRewards(prisma); await expireOverdueRewards(prisma);
// Same trigger again: tell barbers when WhatsApp reminders are waiting to be sent by hand.
// Never lets a failure here fail the run — the notification queue above already did its job.
try {
await nudgeStaffWhatsAppReminders(prisma);
} catch (error) {
console.error("[cron] WhatsApp reminder nudge failed", error instanceof Error ? error.message : error);
}
return NextResponse.json(result); return NextResponse.json(result);
} }
+32 -1
View File
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getInstallationState } from "@/lib/installation/installation-state"; import { getInstallationState } from "@/lib/installation/installation-state";
import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
import { assessQueueHealth, getQueueHealth } from "@/services/notifications/queue-health";
/** /**
* Docker/orchestrator health check — deliberately never requires * Docker/orchestrator health check — deliberately never requires
@@ -17,6 +19,11 @@ import { getInstallationState } from "@/lib/installation/installation-state";
* installation token, rather than Manager needing its own authenticated * installation token, rather than Manager needing its own authenticated
* status endpoint. Deliberately still just a boolean + a timestamp: never * status endpoint. Deliberately still just a boolean + a timestamp: never
* the installation token itself, never anything else about the tenant. * the installation token itself, never anything else about the tenant.
*
* Also reports whether notifications are actually going out (`queue`,
* `cron`, `degradedReasons`): the Manager emails the operator when this
* turns "degraded". It still answers HTTP 200 when degraded — the Docker
* healthcheck must not restart a container over a stopped cron.
*/ */
export async function GET() { export async function GET() {
try { try {
@@ -27,10 +34,34 @@ export async function GET() {
const { installed, installedAt } = await getInstallationState().catch(() => ({ installed: false, installedAt: null })); const { installed, installedAt } = await getInstallationState().catch(() => ({ installed: false, installedAt: null }));
// Never lets the queue report break the probe itself — a failure here just omits the extra fields.
let queueReport: Record<string, unknown> = {};
let status: "healthy" | "degraded" = "healthy";
try {
const [queue, installation] = await Promise.all([
getQueueHealth(prisma),
prisma.applicationInstallation.findUnique({
where: { id: INSTALLATION_SINGLETON_ID },
select: { lastCronRunAt: true },
}),
]);
const lastCronRunAt = installation?.lastCronRunAt ?? null;
const assessment = assessQueueHealth({ installed, installedAt, lastCronRunAt, queue });
status = assessment.status;
queueReport = {
queue,
cron: { lastCronRunAt: lastCronRunAt ? lastCronRunAt.toISOString() : null },
degradedReasons: assessment.degradedReasons,
};
} catch {
// omitted
}
return NextResponse.json({ return NextResponse.json({
status: "healthy", status,
database: "reachable", database: "reachable",
installed, installed,
installedAt: installedAt ? installedAt.toISOString() : null, installedAt: installedAt ? installedAt.toISOString() : null,
...queueReport,
}); });
} }
@@ -40,7 +40,10 @@ export async function GET(request: Request) {
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL)); return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
} }
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } }); const staffProfile = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
select: { id: true, business: { select: { name: true, timezone: true } } },
});
if (!staffProfile) { if (!staffProfile) {
return settingsRedirect("error", "not_staff"); return settingsRedirect("error", "not_staff");
} }
@@ -57,11 +60,23 @@ export async function GET(request: Request) {
try { try {
const provider = new GoogleCalendarProvider(); const provider = new GoogleCalendarProvider();
const tokens = await provider.exchangeCodeForTokens({ code, redirectUri: getRedirectUri() }); const tokens = await provider.exchangeCodeForTokens({ code, redirectUri: getRedirectUri() });
const calendars = await provider.listWritableCalendars(tokens.accessToken); // The narrow scope can't list a person's calendars (that is the point of it), so events go to a
const primaryCalendar = calendars.find((c) => c.primary) ?? calendars[0]; // calendar this app creates. Reconnecting reuses the one it made before, if it's still there,
if (!primaryCalendar) { // rather than adding another "bookings" calendar every time.
return settingsRedirect("error", "no_writable_calendar"); const existing = await prisma.staffCalendarConnection.findUnique({
} where: { staffId: staffProfile.id },
select: { appCreatedCalendar: true, selectedCalendarId: true, selectedCalendarName: true },
});
const reusable =
existing?.appCreatedCalendar &&
(await provider.calendarExists({ accessToken: tokens.accessToken, calendarId: existing.selectedCalendarId }));
const primaryCalendar = reusable
? { id: existing.selectedCalendarId, name: existing.selectedCalendarName }
: await provider.createCalendar({
accessToken: tokens.accessToken,
name: `${staffProfile.business.name} bookings`,
timeZone: staffProfile.business.timezone,
});
const connection = await prisma.staffCalendarConnection.upsert({ const connection = await prisma.staffCalendarConnection.upsert({
where: { staffId: staffProfile.id }, where: { staffId: staffProfile.id },
@@ -74,6 +89,7 @@ export async function GET(request: Request) {
tokenExpiresAt: tokens.expiresAt, tokenExpiresAt: tokens.expiresAt,
selectedCalendarId: primaryCalendar.id, selectedCalendarId: primaryCalendar.id,
selectedCalendarName: primaryCalendar.name, selectedCalendarName: primaryCalendar.name,
appCreatedCalendar: true,
}, },
update: { update: {
externalAccountId: tokens.externalAccountId, externalAccountId: tokens.externalAccountId,
@@ -83,6 +99,7 @@ export async function GET(request: Request) {
tokenExpiresAt: tokens.expiresAt, tokenExpiresAt: tokens.expiresAt,
selectedCalendarId: primaryCalendar.id, selectedCalendarId: primaryCalendar.id,
selectedCalendarName: primaryCalendar.name, selectedCalendarName: primaryCalendar.name,
appCreatedCalendar: true,
revokedAt: null, revokedAt: null,
lastSyncError: null, lastSyncError: null,
}, },
+7 -1
View File
@@ -1,5 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import { Gift, NotebookText } from "lucide-react"; import { Gift, NotebookText, UserX } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill"; import { StatusPill } from "@/components/ui/status-pill";
@@ -20,6 +20,8 @@ export interface AppointmentCardProps {
hasNotes?: boolean; hasNotes?: boolean;
/** Small loyalty-reward-available indicator — same. */ /** Small loyalty-reward-available indicator — same. */
hasAvailableReward?: boolean; hasAvailableReward?: boolean;
/** Customer has reached the no-show flag threshold — staff cards only. */
noShowFlagged?: boolean;
/** Reschedule/Cancel/Book again buttons — left to the caller so this /** Reschedule/Cancel/Book again buttons — left to the caller so this
* component stays agnostic of which actions apply to which status. */ * component stays agnostic of which actions apply to which status. */
actions?: ReactNode; actions?: ReactNode;
@@ -35,6 +37,7 @@ export function AppointmentCard({
formattedTime, formattedTime,
hasNotes, hasNotes,
hasAvailableReward, hasAvailableReward,
noShowFlagged,
actions, actions,
}: AppointmentCardProps) { }: AppointmentCardProps) {
return ( return (
@@ -72,6 +75,9 @@ export function AppointmentCard({
aria-label="Reward available" aria-label="Reward available"
/> />
)} )}
{noShowFlagged && (
<UserX className="text-warning-foreground h-3.5 w-3.5 shrink-0" aria-label="Has missed appointments" />
)}
</div> </div>
<p className="text-muted text-xs"> <p className="text-muted text-xs">
{personLabel} {person.name} {personLabel} {person.name}
@@ -86,6 +86,7 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi
appointmentId: parsed.data.appointmentId, appointmentId: parsed.data.appointmentId,
staffId: parsed.data.staffId, staffId: parsed.data.staffId,
startAt: new Date(parsed.data.startAt), startAt: new Date(parsed.data.startAt),
actorUserId: session?.user?.id,
}); });
if (!result.success) { if (!result.success) {
+15 -1
View File
@@ -5,10 +5,11 @@ import { auth } from "@/auth";
import { clearDraft, readDraft, writeDraft } from "@/lib/booking-draft/cookie"; import { clearDraft, readDraft, writeDraft } from "@/lib/booking-draft/cookie";
import { guestDetailsSchema } from "@/lib/booking-draft/schema"; import { guestDetailsSchema } from "@/lib/booking-draft/schema";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getNoShowStanding } from "@/services/customer/no-show.service";
import { getAppointmentById } from "@/services/scheduling/appointment.service"; import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { validateBookingSlot } from "@/services/scheduling/availability.service"; import { validateBookingSlot } from "@/services/scheduling/availability.service";
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization"; import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
import { getFirstFriendlyMessage } from "../error-messages"; import { getFirstFriendlyMessage, getFriendlyMessage } from "../error-messages";
import { import {
preselectServiceSchema, preselectServiceSchema,
saveBarberTimeStepSchema, saveBarberTimeStepSchema,
@@ -170,6 +171,14 @@ export async function saveDetailsStepAction(input: unknown): Promise<StepActionR
fieldErrors: guestParsed.error.flatten().fieldErrors, fieldErrors: guestParsed.error.flatten().fieldErrors,
}; };
} }
// Same rule createBooking enforces at the final commit — told here so the customer isn't
// sent through the review step only to be refused at the end.
const guestStanding = await getNoShowStanding(prisma, {
businessId: parsed.data.businessId,
email: guestParsed.data.email,
phone: guestParsed.data.phone,
});
if (guestStanding.blocked) return { success: false, error: getFriendlyMessage("CUSTOMER_BOOKING_BLOCKED") };
await writeDraft({ await writeDraft({
...draft, ...draft,
guest: guestParsed.data, guest: guestParsed.data,
@@ -177,6 +186,11 @@ export async function saveDetailsStepAction(input: unknown): Promise<StepActionR
policyAcknowledged: true, policyAcknowledged: true,
}); });
} else { } else {
const profile = await prisma.customerProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
if (profile) {
const standing = await getNoShowStanding(prisma, { businessId: parsed.data.businessId, customerId: profile.id });
if (standing.blocked) return { success: false, error: getFriendlyMessage("CUSTOMER_BOOKING_BLOCKED") };
}
await writeDraft({ await writeDraft({
...draft, ...draft,
guest: undefined, guest: undefined,
+2
View File
@@ -13,6 +13,8 @@ const FRIENDLY_MESSAGES: Record<BookingValidationErrorCode, string> = {
STAFF_CANNOT_PERFORM_SERVICE: "This barber doesn't offer one of the selected services.", STAFF_CANNOT_PERFORM_SERVICE: "This barber doesn't offer one of the selected services.",
BUSINESS_CLOSED: "The business is closed at this time — please choose another slot.", BUSINESS_CLOSED: "The business is closed at this time — please choose another slot.",
BUSINESS_HOLIDAY: "The business is closed on that date — please choose another day.", BUSINESS_HOLIDAY: "The business is closed on that date — please choose another day.",
CUSTOMER_BOOKING_BLOCKED:
"We can't take online bookings for you right now — please get in touch with us to arrange an appointment.",
STAFF_NOT_WORKING: "This barber isn't working at that time — please choose another slot or barber.", STAFF_NOT_WORKING: "This barber isn't working at that time — please choose another slot or barber.",
STAFF_ON_BREAK: "This barber is on a break at that time — please choose another slot.", STAFF_ON_BREAK: "This barber is on a break at that time — please choose another slot.",
STAFF_ON_LEAVE: "This barber is away on that date — please choose another barber or date.", STAFF_ON_LEAVE: "This barber is away on that date — please choose another barber or date.",
@@ -72,6 +72,10 @@ export async function listAvailableCalendarsAction(): Promise<ListCalendarsResul
if (!connection || connection.revokedAt) { if (!connection || connection.revokedAt) {
return { success: false, error: "Connect Google Calendar first." }; return { success: false, error: "Connect Google Calendar first." };
} }
// A connection made with the narrow scope can only reach the calendar this app created.
if (connection.appCreatedCalendar) {
return { success: false, error: "Events are added to the calendar NextChair created in your Google account." };
}
const provider = new GoogleCalendarProvider(); const provider = new GoogleCalendarProvider();
try { try {
@@ -22,6 +22,11 @@ export interface ConnectionState {
* button, because syncing has silently stopped and nothing else says so. * button, because syncing has silently stopped and nothing else says so.
*/ */
needsReconnect?: boolean; needsReconnect?: boolean;
/**
* Connected with the narrow scope: events go to a calendar this app created, and there is
* nothing to choose (the scope can't list the person's calendars).
*/
appCreatedCalendar?: boolean;
} }
export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) { export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) {
@@ -86,8 +91,9 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
</div> </div>
)} )}
<p className="text-muted mb-4 text-xs"> <p className="text-muted mb-4 text-xs">
Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately Sync your confirmed appointments to Google Calendar. We create a calendar for them in your Google account
from sign-in — you can use a different one if you like. and never see or change your other calendars or events. This connects a Google account separately from
sign-in — you can use a different one if you like.
</p> </p>
{message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>} {message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>}
{configured ? ( {configured ? (
@@ -108,7 +114,11 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
<p className="mb-1 text-sm font-bold">Google Calendar</p> <p className="mb-1 text-sm font-bold">Google Calendar</p>
<div className="text-muted mb-4 space-y-1 text-xs"> <div className="text-muted mb-4 space-y-1 text-xs">
<p>Connected as {state.accountEmail}</p> <p>Connected as {state.accountEmail}</p>
<p>Syncing to &ldquo;{state.selectedCalendarName}&rdquo;</p> <p>
{state.appCreatedCalendar ? "Appointments are added to the calendar " : "Syncing to "}&ldquo;
{state.selectedCalendarName}&rdquo;
{state.appCreatedCalendar && " in your Google account — show or hide it from Google Calendar's sidebar."}
</p>
{state.lastSyncAt && <p>Last synced {new Date(state.lastSyncAt).toLocaleString()}</p>} {state.lastSyncAt && <p>Last synced {new Date(state.lastSyncAt).toLocaleString()}</p>}
{state.lastSyncError && <p className="text-danger">Last sync error: {state.lastSyncError}</p>} {state.lastSyncError && <p className="text-danger">Last sync error: {state.lastSyncError}</p>}
</div> </div>
@@ -135,9 +145,11 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
<Button size="sm" isLoading={isPending} onClick={sync}> <Button size="sm" isLoading={isPending} onClick={sync}>
Sync Now Sync Now
</Button> </Button>
{!state.appCreatedCalendar && (
<Button size="sm" variant="secondary" isLoading={isPending} onClick={loadCalendars}> <Button size="sm" variant="secondary" isLoading={isPending} onClick={loadCalendars}>
Change Calendar Change Calendar
</Button> </Button>
)}
<Button size="sm" variant="destructive" isLoading={isPending} onClick={disconnect}> <Button size="sm" variant="destructive" isLoading={isPending} onClick={disconnect}>
Disconnect Disconnect
</Button> </Button>
@@ -170,6 +170,7 @@ export async function ownerRescheduleAppointmentAction(params: {
appointmentId: params.appointmentId, appointmentId: params.appointmentId,
staffId: params.staffId, staffId: params.staffId,
startAt: new Date(params.startAt), startAt: new Date(params.startAt),
actorUserId: owner.userId,
}); });
if (!result.success) { if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this appointment." }; return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this appointment." };
@@ -183,10 +184,14 @@ export async function ownerRescheduleAppointmentAction(params: {
entityId: params.appointmentId, entityId: params.appointmentId,
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId }, metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
}); });
// When the time moved too, the "rescheduled" notice already names the new barber — one
// message, not two. The dedicated notice is only for a pure reassignment.
if (appointment.startAt.getTime() === new Date(params.startAt).getTime()) {
await notifyBookingBarberChanged(prisma, result.appointment, { await notifyBookingBarberChanged(prisma, result.appointment, {
customerSelectedStaff: appointment.staffSelectedByCustomer, customerSelectedStaff: appointment.staffSelectedByCustomer,
}); });
} }
}
await syncAppointmentSafely(prisma, params.appointmentId); await syncAppointmentSafely(prisma, params.appointmentId);
return { success: true }; return { success: true };
+5
View File
@@ -13,6 +13,7 @@ import { parseSplashScreens } from "@/lib/business/branding";
import type { SplashScreenEntry } from "@/lib/business/branding"; import type { SplashScreenEntry } from "@/lib/business/branding";
import { MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage"; import { MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
import { createMediaAsset } from "@/services/media/media.service"; import { createMediaAsset } from "@/services/media/media.service";
import { MAX_WHATSAPP_LEAD_HOURS, validateWhatsAppLeadHours } from "@/services/scheduling/whatsapp-reminders.service";
import { import {
setOpeningHours, setOpeningHours,
updateAppIconSettings, updateAppIconSettings,
@@ -104,6 +105,10 @@ export async function updateBusinessSettingsAction(settings: BusinessSettingsInp
const { staff, error } = await requireBusinessManager(); const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." }; if (!staff) return { success: false, error: error ?? "Not authorized." };
if (settings.whatsappReminderLeadHours !== undefined && !validateWhatsAppLeadHours(settings.whatsappReminderLeadHours)) {
return { success: false, error: `WhatsApp reminders can be due between 1 and ${MAX_WHATSAPP_LEAD_HOURS} hours ahead.` };
}
await updateBusinessSettings(prisma, { businessId: staff.businessId, settings }); await updateBusinessSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, { await writeAuditLog(prisma, {
@@ -126,6 +126,33 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
</div> </div>
</section> </section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">No-shows</h3>
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<p className="text-muted text-xs leading-relaxed">
Count the appointments a customer missed since they last attended (a completed appointment, or
when you reset them). Leave a box empty to turn that part off. Staff can always book a blocked
customer in directly.
</p>
<Input
label="Show a warning symbol at (no-shows)"
hint="e.g. 2 — the symbol appears on their calendar entries, appointments and client page."
type="number"
min={1}
value={form.noShowFlagThreshold ?? ""}
onChange={(e) => set("noShowFlagThreshold", e.target.value ? Number(e.target.value) : null)}
/>
<Input
label="Block online booking at (no-shows)"
hint="e.g. 4 — they're asked to contact you instead. Must be the same as or higher than the warning number."
type="number"
min={1}
value={form.noShowBlockThreshold ?? ""}
onChange={(e) => set("noShowBlockThreshold", e.target.value ? Number(e.target.value) : null)}
/>
</div>
</section>
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3> <h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
<div className="rounded-md border-line space-y-5 border bg-white p-6"> <div className="rounded-md border-line space-y-5 border bg-white p-6">
@@ -39,6 +39,7 @@ export interface BusinessDetailsInitial {
cancellationPolicy: string; cancellationPolicy: string;
legalNameDisplay: LegalNameDisplay; legalNameDisplay: LegalNameDisplay;
legalContactEmail: LegalContactEmail; legalContactEmail: LegalContactEmail;
whatsappReminderLeadHours: number;
} }
export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) { export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) {
@@ -92,6 +93,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
cancellationPolicy: form.cancellationPolicy || undefined, cancellationPolicy: form.cancellationPolicy || undefined,
legalNameDisplay: form.legalNameDisplay, legalNameDisplay: form.legalNameDisplay,
legalContactEmail: form.legalContactEmail, legalContactEmail: form.legalContactEmail,
whatsappReminderLeadHours: form.whatsappReminderLeadHours,
}); });
if (!settingsResult.success) { if (!settingsResult.success) {
setError(settingsResult.error ?? "Couldn't save business policies."); setError(settingsResult.error ?? "Couldn't save business policies.");
@@ -213,6 +215,22 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
value={form.cancellationPolicy} value={form.cancellationPolicy}
onChange={(e) => set("cancellationPolicy", e.target.value)} onChange={(e) => set("cancellationPolicy", e.target.value)}
/> />
<div className="border-line space-y-3 border-t pt-4">
<Select
label="WhatsApp reminders are due"
value={String(form.whatsappReminderLeadHours)}
onChange={(e) => set("whatsappReminderLeadHours", Number(e.target.value))}
>
{[12, 24, 36, 48, 72].map((hours) => (
<option key={hours} value={hours}>
{hours} hours before an appointment
</option>
))}
</Select>
<p className="text-muted ml-1 text-xs leading-relaxed">
Staff see these on the Reminders list and get a push when some are ready to send.
</p>
</div>
<div className="border-line space-y-4 border-t pt-4"> <div className="border-line space-y-4 border-t pt-4">
<p className="text-muted ml-1 text-xs leading-relaxed"> <p className="text-muted ml-1 text-xs leading-relaxed">
Your site&apos;s{" "} Your site&apos;s{" "}
@@ -190,18 +190,6 @@ export function LoyaltyConfigForm({
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules &amp; Expiry</h3> <h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules &amp; Expiry</h3>
<div className="rounded-md border-line space-y-5 border bg-white p-6"> <div className="rounded-md border-line space-y-5 border bg-white p-6">
<Switch
label="Apply reward automatically"
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
checked={config.autoApplyReward}
onCheckedChange={(v) => set("autoApplyReward", v)}
/>
<Switch
label="Require staff approval to redeem"
description="Redemption already requires a permitted staff member — this is informational only this phase."
checked={config.requiresStaffApproval}
onCheckedChange={(v) => set("requiresStaffApproval", v)}
/>
<Switch label="Earn stamps on cancelled appointments" checked={config.earnOnCancelled} onCheckedChange={(v) => set("earnOnCancelled", v)} /> <Switch label="Earn stamps on cancelled appointments" checked={config.earnOnCancelled} onCheckedChange={(v) => set("earnOnCancelled", v)} />
<Switch label="Earn stamps on no-shows" checked={config.earnOnNoShow} onCheckedChange={(v) => set("earnOnNoShow", v)} /> <Switch label="Earn stamps on no-shows" checked={config.earnOnNoShow} onCheckedChange={(v) => set("earnOnNoShow", v)} />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@@ -155,13 +155,14 @@ export async function rescheduleStaffAppointmentAction(params: {
staffId?: string; staffId?: string;
startAt: string; startAt: string;
}): Promise<StaffAppointmentActionResult> { }): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnAppointment(params.appointmentId); const { staff, appointment, error } = await loadOwnAppointment(params.appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." }; if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
const result = await rescheduleBooking(prisma, { const result = await rescheduleBooking(prisma, {
appointmentId: params.appointmentId, appointmentId: params.appointmentId,
staffId: params.staffId, staffId: params.staffId,
startAt: new Date(params.startAt), startAt: new Date(params.startAt),
actorUserId: staff.user.id,
}); });
if (!result.success) { if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment." }; return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment." };
+45
View File
@@ -0,0 +1,45 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { hasPermission } from "@/lib/auth/permissions";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { clientDirectoryWhere } from "@/services/customer/client-directory";
import { resetNoShows } from "@/services/customer/no-show.service";
export interface NoShowActionResult {
success: boolean;
error?: string;
}
/**
* "Forgive" a customer's no-shows: they stop counting towards the flag and block
* thresholds from now on (history is untouched). Needs MANAGE_ALL_APPOINTMENTS —
* it lifts a block the owner configured, so it isn't open to every barber.
*/
export async function resetNoShowsAction(customerId: string): Promise<NoShowActionResult> {
const context = await getActingBusinessContext();
if (!context) return { success: false, error: "You must be signed in as staff." };
if (!(await hasPermission(context.userId, Permission.MANAGE_ALL_APPOINTMENTS))) {
return { success: false, error: "You don't have permission to reset no-shows." };
}
const inDirectory =
(await prisma.customerProfile.count({
where: { AND: [{ id: customerId }, clientDirectoryWhere(context.businessId)] },
})) > 0;
if (!inDirectory) return { success: false, error: "Client not found." };
await resetNoShows(prisma, { customerId });
await writeAuditLog(prisma, {
actorUserId: context.userId,
action: "CUSTOMER_NO_SHOWS_RESET",
entityType: "CustomerProfile",
entityId: customerId,
});
revalidatePath(`/staff/clients/${customerId}`);
revalidatePath("/staff/clients");
return { success: true };
}
@@ -41,6 +41,8 @@ export interface CalendarAppointment {
status: AppointmentStatus; status: AppointmentStatus;
hasNotes: boolean; hasNotes: boolean;
hasAvailableReward: boolean; hasAvailableReward: boolean;
/** Customer has reached the no-show flag threshold (see no-show.service.ts). */
noShowFlagged?: boolean;
} }
export interface CalendarBlock { export interface CalendarBlock {
@@ -413,6 +415,9 @@ function EventContent({ event }: { event: CalendarEvent }) {
{appointment.hasAvailableReward && ( {appointment.hasAvailableReward && (
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" /> <Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
)} )}
{appointment.noShowFlagged && (
<UserX className="text-warning-foreground h-2.5 w-2.5 shrink-0" aria-label="Has missed appointments" />
)}
</div> </div>
{height >= 44 && ( {height >= 44 && (
<p className="truncate text-[11px] opacity-80"> <p className="truncate text-[11px] opacity-80">
@@ -0,0 +1,38 @@
import { UserX } from "lucide-react";
import { cn } from "@/lib/utils/cn";
/**
* The symbol shown against a customer who has reached the no-show flag (amber) or
* block (red) threshold — see no-show.service.ts. Renders nothing for a customer
* who is neither, so callers can drop it in unconditionally.
*/
export function NoShowBadge({
count,
flagged,
blocked,
className,
}: {
count: number;
flagged: boolean;
blocked: boolean;
className?: string;
}) {
if (!flagged && !blocked) return null;
const label = blocked
? `Online booking blocked after ${count} no-show${count === 1 ? "" : "s"}`
: `${count} no-show${count === 1 ? "" : "s"} since their last visit`;
return (
<span
title={label}
aria-label={label}
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wide whitespace-nowrap",
blocked ? "bg-danger/10 text-danger" : "bg-warning/15 text-warning-foreground",
className,
)}
>
<UserX className="h-3 w-3" aria-hidden="true" />
{blocked ? `Blocked · ${count}` : count}
</span>
);
}
@@ -3,36 +3,28 @@ import Link from "next/link";
import { getBusinessCountry } from "@/lib/business/get-business-country"; import { getBusinessCountry } from "@/lib/business/get-business-country";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { toWhatsAppNumber } from "@/lib/messaging/whatsapp"; import { toWhatsAppNumber } from "@/lib/messaging/whatsapp";
import { addDays, todayIn } from "@/lib/time/business-time"; import { getWhatsAppLeadHours, listDueReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service";
import { listReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service";
/** /**
* Entry point to the reminders list, shown on the calendar pages with the * Entry point to the reminders list, shown on the calendar pages with the
* number still to send for tomorrow — so the job announces itself instead of * number due to send right now (starting within the business's reminder
* relying on someone remembering to look. * lead time) — so the job announces itself instead of relying on someone
* remembering to look.
*/ */
export async function RemindersLink({ export async function RemindersLink({
businessId, businessId,
timezone,
staffId, staffId,
}: { }: {
businessId: string; businessId: string;
timezone: string;
/** Narrow to one barber's bookings; omit for the whole shop. */ /** Narrow to one barber's bookings; omit for the whole shop. */
staffId?: string; staffId?: string;
}) { }) {
const leadHours = await getWhatsAppLeadHours(prisma, businessId);
const [appointments, country] = await Promise.all([ const [appointments, country] = await Promise.all([
listReminderCandidates(prisma, { listDueReminderCandidates(prisma, { businessId, leadHours, staffId }),
businessId,
date: addDays(todayIn(timezone), 1),
businessTimezone: timezone,
staffId,
}),
getBusinessCountry(businessId), getBusinessCountry(businessId),
]); ]);
const toSend = appointments.filter( const toSend = appointments.filter((a) => toWhatsAppNumber(a.customer.phone, country)).length;
(a) => !a.whatsappReminderSentAt && toWhatsAppNumber(a.customer.phone, country),
).length;
return ( return (
<Link <Link
@@ -42,7 +34,7 @@ export async function RemindersLink({
<MessageCircle className="text-accent h-4 w-4 shrink-0" aria-hidden="true" /> <MessageCircle className="text-accent h-4 w-4 shrink-0" aria-hidden="true" />
<span className="flex-1 text-xs font-bold"> <span className="flex-1 text-xs font-bold">
WhatsApp reminders WhatsApp reminders
{toSend > 0 ? <span className="text-accent-foreground"> · {toSend} to send tomorrow</span> : null} {toSend > 0 ? <span className="text-accent-foreground"> · {toSend} to send now</span> : null}
</span> </span>
<ChevronRight className="text-muted h-4 w-4 shrink-0" aria-hidden="true" /> <ChevronRight className="text-muted h-4 w-4 shrink-0" aria-hidden="true" />
</Link> </Link>
@@ -0,0 +1,60 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { resetNoShowsAction } from "../actions/no-show";
/** Two taps on purpose: it lifts a block the owner set, so it shouldn't happen by accident. */
export function ResetNoShowsButton({ customerId }: { customerId: string }) {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const reset = () => {
setError(null);
startTransition(async () => {
const result = await resetNoShowsAction(customerId);
if (!result.success) {
setError(result.error ?? "Couldn't reset no-shows.");
return;
}
setConfirming(false);
router.refresh();
});
};
if (!confirming) {
return (
<Button variant="secondary" size="sm" onClick={() => setConfirming(true)}>
Reset no-shows
</Button>
);
}
return (
<div className="space-y-2">
<p className="text-muted text-xs">
Their no-shows stop counting from now. Their history is kept.
</p>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setConfirming(false)}
disabled={isPending}
>
Cancel
</Button>
<Button size="sm" onClick={reset} isLoading={isPending}>
Yes, reset
</Button>
</div>
{error && (
<p role="alert" className="text-danger text-xs">
{error}
</p>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Instrumentation } from "next";
/**
* Server-side error reporting: every unhandled error in a route, page or
* server action reaches this hook with the digest the visitor sees on the
* error page. See src/lib/monitoring/report-error.ts for what is logged and
* emailed to the operator (OPERATOR_ALERT_EMAIL) and what never is.
*/
export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
try {
const { reportServerError } = await import("@/lib/monitoring/report-error");
await reportServerError(
error,
{ path: request.path, method: request.method },
{ routePath: context.routePath },
);
} catch {
// Reporting must never be the thing that breaks a request.
}
};
+3 -2
View File
@@ -2,6 +2,7 @@ import { randomInt } from "node:crypto";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { checkRateLimit } from "@/lib/security/rate-limit"; import { checkRateLimit } from "@/lib/security/rate-limit";
import { resolveSenderName } from "@/services/notifications/sender-identity";
// Type-only — the barrel this comes from has a `server-only` guard that // Type-only — the barrel this comes from has a `server-only` guard that
// throws under Vitest's plain Node runtime (see reminder-worker.ts's own // throws under Vitest's plain Node runtime (see reminder-worker.ts's own
// established pattern: never call getEmailProvider() from inside a // established pattern: never call getEmailProvider() from inside a
@@ -152,12 +153,12 @@ export async function requestOtp(db: Db, options: RequestOtpOptions): Promise<Re
}); });
} }
const rendered = renderOtpEmail({ senderName: options.senderName ?? "NextChair", code, expiresInMinutes: 10 }); const rendered = renderOtpEmail({ senderName: resolveSenderName(options.senderName), code, expiresInMinutes: 10 });
// Never logged — the code must not reach application logs, only this // Never logged — the code must not reach application logs, only this
// one email (see the brief's "do not include the OTP in logs"). // one email (see the brief's "do not include the OTP in logs").
await options.emailProvider.sendEmail({ await options.emailProvider.sendEmail({
to: { email }, to: { email },
sender: { name: options.senderName ?? "NextChair" }, sender: { name: resolveSenderName(options.senderName) },
subject: rendered.subject, subject: rendered.subject,
html: rendered.html, html: rendered.html,
text: rendered.text, text: rendered.text,
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { createAlertLimiter } from "../alert-limiter";
describe("createAlertLimiter", () => {
const hour = 3_600_000;
it("sends the first alert and holds the same one back for the cooldown", () => {
const limiter = createAlertLimiter({ cooldownMs: hour, maxPerDay: 20 });
expect(limiter.shouldSend("boom", 0)).toBe(true);
expect(limiter.shouldSend("boom", 30 * 60_000)).toBe(false);
expect(limiter.shouldSend("boom", hour)).toBe(true);
});
it("treats different errors independently", () => {
const limiter = createAlertLimiter({ cooldownMs: hour, maxPerDay: 20 });
expect(limiter.shouldSend("a", 0)).toBe(true);
expect(limiter.shouldSend("b", 1)).toBe(true);
});
it("caps how many alerts one day can produce, then starts over the next day", () => {
const limiter = createAlertLimiter({ cooldownMs: 0, maxPerDay: 3 });
expect([1, 2, 3, 4, 5].map((n) => limiter.shouldSend(`e${n}`, n))).toEqual([
true,
true,
true,
false,
false,
]);
expect(limiter.shouldSend("e6", 25 * hour)).toBe(true);
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@/services/notifications/providers", () => ({ getEmailProvider: vi.fn() }));
import { buildErrorAlert, isIgnorableError } from "../report-error";
describe("isIgnorableError", () => {
it("ignores redirects, not-found and dropped connections, but not real failures", () => {
expect(isIgnorableError(new Error("NEXT_REDIRECT"))).toBe(true);
expect(isIgnorableError(new Error("NEXT_NOT_FOUND"))).toBe(true);
expect(isIgnorableError(new Error("socket hang up"))).toBe(true);
expect(isIgnorableError(new Error("Cannot read properties of undefined"))).toBe(false);
});
});
describe("buildErrorAlert", () => {
const request = { path: "/owner/calendar?token=secret-value&date=2026-10-20", method: "GET" };
it("names the business and route and never includes the query string", () => {
const alert = buildErrorAlert({
business: "Blake Heritage",
error: new Error("db exploded"),
request,
});
expect(alert.subject).toBe("[Blake Heritage] Server error on /owner/calendar");
expect(alert.text).toContain("db exploded");
expect(alert.text).not.toContain("secret-value");
});
it("dedupes on the digest when there is one, otherwise on route and message", () => {
const withDigest = Object.assign(new Error("x"), { digest: "abc123" });
expect(buildErrorAlert({ business: "B", error: withDigest, request }).key).toBe("abc123");
expect(buildErrorAlert({ business: "B", error: new Error("x"), request }).key).toBe(
"/owner/calendar:x",
);
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Keeps operator alert emails useful: the same problem is reported once per
* cooldown (not once per request while it's broken), and a runaway failure
* can't send more than a fixed number of emails a day. In-memory and
* per-process, like the app's other rate limiter — a restart resets it, which
* at worst re-sends one alert.
*/
export interface AlertLimiterOptions {
cooldownMs: number;
maxPerDay: number;
}
export function createAlertLimiter(options: AlertLimiterOptions) {
const lastSent = new Map<string, number>();
let dayStart = 0;
let sentToday = 0;
return {
/** True when an alert with this key should go out now — and records it as sent. */
shouldSend(key: string, now: number = Date.now()): boolean {
if (now - dayStart >= 24 * 3_600_000) {
dayStart = now;
sentToday = 0;
}
if (sentToday >= options.maxPerDay) return false;
const previous = lastSent.get(key);
if (previous !== undefined && now - previous < options.cooldownMs) return false;
lastSent.set(key, now);
sentToday += 1;
// Bound memory: drop entries that have long since stopped mattering.
if (lastSent.size > 500) {
for (const [k, t] of lastSent) if (now - t >= options.cooldownMs) lastSent.delete(k);
}
return true;
},
};
}
+90
View File
@@ -0,0 +1,90 @@
import "server-only";
import { createAlertLimiter } from "./alert-limiter";
import { getEmailProvider } from "@/services/notifications/providers";
/**
* Server-side error reporting for Next's `onRequestError` hook (see
* src/instrumentation.ts). Every error is logged with its digest — the id
* the browser's error page can show — and, when OPERATOR_ALERT_EMAIL is set
* for this deployment, emailed to the operator, de-duplicated so one broken
* page produces one email an hour, not one per visitor.
*
* The email carries only: which business, the route, the method, the error
* message and the first lines of its stack. Never request headers, cookies,
* bodies or the query string, which can hold tokens and personal data.
*/
const limiter = createAlertLimiter({ cooldownMs: 60 * 60_000, maxPerDay: 20 });
export interface ReportedRequest {
path: string;
method: string;
}
/** Errors that are control flow or a visitor going away, not something to wake anyone for. */
export function isIgnorableError(error: unknown): boolean {
const text = error instanceof Error ? `${error.name} ${error.message}` : String(error);
return /NEXT_REDIRECT|NEXT_NOT_FOUND|NEXT_HTTP_ERROR_FALLBACK|aborted|ECONNRESET|socket hang up/i.test(
text,
);
}
export function buildErrorAlert(params: {
business: string;
error: unknown;
request: ReportedRequest;
routePath?: string;
}): { subject: string; text: string; key: string } {
const error = params.error instanceof Error ? params.error : new Error(String(params.error));
const digestValue = (error as { digest?: unknown }).digest;
const digest = typeof digestValue === "string" ? digestValue : undefined;
const route = params.routePath ?? params.request.path.split("?")[0] ?? "";
const stack = (error.stack ?? "").split("\n").slice(0, 12).join("\n");
return {
subject: `[${params.business}] Server error on ${route}`,
key: digest ?? `${route}:${error.message}`.slice(0, 200),
text: [
`A server error happened on ${params.business}.`,
"",
`Route: ${route}`,
`Method: ${params.request.method}`,
`Error: ${error.message}`,
...(digest ? [`Digest: ${digest} (also shown to the visitor on the error page)`] : []),
"",
"Stack (first lines):",
stack,
"",
"You'll get at most one email per hour for the same error.",
].join("\n"),
};
}
export async function reportServerError(
error: unknown,
request: ReportedRequest,
context: { routePath?: string },
): Promise<void> {
if (isIgnorableError(error)) return;
const business = process.env.BUSINESS_NAME?.trim() || process.env.DOMAIN?.trim() || "NextChair";
const alert = buildErrorAlert({ business, error, request, routePath: context.routePath });
console.error(
`[server-error] ${alert.subject} — ${error instanceof Error ? error.message : String(error)}`,
);
const to = process.env.OPERATOR_ALERT_EMAIL?.trim();
if (!to || !limiter.shouldSend(alert.key)) return;
try {
await getEmailProvider().sendEmail({
to: { email: to },
sender: { name: `${business} alerts` },
subject: alert.subject,
html: `<pre style="font:13px/1.5 monospace;white-space:pre-wrap;">${alert.text.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]!)}</pre>`,
text: alert.text,
});
} catch {
// An alert that can't be sent must never turn into a second error.
}
}
@@ -70,6 +70,8 @@ export interface BusinessSettingsInput {
legalNameDisplay?: LegalNameDisplay; legalNameDisplay?: LegalNameDisplay;
/** Whose inbox those pages give for legal/privacy requests. */ /** Whose inbox those pages give for legal/privacy requests. */
legalContactEmail?: LegalContactEmail; legalContactEmail?: LegalContactEmail;
/** Hours ahead the staff WhatsApp reminders queue treats an appointment as due — see whatsapp-reminders.service.ts. */
whatsappReminderLeadHours?: number;
/** Reviews visibility policy — see src/services/reviews. Applied on top of each Review's own isHidden flag. */ /** Reviews visibility policy — see src/services/reviews. Applied on top of each Review's own isHidden flag. */
reviewsMinRating?: number | null; reviewsMinRating?: number | null;
reviewsExcludeRatingOnly?: boolean; reviewsExcludeRatingOnly?: boolean;
@@ -5,7 +5,10 @@ import { CalendarProviderError } from "../types";
describe("mapGoogleError", () => { describe("mapGoogleError", () => {
it("treats the token endpoint's invalid_grant as a dead connection, not a bad event", () => { it("treats the token endpoint's invalid_grant as a dead connection, not a bad event", () => {
// Google sends HTTP 400 for this — the case that used to read "rejected this event". // Google sends HTTP 400 for this — the case that used to read "rejected this event".
const error = mapGoogleError(400, { error: "invalid_grant", error_description: "Token has been expired or revoked." }); const error = mapGoogleError(400, {
error: "invalid_grant",
error_description: "Token has been expired or revoked.",
});
expect(error.code).toBe("TOKEN_REVOKED"); expect(error.code).toBe("TOKEN_REVOKED");
expect(error.retryable).toBe(false); expect(error.retryable).toBe(false);
@@ -25,7 +28,12 @@ describe("mapGoogleError", () => {
}); });
it("still reports a genuinely bad event as one, now with Google's reason", () => { it("still reports a genuinely bad event as one, now with Google's reason", () => {
const error = mapGoogleError(400, { error: { message: "The specified time range is empty.", errors: [{ reason: "timeRangeEmpty" }] } }); const error = mapGoogleError(400, {
error: {
message: "The specified time range is empty.",
errors: [{ reason: "timeRangeEmpty" }],
},
});
expect(error.code).toBe("INVALID_EVENT"); expect(error.code).toBe("INVALID_EVENT");
expect(error.retryable).toBe(false); expect(error.retryable).toBe(false);
@@ -38,12 +46,21 @@ describe("mapGoogleError", () => {
}); });
it("keeps the other mappings", () => { it("keeps the other mappings", () => {
expect(mapGoogleError(401, { error: { errors: [{ reason: "authError" }] } }).code).toBe("TOKEN_EXPIRED"); expect(mapGoogleError(401, { error: { errors: [{ reason: "authError" }] } }).code).toBe(
expect(mapGoogleError(403, { error: { errors: [{ reason: "insufficientPermissions" }] } }).code).toBe("CALENDAR_NOT_WRITABLE"); "TOKEN_EXPIRED",
expect(mapGoogleError(403, { error: { errors: [{ reason: "rateLimitExceeded" }] } }).code).toBe("RATE_LIMITED"); );
expect(
mapGoogleError(403, { error: { errors: [{ reason: "insufficientPermissions" }] } }).code,
).toBe("CALENDAR_NOT_WRITABLE");
expect(mapGoogleError(403, { error: { errors: [{ reason: "rateLimitExceeded" }] } }).code).toBe(
"RATE_LIMITED",
);
expect(mapGoogleError(429, undefined).retryable).toBe(true); expect(mapGoogleError(429, undefined).retryable).toBe(true);
expect(mapGoogleError(404, undefined).code).toBe("CALENDAR_NOT_FOUND"); expect(mapGoogleError(404, undefined).code).toBe("CALENDAR_NOT_FOUND");
expect(mapGoogleError(503, undefined)).toMatchObject({ code: "PROVIDER_OUTAGE", retryable: true }); expect(mapGoogleError(503, undefined)).toMatchObject({
code: "PROVIDER_OUTAGE",
retryable: true,
});
expect(mapGoogleError(418, undefined).code).toBe("UNKNOWN"); expect(mapGoogleError(418, undefined).code).toBe("UNKNOWN");
}); });
@@ -52,13 +69,19 @@ describe("mapGoogleError", () => {
}); });
it("never puts the response body or any token in the user-facing message", () => { it("never puts the response body or any token in the user-facing message", () => {
const error = mapGoogleError(400, { error: "invalid_grant", error_description: "secret-detail-xyz" }); const error = mapGoogleError(400, {
error: "invalid_grant",
error_description: "secret-detail-xyz",
});
expect(error.message).not.toContain("secret-detail-xyz"); expect(error.message).not.toContain("secret-detail-xyz");
}); });
}); });
describe("GoogleCalendarProvider against a failing Google", () => { describe("GoogleCalendarProvider against a failing Google", () => {
const original = { id: process.env.GOOGLE_CALENDAR_CLIENT_ID, secret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET }; const original = {
id: process.env.GOOGLE_CALENDAR_CLIENT_ID,
secret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET,
};
beforeEach(() => { beforeEach(() => {
process.env.GOOGLE_CALENDAR_CLIENT_ID = "test-client-id"; process.env.GOOGLE_CALENDAR_CLIENT_ID = "test-client-id";
@@ -72,22 +95,126 @@ describe("GoogleCalendarProvider against a failing Google", () => {
}); });
const respondWith = (status: number, body: unknown) => const respondWith = (status: number, body: unknown) =>
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status }))); vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })),
);
it("an expired refresh token surfaces as TOKEN_REVOKED when refreshing", async () => { it("an expired refresh token surfaces as TOKEN_REVOKED when refreshing", async () => {
respondWith(400, { error: "invalid_grant", error_description: "Token has been expired or revoked." }); respondWith(400, {
error: "invalid_grant",
error_description: "Token has been expired or revoked.",
});
const failure = await new GoogleCalendarProvider().refreshAccessToken("old-refresh-token").catch((e: unknown) => e); const failure = await new GoogleCalendarProvider()
.refreshAccessToken("old-refresh-token")
.catch((e: unknown) => e);
expect(failure).toBeInstanceOf(CalendarProviderError); expect(failure).toBeInstanceOf(CalendarProviderError);
expect(failure).toMatchObject({ code: "TOKEN_REVOKED", retryable: false }); expect(failure).toMatchObject({ code: "TOKEN_REVOKED", retryable: false });
}); });
it("deleting an event Google already removed (410) is not an error", async () => { it("deleting an event Google already removed (410) is not an error", async () => {
respondWith(410, { error: { message: "Resource has been deleted", errors: [{ reason: "deleted" }] } }); respondWith(410, {
error: { message: "Resource has been deleted", errors: [{ reason: "deleted" }] },
});
await expect( await expect(
new GoogleCalendarProvider().deleteEvent({ accessToken: "t", calendarId: "primary", providerEventId: "gone" }), new GoogleCalendarProvider().deleteEvent({
accessToken: "t",
calendarId: "primary",
providerEventId: "gone",
}),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
}); });
describe("GoogleCalendarProvider scopes and the app-created calendar", () => {
const original = {
id: process.env.GOOGLE_CALENDAR_CLIENT_ID,
secret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET,
};
beforeEach(() => {
process.env.GOOGLE_CALENDAR_CLIENT_ID = "test-client-id";
process.env.GOOGLE_CALENDAR_CLIENT_SECRET = "test-client-secret";
});
afterEach(() => {
vi.unstubAllGlobals();
process.env.GOOGLE_CALENDAR_CLIENT_ID = original.id;
process.env.GOOGLE_CALENDAR_CLIENT_SECRET = original.secret;
});
it("asks Google for the one narrow calendar scope — never the broad ones that trigger the unverified-app warning", () => {
const url = new URL(
new GoogleCalendarProvider().buildAuthorizationUrl({
redirectUri: "https://x.test/cb",
state: "s",
}),
);
const scopes = (url.searchParams.get("scope") ?? "").split(" ");
expect(scopes).toContain("https://www.googleapis.com/auth/calendar.app.created");
expect(scopes.filter((s) => s.includes("/auth/calendar"))).toEqual([
"https://www.googleapis.com/auth/calendar.app.created",
]);
expect(scopes).not.toContain("https://www.googleapis.com/auth/calendar.events");
expect(scopes).not.toContain("https://www.googleapis.com/auth/calendar.readonly");
});
it("creates a secondary calendar with the business's name and time zone", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ id: "cal-123", summary: "Blake Heritage bookings" }), {
status: 200,
}),
);
vi.stubGlobal("fetch", fetchMock);
const calendar = await new GoogleCalendarProvider().createCalendar({
accessToken: "token",
name: "Blake Heritage bookings",
timeZone: "Europe/London",
});
expect(calendar).toEqual({
id: "cal-123",
name: "Blake Heritage bookings",
writable: true,
primary: false,
});
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://www.googleapis.com/calendar/v3/calendars");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
summary: "Blake Heritage bookings",
timeZone: "Europe/London",
});
});
it("calendarExists says yes for a calendar that's there, no for one that was deleted, and rethrows anything else", async () => {
const provider = new GoogleCalendarProvider();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 200 })));
expect(await provider.calendarExists({ accessToken: "t", calendarId: "cal-123" })).toBe(true);
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ error: { errors: [{ reason: "notFound" }] } }), {
status: 404,
}),
),
);
expect(await provider.calendarExists({ accessToken: "t", calendarId: "cal-123" })).toBe(false);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 503 })));
await expect(
provider.calendarExists({ accessToken: "t", calendarId: "cal-123" }),
).rejects.toMatchObject({ code: "PROVIDER_OUTAGE" });
});
});
@@ -21,6 +21,12 @@ export interface CalendarProvider {
/** Only ever returns calendars the account can write to — see RemoteCalendarInfo.writable. */ /** Only ever returns calendars the account can write to — see RemoteCalendarInfo.writable. */
listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]>; listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]>;
/** Creates a secondary calendar owned by this app in the person's account. */
createCalendar(params: { accessToken: string; name: string; timeZone: string }): Promise<RemoteCalendarInfo>;
/** True while a calendar this app created earlier still exists. */
calendarExists(params: { accessToken: string; calendarId: string }): Promise<boolean>;
createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }>; createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }>;
updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void>; updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void>;
@@ -8,17 +8,22 @@ import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteC
* existing pattern of building directly against a documented REST * existing pattern of building directly against a documented REST
* contract rather than adding a heavy client library (see * contract rather than adding a heavy client library (see
* brevo-email-provider.ts's own header for the same reasoning applied to * brevo-email-provider.ts's own header for the same reasoning applied to
* email). Minimum scopes: `calendar.events` (create/update/delete events) * email). One calendar scope only: `calendar.app.created` — create a
* and `calendar.readonly` (list calendars to choose a destination) — * secondary calendar and read/write events on calendars THIS APP created,
* deliberately not the full `calendar` scope, which also grants calendar * and nothing else. It cannot see or touch a person's existing calendars or
* *settings* management this app never needs. * events, which is what keeps the consent screen narrow (the broader
* `calendar.events` / `calendar.readonly` pair is what made Google show its
* "hasn't verified this app" warning). The trade-off is by design: events go
* to a dedicated "<Business> bookings" calendar this app creates in the
* person's Google account, not into an existing one — see createCalendar.
* *
* Unverified against a live Google account in this environment — no * Unverified against a live Google account in this environment — no
* OAuth credentials were available to test against. See * OAuth credentials were available to test against. See
* docs/phase-8-summary.md, "Unverified external-provider behaviour." * docs/phase-8-summary.md, "Unverified external-provider behaviour."
*/ */
const SCOPES = ["https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly", "email"].join(" "); export const CALENDAR_SCOPES = ["https://www.googleapis.com/auth/calendar.app.created", "email"];
const SCOPES = CALENDAR_SCOPES.join(" ");
export interface GoogleErrorBody { export interface GoogleErrorBody {
error?: string | { message?: string; errors?: { reason?: string }[] }; error?: string | { message?: string; errors?: { reason?: string }[] };
@@ -216,6 +221,30 @@ export class GoogleCalendarProvider implements CalendarProvider {
.map((item) => ({ id: item.id, name: item.summary, writable: true, primary: !!item.primary })); .map((item) => ({ id: item.id, name: item.summary, writable: true, primary: !!item.primary }));
} }
/** Creates the secondary calendar this app writes events to. Needs only `calendar.app.created`. */
async createCalendar(params: { accessToken: string; name: string; timeZone: string }): Promise<RemoteCalendarInfo> {
const response = await googleFetch("https://www.googleapis.com/calendar/v3/calendars", {
method: "POST",
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ summary: params.name, timeZone: params.timeZone }),
});
const created: { id: string; summary?: string } = await response.json();
return { id: created.id, name: created.summary ?? params.name, writable: true, primary: false };
}
/** Whether a calendar this app created earlier still exists — a reconnect reuses it instead of creating a second one. */
async calendarExists(params: { accessToken: string; calendarId: string }): Promise<boolean> {
try {
await googleFetch(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}`, {
headers: { Authorization: `Bearer ${params.accessToken}` },
});
return true;
} catch (error) {
if (error instanceof CalendarProviderError && error.code === "CALENDAR_NOT_FOUND") return false;
throw error;
}
}
async createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }> { async createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }> {
const response = await googleFetch( const response = await googleFetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events`, `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events`,
@@ -37,6 +37,16 @@ export class MockCalendarProvider implements CalendarProvider {
return this.calendars; return this.calendars;
} }
async createCalendar(params: { name: string }): Promise<RemoteCalendarInfo> {
const calendar = { id: `mock-calendar-${this.calendars.length + 1}`, name: params.name, writable: true, primary: false };
this.calendars.push(calendar);
return calendar;
}
async calendarExists(params: { calendarId: string }): Promise<boolean> {
return this.calendars.some((c) => c.id === params.calendarId);
}
async createEvent(params: { event: CalendarEventInput }): Promise<{ providerEventId: string }> { async createEvent(params: { event: CalendarEventInput }): Promise<{ providerEventId: string }> {
const id = `mock-event-${this.nextEventId++}`; const id = `mock-event-${this.nextEventId++}`;
this.events.set(id, params.event); this.events.set(id, params.event);
@@ -0,0 +1,234 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AppointmentStatus, Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { createBooking } from "@/services/scheduling/booking.service";
import {
cleanupTestBusiness,
createTestAppointment,
createTestBusiness,
createTestCustomer,
createTestGuestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { getNoShowStanding, getNoShowStandings, resetNoShows } from "../no-show.service";
function nextMonday(timezone: string): CalendarDate {
let date = addDays(todayIn(timezone), 7);
while (weekdayOf(date) !== Weekday.MONDAY) date = addDays(date, 1);
return date;
}
describe("no-show flag and block (integration)", () => {
let business: TestBusiness;
let staffId: string;
let staffUserId: string;
let monday: CalendarDate;
const guestProfileIds: string[] = [];
beforeEach(async () => {
business = await createTestBusiness();
({ staffId, userId: staffUserId } = await createTestStaff(business));
monday = nextMonday(business.timezone);
guestProfileIds.length = 0;
});
afterEach(async () => {
await cleanupTestBusiness(business);
await prisma.customerProfile
.deleteMany({ where: { id: { in: guestProfileIds } } })
.catch(() => {});
});
const setThresholds = (flag: number | null, block: number | null) =>
prisma.bookingRule.update({
where: { businessId: business.businessId },
data: { noShowFlagThreshold: flag, noShowBlockThreshold: block },
});
/** Past appointments with a given outcome, oldest first, one per day. */
async function addHistory(customerId: string, statuses: AppointmentStatus[]) {
for (const [i, status] of statuses.entries()) {
const { appointmentId } = await createTestAppointment(business, {
customerId,
staffId,
startAt: new Date(Date.now() - (statuses.length - i + 1) * 24 * 60 * 60_000),
});
await prisma.appointment.update({ where: { id: appointmentId }, data: { status } });
}
}
const bookOnline = (customerId: string, hour = 10) =>
createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, hour * 60, business.timezone),
});
it("does nothing while the feature is off, however many no-shows", async () => {
const { customerId } = await createTestCustomer(business);
await addHistory(customerId, ["NO_SHOW", "NO_SHOW", "NO_SHOW"]);
expect(
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).blocked,
).toBe(false);
expect((await bookOnline(customerId)).success).toBe(true);
});
it("flags at the flag number and blocks online booking at the block number", async () => {
await setThresholds(2, 3);
const { customerId } = await createTestCustomer(business);
await addHistory(customerId, ["NO_SHOW"]);
expect(
await getNoShowStanding(prisma, { businessId: business.businessId, customerId }),
).toMatchObject({
count: 1,
flagged: false,
blocked: false,
});
await addHistory(customerId, ["NO_SHOW"]);
expect(
await getNoShowStanding(prisma, { businessId: business.businessId, customerId }),
).toMatchObject({
count: 2,
flagged: true,
blocked: false,
});
expect((await bookOnline(customerId)).success).toBe(true);
await addHistory(customerId, ["NO_SHOW"]);
const blocked = await bookOnline(customerId, 14);
expect(blocked.success).toBe(false);
if (!blocked.success)
expect(blocked.validation.issues.map((i) => i.code)).toContain("CUSTOMER_BOOKING_BLOCKED");
});
it("lets staff and owners still book a blocked customer in", async () => {
await setThresholds(null, 1);
const { customerId } = await createTestCustomer(business);
await addHistory(customerId, ["NO_SHOW"]);
expect((await bookOnline(customerId)).success).toBe(false);
const byStaff = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
createdByUserId: staffUserId,
});
expect(byStaff.success).toBe(true);
});
it("resets when they attend: a completed appointment after the no-shows clears the count", async () => {
await setThresholds(1, 2);
const { customerId } = await createTestCustomer(business);
await addHistory(customerId, ["NO_SHOW", "NO_SHOW"]);
expect(
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).blocked,
).toBe(true);
await addHistory(customerId, ["COMPLETED"]);
expect(
await getNoShowStanding(prisma, { businessId: business.businessId, customerId }),
).toMatchObject({
count: 0,
flagged: false,
blocked: false,
});
expect((await bookOnline(customerId)).success).toBe(true);
});
it("an owner reset forgives past no-shows but not later ones", async () => {
await setThresholds(null, 2);
const { customerId } = await createTestCustomer(business);
await addHistory(customerId, ["NO_SHOW", "NO_SHOW"]);
expect(
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).blocked,
).toBe(true);
await resetNoShows(prisma, { customerId, now: new Date(Date.now() - 60_000) });
expect(
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).count,
).toBe(0);
// A no-show that happened after the reset counts again.
const { appointmentId } = await createTestAppointment(business, {
customerId,
staffId,
startAt: new Date(Date.now() - 30_000),
});
await prisma.appointment.update({ where: { id: appointmentId }, data: { status: "NO_SHOW" } });
expect(
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).count,
).toBe(1);
});
it("a blocked guest can't dodge the block with a fresh profile that reuses their email or phone", async () => {
await setThresholds(null, 2);
const original = await createTestGuestCustomer();
guestProfileIds.push(original.customerId);
await addHistory(original.customerId, ["NO_SHOW", "NO_SHOW"]);
const sameEmail = await createTestGuestCustomer({ email: original.email.toUpperCase() });
const samePhone = await createTestGuestCustomer({ phone: original.phone });
guestProfileIds.push(sameEmail.customerId, samePhone.customerId);
const stranger = await createTestGuestCustomer();
guestProfileIds.push(stranger.customerId);
expect(
(
await getNoShowStanding(prisma, {
businessId: business.businessId,
customerId: sameEmail.customerId,
})
).blocked,
).toBe(true);
expect(
(
await getNoShowStanding(prisma, {
businessId: business.businessId,
customerId: samePhone.customerId,
})
).blocked,
).toBe(true);
expect(
(
await getNoShowStanding(prisma, {
businessId: business.businessId,
email: original.email,
phone: null,
})
).blocked,
).toBe(true);
expect(
(
await getNoShowStanding(prisma, {
businessId: business.businessId,
customerId: stranger.customerId,
})
).blocked,
).toBe(false);
});
it("standings for a whole day's customers only include people who have missed appointments", async () => {
await setThresholds(1, null);
const a = await createTestCustomer(business);
const b = await createTestCustomer(business);
await addHistory(a.customerId, ["NO_SHOW"]);
await addHistory(b.customerId, ["COMPLETED"]);
const standings = await getNoShowStandings(prisma, {
businessId: business.businessId,
customerIds: [a.customerId, b.customerId],
});
expect([...standings.keys()]).toEqual([a.customerId]);
expect(standings.get(a.customerId)).toMatchObject({ flagged: true, blocked: false });
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { countNoShows, evaluateStanding, normalizeEmail } from "../no-show.service";
const at = (iso: string) => new Date(iso);
const noShow = (iso: string) => ({ status: "NO_SHOW", startAt: at(iso) });
const completed = (iso: string) => ({ status: "COMPLETED", startAt: at(iso) });
describe("countNoShows", () => {
it("counts every no-show when the customer has never attended", () => {
expect(
countNoShows([noShow("2026-01-01T10:00:00Z"), noShow("2026-02-01T10:00:00Z")], null),
).toBe(2);
});
it("resets after an attended appointment", () => {
const history = [
noShow("2026-01-01T10:00:00Z"),
noShow("2026-02-01T10:00:00Z"),
completed("2026-03-01T10:00:00Z"),
];
expect(countNoShows(history, null)).toBe(0);
});
it("counts only the no-shows after the latest attended appointment", () => {
const history = [
noShow("2026-01-01T10:00:00Z"),
completed("2026-02-01T10:00:00Z"),
noShow("2026-03-01T10:00:00Z"),
noShow("2026-04-01T10:00:00Z"),
];
expect(countNoShows(history, null)).toBe(2);
});
it("ignores order of the input", () => {
const history = [
noShow("2026-04-01T10:00:00Z"),
completed("2026-02-01T10:00:00Z"),
noShow("2026-01-01T10:00:00Z"),
];
expect(countNoShows(history, null)).toBe(1);
});
it("an owner reset forgives everything before it, but not later no-shows", () => {
const history = [
noShow("2026-01-01T10:00:00Z"),
noShow("2026-02-01T10:00:00Z"),
noShow("2026-04-01T10:00:00Z"),
];
expect(countNoShows(history, at("2026-03-01T00:00:00Z"))).toBe(1);
});
it("ignores appointments that are neither no-shows nor attended", () => {
expect(countNoShows([{ status: "CANCELLED", startAt: at("2026-01-01T10:00:00Z") }], null)).toBe(
0,
);
});
});
describe("evaluateStanding", () => {
it("is all clear when the feature is off", () => {
expect(evaluateStanding(9, { flagThreshold: null, blockThreshold: null })).toMatchObject({
flagged: false,
blocked: false,
});
});
it("flags at the flag threshold and blocks at the block threshold", () => {
const thresholds = { flagThreshold: 2, blockThreshold: 4 };
expect(evaluateStanding(1, thresholds)).toMatchObject({ flagged: false, blocked: false });
expect(evaluateStanding(2, thresholds)).toMatchObject({ flagged: true, blocked: false });
expect(evaluateStanding(3, thresholds)).toMatchObject({ flagged: true, blocked: false });
expect(evaluateStanding(4, thresholds)).toMatchObject({ flagged: true, blocked: true });
});
it("supports a block with no flag, and a flag with no block", () => {
expect(evaluateStanding(3, { flagThreshold: null, blockThreshold: 3 })).toMatchObject({
flagged: true,
blocked: true,
});
expect(evaluateStanding(5, { flagThreshold: 2, blockThreshold: null })).toMatchObject({
flagged: true,
blocked: false,
});
});
});
describe("normalizeEmail", () => {
it("lowercases and trims so the same address always matches", () => {
expect(normalizeEmail(" Alex@Example.COM ")).toBe("alex@example.com");
});
});
+199
View File
@@ -0,0 +1,199 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { AppointmentStatus } from "@/generated/prisma/enums";
/**
* No-show handling: a symbol against a customer at N no-shows, and a block
* on online booking at M. The count is DERIVED from appointment history,
* never stored — a no-show is an Appointment with status NO_SHOW, and the
* count is how many of those happened after the customer's most recent
* COMPLETED (attended) appointment, or after an owner's reset
* (CustomerProfile.noShowResetAt), whichever is later. That is what makes
* "this resets if they show" free: there is no counter to forget to clear,
* and it survives guest-profile merges.
*
* Caveat inherent to the design: "attended" means staff marked the
* appointment COMPLETED, so a no-show streak only resets when they do.
*/
export interface NoShowThresholds {
flagThreshold: number | null;
blockThreshold: number | null;
}
export interface NoShowStanding extends NoShowThresholds {
count: number;
flagged: boolean;
blocked: boolean;
}
interface HistoryEntry {
status: string;
startAt: Date;
}
/** No-shows since the latest of (last completed appointment, reset). Pure — callers pass what they already loaded. */
export function countNoShows(history: HistoryEntry[], resetAt: Date | null): number {
let cutoff = resetAt ? resetAt.getTime() : Number.NEGATIVE_INFINITY;
for (const entry of history) {
if (entry.status === AppointmentStatus.COMPLETED)
cutoff = Math.max(cutoff, entry.startAt.getTime());
}
return history.filter(
(e) => e.status === AppointmentStatus.NO_SHOW && e.startAt.getTime() > cutoff,
).length;
}
/** Thresholds are null (off) or a positive integer; at/above counts, so a threshold of 2 flags on the second no-show. */
export function evaluateStanding(count: number, thresholds: NoShowThresholds): NoShowStanding {
const { flagThreshold, blockThreshold } = thresholds;
const blocked = blockThreshold !== null && count >= blockThreshold;
// A blocked customer is always also flagged, even if the owner set the block threshold below the flag one.
const flagged = blocked || (flagThreshold !== null && count >= flagThreshold);
return { count, flagThreshold, blockThreshold, flagged, blocked };
}
export async function getNoShowThresholds(
prisma: PrismaClient,
businessId: string,
): Promise<NoShowThresholds> {
const rule = await prisma.bookingRule.findUnique({
where: { businessId },
select: { noShowFlagThreshold: true, noShowBlockThreshold: true },
});
return {
flagThreshold: rule?.noShowFlagThreshold ?? null,
blockThreshold: rule?.noShowBlockThreshold ?? null,
};
}
export function isNoShowHandlingOn(thresholds: NoShowThresholds): boolean {
return thresholds.flagThreshold !== null || thresholds.blockThreshold !== null;
}
/** Guest profiles are keyed by email + phone, so these are the two things a person keeps constant. */
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/**
* Every profile that is evidently the same person as the one asked about:
* the profile itself, plus any sharing its email or its phone. Without
* this a blocked guest could book again by changing one of the two.
*/
async function relatedProfiles(
prisma: PrismaClient,
params: { customerId?: string; email?: string; phone?: string | null },
) {
const known = params.customerId
? await prisma.customerProfile.findUnique({
where: { id: params.customerId },
select: { id: true, email: true, phone: true },
})
: null;
const emails = new Set<string>();
const phones = new Set<string>();
if (known?.email) emails.add(normalizeEmail(known.email));
if (known?.phone) phones.add(known.phone);
if (params.email) emails.add(normalizeEmail(params.email));
if (params.phone) phones.add(params.phone);
const or = [
...(params.customerId ? [{ id: params.customerId }] : []),
...[...emails].map((email) => ({ email })),
...[...phones].map((phone) => ({ phone })),
];
if (or.length === 0) return [];
return prisma.customerProfile.findMany({
where: { OR: or },
select: { id: true, noShowResetAt: true },
});
}
/**
* The standing used to ENFORCE the block: counts across every profile that
* shares this customer's email or phone. Returns an "all clear" standing
* without touching appointment data when the feature is off.
*/
export async function getNoShowStanding(
prisma: PrismaClient,
params: { businessId: string; customerId?: string; email?: string; phone?: string | null },
): Promise<NoShowStanding> {
const thresholds = await getNoShowThresholds(prisma, params.businessId);
if (!isNoShowHandlingOn(thresholds)) return evaluateStanding(0, thresholds);
const profiles = await relatedProfiles(prisma, params);
if (profiles.length === 0) return evaluateStanding(0, thresholds);
const history = await prisma.appointment.findMany({
where: {
businessId: params.businessId,
customerId: { in: profiles.map((p) => p.id) },
status: { in: [AppointmentStatus.NO_SHOW, AppointmentStatus.COMPLETED] },
},
select: { status: true, startAt: true },
});
const resetAt = profiles.reduce<Date | null>(
(latest, p) =>
p.noShowResetAt && (!latest || p.noShowResetAt > latest) ? p.noShowResetAt : latest,
null,
);
return evaluateStanding(countNoShows(history, resetAt), thresholds);
}
/**
* Standings for a batch of profiles at once (the clients list, a day's
* calendar). Per profile, not cross-profile: display only, and one query
* for the whole batch matters more there than catching a renamed guest.
*/
export async function getNoShowStandings(
prisma: PrismaClient,
params: { businessId: string; customerIds: string[] },
): Promise<Map<string, NoShowStanding>> {
const standings = new Map<string, NoShowStanding>();
const thresholds = await getNoShowThresholds(prisma, params.businessId);
if (!isNoShowHandlingOn(thresholds) || params.customerIds.length === 0) return standings;
const ids = [...new Set(params.customerIds)];
const [profiles, history] = await Promise.all([
prisma.customerProfile.findMany({
where: { id: { in: ids } },
select: { id: true, noShowResetAt: true },
}),
prisma.appointment.findMany({
where: {
businessId: params.businessId,
customerId: { in: ids },
status: { in: [AppointmentStatus.NO_SHOW, AppointmentStatus.COMPLETED] },
},
select: { customerId: true, status: true, startAt: true },
}),
]);
const resetById = new Map(profiles.map((p) => [p.id, p.noShowResetAt]));
const historyById = new Map<string, HistoryEntry[]>();
for (const entry of history) {
const list = historyById.get(entry.customerId) ?? [];
list.push(entry);
historyById.set(entry.customerId, list);
}
for (const id of ids) {
const standing = evaluateStanding(
countNoShows(historyById.get(id) ?? [], resetById.get(id) ?? null),
thresholds,
);
if (standing.count > 0) standings.set(id, standing);
}
return standings;
}
/** Owner "forgive": every profile that is evidently this person starts again from zero. */
export async function resetNoShows(
prisma: PrismaClient,
params: { customerId: string; now?: Date },
): Promise<number> {
const profiles = await relatedProfiles(prisma, { customerId: params.customerId });
const { count } = await prisma.customerProfile.updateMany({
where: { id: { in: profiles.map((p) => p.id) } },
data: { noShowResetAt: params.now ?? new Date() },
});
return count;
}
@@ -9,8 +9,6 @@ const base: ProgrammeConfigInput = {
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE", rewardType: "PERCENTAGE",
rewardValue: "50", rewardValue: "50",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false, earnOnCancelled: false,
earnOnNoShow: false, earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS, cardFont: LoyaltyCardFont.SANS,
@@ -36,8 +36,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE", rewardType: "PERCENTAGE",
rewardValue: "25", rewardValue: "25",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false, earnOnCancelled: false,
earnOnNoShow: false, earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS, cardFont: LoyaltyCardFont.SANS,
@@ -58,8 +56,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE", rewardType: "PERCENTAGE",
rewardValue: "40", rewardValue: "40",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false, earnOnCancelled: false,
earnOnNoShow: false, earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS, cardFont: LoyaltyCardFont.SANS,
@@ -87,8 +83,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "FREE_SERVICE", rewardType: "FREE_SERVICE",
freeServiceId: inactiveService.id, freeServiceId: inactiveService.id,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false, earnOnCancelled: false,
earnOnNoShow: false, earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS, cardFont: LoyaltyCardFont.SANS,
@@ -342,21 +336,18 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
expect(count).toBe(1); expect(count).toBe(1);
}); });
it("sends exactly one more warning when the reward is down to its last day — two in total", async () => { it("never sends a second warning when the reward is down to its last day — one heads-up is enough", async () => {
const { reward } = await setUpAccountWithReward(new Date(Date.now() + 5 * 24 * 60 * 60 * 1000)); const { reward } = await setUpAccountWithReward(new Date(Date.now() + 5 * 24 * 60 * 60 * 1000));
await expireOverdueRewards(prisma); // 5 days out → the "7d" warning await expireOverdueRewards(prisma); // 5 days out → the one warning
await expireOverdueRewards(prisma, new Date(Date.now() + 4.5 * 24 * 60 * 60 * 1000)); // 12h left → the "1d" warning await expireOverdueRewards(prisma, new Date(Date.now() + 4.5 * 24 * 60 * 60 * 1000)); // 12h left — no second
await expireOverdueRewards(prisma, new Date(Date.now() + 4.8 * 24 * 60 * 60 * 1000)); // still the last day — no third await expireOverdueRewards(prisma, new Date(Date.now() + 4.8 * 24 * 60 * 60 * 1000)); // still the last day — no second
const notifications = await prisma.notification.findMany({ const notifications = await prisma.notification.findMany({
where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id }, where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id },
select: { idempotencyKey: true }, select: { idempotencyKey: true },
}); });
expect(notifications.map((n) => n.idempotencyKey).sort()).toEqual([ expect(notifications.map((n) => n.idempotencyKey)).toEqual([`notify:loyalty-reward-expiring:${reward.id}:7d`]);
`notify:loyalty-reward-expiring:${reward.id}:1d`,
`notify:loyalty-reward-expiring:${reward.id}:7d`,
]);
}); });
}); });
}); });
+4 -8
View File
@@ -549,8 +549,6 @@ export interface ProgrammeConfigInput {
rewardLabel?: string; rewardLabel?: string;
maxRewardValue?: string; maxRewardValue?: string;
freeServiceId?: string; freeServiceId?: string;
autoApplyReward: boolean;
requiresStaffApproval: boolean;
stampsExpireAfterDays?: number; stampsExpireAfterDays?: number;
rewardExpiresAfterDays?: number; rewardExpiresAfterDays?: number;
earnOnCancelled: boolean; earnOnCancelled: boolean;
@@ -630,8 +628,6 @@ export async function updateProgrammeConfig(
rewardLabel: params.input.rewardLabel?.trim() || null, rewardLabel: params.input.rewardLabel?.trim() || null,
maxRewardValue: params.input.maxRewardValue || null, maxRewardValue: params.input.maxRewardValue || null,
freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null, freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null,
autoApplyReward: params.input.autoApplyReward,
requiresStaffApproval: params.input.requiresStaffApproval,
stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null, stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null,
rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null, rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null,
earnOnCancelled: params.input.earnOnCancelled, earnOnCancelled: params.input.earnOnCancelled,
@@ -864,9 +860,9 @@ const REWARD_EXPIRING_SOON_WINDOW_DAYS = 7;
* expiresAt has passed to EXPIRED (redeemReward also defends against this * expiresAt has passed to EXPIRED (redeemReward also defends against this
* directly, but the sweep is what makes "Expired" actually show up in * directly, but the sweep is what makes "Expired" actually show up in
* history/UI instead of staying stuck at AVAILABLE forever), then warn * history/UI instead of staying stuck at AVAILABLE forever), then warn
* about rewards still AVAILABLE and expiring within the next week — twice * about rewards still AVAILABLE and expiring within the next week — once
* per reward at most (7 days out, then the last day; see * per reward (see notifyLoyaltyRewardExpiring), not on every sweep. A second
* notifyLoyaltyRewardExpiring), not on every sweep. * "last day" email was cut: one heads-up is enough, and it arrives a week out.
*/ */
export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise<void> { export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise<void> {
await prisma.loyaltyReward.updateMany({ await prisma.loyaltyReward.updateMany({
@@ -890,7 +886,7 @@ export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new
rewardId: reward.id, rewardId: reward.id,
rewardLabel: reward.label, rewardLabel: reward.label,
expiresAt: reward.expiresAt, expiresAt: reward.expiresAt,
bucket: reward.expiresAt.getTime() - now.getTime() <= 86_400_000 ? "1d" : "7d", bucket: "7d",
}); });
} }
} }
@@ -231,15 +231,9 @@ describe("NotificationService (integration)", () => {
expect(staffNotification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.IN_APP); expect(staffNotification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.IN_APP);
}); });
it("STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, OWNER_NEW_BOOKING, and OWNER_NO_SHOW default to off with no preference row", async () => { it("OWNER_NEW_BOOKING and OWNER_NO_SHOW default to off on every channel with no preference row", async () => {
const { userId } = await createTestCustomer(business); const { userId } = await createTestCustomer(business);
const disabledByDefault = [ for (const type of [NotificationType.OWNER_NEW_BOOKING, NotificationType.OWNER_NO_SHOW]) {
NotificationType.STAFF_NEW_APPOINTMENT,
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
NotificationType.OWNER_NEW_BOOKING,
NotificationType.OWNER_NO_SHOW,
];
for (const type of disabledByDefault) {
const { notification } = await notify(prisma, { const { notification } = await notify(prisma, {
businessId: business.businessId, businessId: business.businessId,
type, type,
@@ -251,6 +245,19 @@ describe("NotificationService (integration)", () => {
} }
}); });
it("STAFF_NEW_APPOINTMENT and STAFF_APPOINTMENT_RESCHEDULED never email by default, and a stored EMAIL preference opts in", async () => {
const { userId } = await createTestCustomer(business);
for (const type of [NotificationType.STAFF_NEW_APPOINTMENT, NotificationType.STAFF_APPOINTMENT_RESCHEDULED]) {
const params = { businessId: business.businessId, type, recipientUserId: userId, bodyText: "x" };
const { notification: defaults } = await notify(prisma, { ...params, idempotencyKey: `test:${userId}:default:${type}` });
expect(defaults.deliveries.map((d) => d.channel)).not.toContain(NotificationChannel.EMAIL);
await setNotificationPreference(prisma, { userId, type, channel: NotificationChannel.EMAIL, isEnabled: true });
const { notification: optedIn } = await notify(prisma, { ...params, idempotencyKey: `test:${userId}:optin:${type}` });
expect(optedIn.deliveries.map((d) => d.channel)).toContain(NotificationChannel.EMAIL);
}
});
it("STAFF_APPOINTMENT_REMINDER defaults to IN_APP on and EMAIL off, and a stored EMAIL preference opts in", async () => { it("STAFF_APPOINTMENT_REMINDER defaults to IN_APP on and EMAIL off, and a stored EMAIL preference opts in", async () => {
const { userId } = await createTestCustomer(business); const { userId } = await createTestCustomer(business);
const params = { const params = {
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NotificationType } from "@/generated/prisma/enums";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { notify } from "../notification.service";
import { getQueueHealth } from "../queue-health";
import { MockEmailProvider } from "../providers/mock-email-provider";
import { processDueNotificationDeliveries } from "../reminder-worker";
describe("notification queue health (integration)", () => {
let business: TestBusiness;
let n = 0;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
/** One EMAIL delivery for a registered customer, backdated by `minutesOld`. */
async function queueEmail(minutesOld: number) {
const { customerId, userId } = await createTestCustomer(business);
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "hi",
idempotencyKey: `qh:${Date.now()}:${n++}`,
});
const delivery = notification.deliveries.find((d) => d.channel === "EMAIL")!;
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: { createdAt: new Date(Date.now() - minutesOld * 60_000) },
});
return delivery.id;
}
// The test database is shared by every business a run has created, so assert on differences.
it("counts only deliveries that have been waiting more than ten minutes", async () => {
const before = await getQueueHealth(prisma);
await queueEmail(2); // fresh: not stale
await queueEmail(30); // stale
const after = await getQueueHealth(prisma);
expect(after.stalePendingCount - before.stalePendingCount).toBe(1);
expect(after.oldestPendingAgeSeconds).toBeGreaterThanOrEqual(29 * 60);
});
it("puts a delivery stuck in PROCESSING back in the queue, and sends it", async () => {
const id = await queueEmail(60);
await prisma.notificationDelivery.update({
where: { id },
data: { status: "PROCESSING", lastAttemptedAt: new Date(Date.now() - 30 * 60_000) },
});
expect((await getQueueHealth(prisma)).stuckProcessingCount).toBeGreaterThanOrEqual(1);
const provider = new MockEmailProvider();
await processDueNotificationDeliveries(prisma, {
emailProvider: provider,
businessId: business.businessId,
});
const delivery = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id } });
expect(delivery.status).toBe("SENT");
expect(provider.sentEmails).toHaveLength(1);
});
it("leaves a delivery that is mid-send right now alone", async () => {
const id = await queueEmail(1);
await prisma.notificationDelivery.update({
where: { id },
data: { status: "PROCESSING", lastAttemptedAt: new Date(Date.now() - 30_000) },
});
await processDueNotificationDeliveries(prisma, {
emailProvider: new MockEmailProvider(),
businessId: business.businessId,
});
expect((await prisma.notificationDelivery.findUniqueOrThrow({ where: { id } })).status).toBe(
"PROCESSING",
);
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { assessQueueHealth } from "../queue-health";
import type { QueueHealth } from "../queue-health";
import { isSystemicFailureReason } from "../failure-reasons";
const now = new Date("2026-10-20T12:00:00Z");
const minutesAgo = (m: number) => new Date(now.getTime() - m * 60_000);
const calm: QueueHealth = {
stalePendingCount: 0,
oldestPendingAgeSeconds: null,
stuckProcessingCount: 0,
failedLast24h: 0,
sentLast24h: 10,
};
const assess = (overrides: Partial<Parameters<typeof assessQueueHealth>[0]> = {}) =>
assessQueueHealth({
installed: true,
installedAt: minutesAgo(60 * 24 * 30),
lastCronRunAt: minutesAgo(1),
queue: calm,
now,
...overrides,
});
describe("assessQueueHealth", () => {
it("is healthy when the cron ran a minute ago and nothing is backed up", () => {
expect(assess()).toEqual({ status: "healthy", degradedReasons: [] });
});
it("is degraded when the cron hasn't run for ten minutes, and says how long", () => {
const result = assess({ lastCronRunAt: minutesAgo(45) });
expect(result.status).toBe("degraded");
expect(result.degradedReasons[0]).toContain("45 minutes");
});
it("is degraded when the cron has never run on an established install", () => {
const result = assess({ lastCronRunAt: null });
expect(result.status).toBe("degraded");
expect(result.degradedReasons[0]).toContain("never run");
});
it("gives a brand-new install ten minutes before 'the cron never ran' counts", () => {
expect(assess({ lastCronRunAt: null, installedAt: minutesAgo(3) }).status).toBe("healthy");
expect(assess({ lastCronRunAt: null, installedAt: minutesAgo(30) }).status).toBe("degraded");
});
it("says nothing about the cron before the app is installed", () => {
expect(assess({ installed: false, installedAt: null, lastCronRunAt: null }).status).toBe(
"healthy",
);
});
it("is degraded when notifications are waiting or stuck", () => {
expect(
assess({ queue: { ...calm, stalePendingCount: 4, oldestPendingAgeSeconds: 900 } })
.degradedReasons[0],
).toContain("4 notification");
expect(assess({ queue: { ...calm, stuckProcessingCount: 1 } }).status).toBe("degraded");
});
it("treats a few failures among successes as normal, but all-failing as a setup problem", () => {
expect(assess({ queue: { ...calm, failedLast24h: 5, sentLast24h: 20 } }).status).toBe(
"healthy",
);
expect(assess({ queue: { ...calm, failedLast24h: 5, sentLast24h: 0 } }).status).toBe(
"degraded",
);
expect(assess({ queue: { ...calm, failedLast24h: 2, sentLast24h: 0 } }).status).toBe("healthy");
});
});
describe("isSystemicFailureReason", () => {
it("recognises setup problems: bad SMTP login, auth failures, missing keys", () => {
for (const reason of [
"Invalid login: 535 5.7.8 Authentication failed",
"Web Push is not configured — VAPID_PUBLIC_KEY must be set",
"401 Unauthorized",
"Invalid API key",
]) {
expect(isSystemicFailureReason(reason)).toBe(true);
}
});
it("does not treat one unreachable recipient as a setup problem", () => {
for (const reason of [
"550 5.1.1 Mailbox not found",
"No email address on file for this recipient.",
"Connection timed out",
]) {
expect(isSystemicFailureReason(reason)).toBe(false);
}
});
});
@@ -53,13 +53,33 @@ describe("defaultPreferenceIsEnabled", () => {
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(false); expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(false);
}); });
it("types that default off do so on every channel", () => { it("routine staff events are push-only by default: email off, push on", () => {
for (const type of [ for (const type of [
NotificationType.STAFF_NEW_APPOINTMENT, NotificationType.STAFF_NEW_APPOINTMENT,
NotificationType.STAFF_APPOINTMENT_RESCHEDULED, NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
NotificationType.OWNER_NEW_BOOKING, NotificationType.STAFF_APPOINTMENT_REMINDER,
NotificationType.OWNER_NO_SHOW, NotificationType.STAFF_WHATSAPP_REMINDERS_DUE,
]) { ]) {
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(false);
expect(defaultPreferenceIsEnabled(type, NotificationChannel.PUSH)).toBe(true);
}
});
it("important staff and owner events keep email on by default", () => {
for (const type of [
NotificationType.STAFF_APPOINTMENT_CANCELLED,
NotificationType.STAFF_SCHEDULE_CHANGED,
NotificationType.STAFF_INVITATION_RECEIVED,
NotificationType.OWNER_BOOKING_CANCELLED,
NotificationType.OWNER_STAFF_INVITATION_ACCEPTED,
NotificationType.OWNER_REPEATED_DELIVERY_FAILURE,
]) {
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(true);
}
});
it("owner-facing types that would be noise for a multi-barber shop default off on every channel", () => {
for (const type of [NotificationType.OWNER_NEW_BOOKING, NotificationType.OWNER_NO_SHOW]) {
for (const channel of channels) expect(defaultPreferenceIsEnabled(type, channel)).toBe(false); for (const channel of channels) expect(defaultPreferenceIsEnabled(type, channel)).toBe(false);
} }
}); });
@@ -0,0 +1,11 @@
/**
* Failures that say the SETUP is broken (a wrong SMTP login, bad push keys, a
* missing config) rather than one recipient being unreachable (a mistyped
* address). The first of these is worth telling the owner about at once; a
* recipient-specific one has to repeat before it is.
*/
export function isSystemicFailureReason(reason: string): boolean {
return /\b(auth(entication)?|invalid login|credential|unauthori[sz]ed|forbidden|api[- ]?key|vapid|not configured|535|534|530)\b/i.test(
reason,
);
}
@@ -153,7 +153,7 @@ export class BrevoEmailProvider implements EmailProvider {
// display name comes from the caller. // display name comes from the caller.
from: { address: this.fromAddress, name: params.sender.name }, from: { address: this.fromAddress, name: params.sender.name },
to: { address: params.to.email, name: params.to.name }, to: { address: params.to.email, name: params.to.name },
replyTo: this.replyTo, replyTo: params.replyTo || this.replyTo,
subject: params.subject, subject: params.subject,
html: params.html, html: params.html,
text: params.text, text: params.text,
@@ -32,6 +32,12 @@ export interface SendEmailParams {
/** The resolved business name (or fallback) to show as the visible sender — see render-email.ts. */ /** The resolved business name (or fallback) to show as the visible sender — see render-email.ts. */
sender: EmailSender; sender: EmailSender;
subject: string; subject: string;
/**
* Where a customer's reply should go — the business's own email, so answering
* "can I move my appointment?" reaches the shop rather than a shared inbox.
* Falls back to the provider's own configured reply-to (EMAIL_REPLY_TO), if any.
*/
replyTo?: string;
html: string; html: string;
/** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */ /** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */
text: string; text: string;
+140
View File
@@ -0,0 +1,140 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationChannel, NotificationDeliveryStatus } from "@/generated/prisma/enums";
/**
* What GET /api/health reports about the notification queue, and the rules
* that turn it into "healthy" or "degraded". The Manager polls this per
* business and emails the operator when it turns degraded (see
* manager/src/lib/health.ts, which reads exactly these field names).
*
* Why a "degraded" state at all: email, push and WhatsApp nudges are all
* delivered by a background tick (/api/cron/process-notifications). If that
* tick silently stops, nothing errors — messages just never go out — so the
* app has to say so itself.
*/
export const STALE_AFTER_MINUTES = 10;
export interface QueueHealth {
/** Email/push deliveries that were due more than STALE_AFTER_MINUTES ago and still haven't been sent. */
stalePendingCount: number;
oldestPendingAgeSeconds: number | null;
/** Deliveries claimed by a worker that then never finished (it crashed mid-send). */
stuckProcessingCount: number;
failedLast24h: number;
sentLast24h: number;
}
const QUEUED_CHANNELS = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
export async function getQueueHealth(
prisma: PrismaClient,
now: Date = new Date(),
): Promise<QueueHealth> {
const cutoff = new Date(now.getTime() - STALE_AFTER_MINUTES * 60_000);
const dayAgo = new Date(now.getTime() - 24 * 3_600_000);
const [pending, scheduled, stuck, failed, sent] = await Promise.all([
// Created and never picked up. PENDING rows have no scheduledFor unless they're waiting out a retry backoff.
prisma.notificationDelivery.aggregate({
where: {
channel: { in: QUEUED_CHANNELS },
status: NotificationDeliveryStatus.PENDING,
OR: [{ scheduledFor: null, createdAt: { lt: cutoff } }, { scheduledFor: { lt: cutoff } }],
},
_count: true,
_min: { createdAt: true, scheduledFor: true },
}),
// Reminders whose time has come.
prisma.notificationDelivery.aggregate({
where: {
channel: { in: QUEUED_CHANNELS },
status: NotificationDeliveryStatus.SCHEDULED,
scheduledFor: { lt: cutoff },
},
_count: true,
_min: { scheduledFor: true },
}),
prisma.notificationDelivery.count({
where: {
channel: { in: QUEUED_CHANNELS },
status: NotificationDeliveryStatus.PROCESSING,
lastAttemptedAt: { lt: cutoff },
},
}),
prisma.notificationDelivery.count({
where: {
channel: { in: QUEUED_CHANNELS },
status: NotificationDeliveryStatus.FAILED,
lastAttemptedAt: { gte: dayAgo },
},
}),
prisma.notificationDelivery.count({
where: {
channel: { in: QUEUED_CHANNELS },
status: NotificationDeliveryStatus.SENT,
sentAt: { gte: dayAgo },
},
}),
]);
const dueTimes = [
pending._min.scheduledFor ?? pending._min.createdAt,
scheduled._min.scheduledFor,
].filter((d): d is Date => d instanceof Date);
const oldest = dueTimes.length > 0 ? Math.min(...dueTimes.map((d) => d.getTime())) : null;
return {
stalePendingCount: pending._count + scheduled._count,
oldestPendingAgeSeconds:
oldest === null ? null : Math.max(0, Math.round((now.getTime() - oldest) / 1000)),
stuckProcessingCount: stuck,
failedLast24h: failed,
sentLast24h: sent,
};
}
export interface HealthAssessment {
status: "healthy" | "degraded";
degradedReasons: string[];
}
/** Pure. `installedAt`/`lastCronRunAt` null = never. A brand-new install gets a grace period before "the cron never ran" counts. */
export function assessQueueHealth(input: {
installed: boolean;
installedAt: Date | null;
lastCronRunAt: Date | null;
queue: QueueHealth;
now?: Date;
}): HealthAssessment {
const now = input.now ?? new Date();
const reasons: string[] = [];
const minutesAgo = (d: Date) => Math.round((now.getTime() - d.getTime()) / 60_000);
if (input.installed) {
const reference = input.lastCronRunAt ?? input.installedAt;
if (reference && minutesAgo(reference) >= STALE_AFTER_MINUTES) {
reasons.push(
input.lastCronRunAt
? `The notification processor hasn't run for ${minutesAgo(input.lastCronRunAt)} minutes — emails and push notifications are not being sent.`
: "The notification processor has never run — emails and push notifications are not being sent.",
);
}
}
if (input.queue.stalePendingCount > 0) {
reasons.push(
`${input.queue.stalePendingCount} notification(s) have been waiting more than ${STALE_AFTER_MINUTES} minutes to send.`,
);
}
if (input.queue.stuckProcessingCount > 0) {
reasons.push(`${input.queue.stuckProcessingCount} notification(s) got stuck while sending.`);
}
// A handful of failures is normal (a mistyped address). Every one failing is a configuration problem: bad SMTP login, bad push keys.
if (input.queue.failedLast24h >= 3 && input.queue.sentLast24h === 0) {
reasons.push(
`All ${input.queue.failedLast24h} notification deliveries in the last 24 hours failed — check the email and push settings.`,
);
}
return { status: reasons.length > 0 ? "degraded" : "healthy", degradedReasons: reasons };
}
+21 -2
View File
@@ -1,5 +1,6 @@
import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums"; import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
import { isSystemicFailureReason } from "./failure-reasons";
import { notifySafely, resolveActivePushSubscriptions, resolveEmailAddress } from "./notification.service"; import { notifySafely, resolveActivePushSubscriptions, resolveEmailAddress } from "./notification.service";
import type { EmailProvider } from "./providers/email-provider"; import type { EmailProvider } from "./providers/email-provider";
import type { PushProvider } from "./providers/push-provider"; import type { PushProvider } from "./providers/push-provider";
@@ -39,6 +40,9 @@ const BACKOFF_MINUTES = [1, 5, 30] as const;
/** Failed deliveries for one business within a single processing run at or above this count raises OWNER_REPEATED_DELIVERY_FAILURE — see NotificationType in schema.prisma. */ /** Failed deliveries for one business within a single processing run at or above this count raises OWNER_REPEATED_DELIVERY_FAILURE — see NotificationType in schema.prisma. */
const REPEATED_FAILURE_THRESHOLD = 3; const REPEATED_FAILURE_THRESHOLD = 3;
/** A delivery claimed this long ago that never finished belongs to a worker that died mid-send. */
const STUCK_PROCESSING_AFTER_MINUTES = 10;
export interface ProcessNotificationsResult { export interface ProcessNotificationsResult {
claimed: number; claimed: number;
sent: number; sent: number;
@@ -130,6 +134,17 @@ export async function processDueNotificationDeliveries(
const now = params.now ?? new Date(); const now = params.now ?? new Date();
const batchSize = params.batchSize ?? 25; const batchSize = params.batchSize ?? 25;
// A worker that crashed mid-send leaves its delivery PROCESSING forever, and nothing else
// ever picks it up. Put those back in the queue (a send takes seconds; 10 minutes is a corpse).
await prisma.notificationDelivery.updateMany({
where: {
channel: { in: [NotificationChannel.EMAIL, NotificationChannel.PUSH] },
status: NotificationDeliveryStatus.PROCESSING,
lastAttemptedAt: { lt: new Date(now.getTime() - STUCK_PROCESSING_AFTER_MINUTES * 60_000) },
},
data: { status: NotificationDeliveryStatus.PENDING },
});
const due = await prisma.notificationDelivery.findMany({ const due = await prisma.notificationDelivery.findMany({
where: { where: {
channel: { in: [NotificationChannel.EMAIL, NotificationChannel.PUSH] }, channel: { in: [NotificationChannel.EMAIL, NotificationChannel.PUSH] },
@@ -152,6 +167,8 @@ export async function processDueNotificationDeliveries(
inAppReleased: 0, inAppReleased: 0,
}; };
const failuresByBusiness = new Map<string, number>(); const failuresByBusiness = new Map<string, number>();
const systemicFailureBusinesses = new Set<string>();
const inApp = await releaseDueInAppReminders(prisma, now); const inApp = await releaseDueInAppReminders(prisma, now);
result.inAppReleased = inApp.released; result.inAppReleased = inApp.released;
@@ -230,11 +247,12 @@ export async function processDueNotificationDeliveries(
delivery.notification.businessId, delivery.notification.businessId,
(failuresByBusiness.get(delivery.notification.businessId) ?? 0) + 1, (failuresByBusiness.get(delivery.notification.businessId) ?? 0) + 1,
); );
if (isSystemicFailureReason(outcome.reason)) systemicFailureBusinesses.add(delivery.notification.businessId);
} }
} }
for (const [businessId, count] of failuresByBusiness) { for (const [businessId, count] of failuresByBusiness) {
if (count >= REPEATED_FAILURE_THRESHOLD) { if (count >= REPEATED_FAILURE_THRESHOLD || systemicFailureBusinesses.has(businessId)) {
await raiseRepeatedFailureNotice(prisma, businessId, now); await raiseRepeatedFailureNotice(prisma, businessId, now);
} }
} }
@@ -266,6 +284,7 @@ async function processEmailDelivery(prisma: PrismaClient, delivery: DueDelivery,
const sendResult = await emailProvider.sendEmail({ const sendResult = await emailProvider.sendEmail({
to: { email: recipientEmail }, to: { email: recipientEmail },
sender: email.sender, sender: email.sender,
replyTo: email.replyTo,
subject: email.subject, subject: email.subject,
html: email.html, html: email.html,
text: email.text, text: email.text,
@@ -353,7 +372,7 @@ async function raiseRepeatedFailureNotice(prisma: PrismaClient, businessId: stri
recipientUserId: ownerUserId, recipientUserId: ownerUserId,
subject: "Some notifications failed to send", subject: "Some notifications failed to send",
bodyText: "Several notifications failed to send today. Check your notification settings, Brevo configuration, and push setup.", bodyText: "Several notifications failed to send today. Check your notification settings, Brevo configuration, and push setup.",
deepLinkPath: "/owner/notifications", deepLinkPath: "/staff/settings/notifications",
idempotencyKey: `notify:owner-repeated-delivery-failure:${businessId}:${dayStamp}`, idempotencyKey: `notify:owner-repeated-delivery-failure:${businessId}:${dayStamp}`,
}); });
} }
+75 -6
View File
@@ -1,5 +1,6 @@
import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums"; import { NotificationType } from "@/generated/prisma/enums";
import { recipientKindOf } from "./types";
import { import {
renderBookingCancelledEmail, renderBookingCancelledEmail,
renderBookingConfirmedEmail, renderBookingConfirmedEmail,
@@ -21,6 +22,44 @@ import type { EmailSender } from "./providers/email-provider";
export interface RenderedEmailWithSender extends RenderedEmail { export interface RenderedEmailWithSender extends RenderedEmail {
/** Resolved fresh on every call from the notification's own business — see resolveSenderName. */ /** Resolved fresh on every call from the notification's own business — see resolveSenderName. */
sender: EmailSender; sender: EmailSender;
/** The business's own email, so a customer's reply reaches the shop. Undefined when the business hasn't set one. */
replyTo?: string;
}
/** Button text per notification type for emails that use the generic template. */
const GENERIC_ACTION_LABEL: Partial<Record<NotificationType, string>> = {
BOOKING_APPROVED: "View Appointment",
BOOKING_BARBER_CHANGED: "View Appointment",
LOYALTY_REWARD_EXPIRING: "View Loyalty Card",
STAFF_NEW_APPOINTMENT: "View Appointment",
STAFF_APPOINTMENT_RESCHEDULED: "View Appointment",
STAFF_APPOINTMENT_CANCELLED: "View Appointment",
STAFF_APPOINTMENT_REMINDER: "View Appointment",
STAFF_SCHEDULE_CHANGED: "View Schedule",
STAFF_INVITATION_RECEIVED: "Accept Invitation",
STAFF_WHATSAPP_REMINDERS_DUE: "Open Reminders",
OWNER_NEW_BOOKING: "View Booking",
OWNER_BOOKING_CANCELLED: "View Booking",
OWNER_NO_SHOW: "View Booking",
OWNER_STAFF_INVITATION_ACCEPTED: "View Team",
OWNER_REPEATED_DELIVERY_FAILURE: "Check Notification Settings",
};
/** Where this recipient manages their notification choices — guests have no account, so no link. */
function preferencesPathFor(notification: { type: NotificationType; recipientUserId: string | null }): string | undefined {
if (!notification.recipientUserId) return undefined;
return recipientKindOf(notification.type) === "CUSTOMER" ? "/settings/notifications" : "/staff/settings/notifications";
}
/** The .ics download for an appointment, carrying the same guest token as the manage link so it works without signing in. */
function calendarPathFor(appointmentId: string, manageAppointmentPath: string): string {
let token: string | null = null;
try {
token = new URL(manageAppointmentPath, "http://placeholder.invalid").searchParams.get("token");
} catch {
token = null;
}
return `/api/appointments/${appointmentId}/calendar.ics${token ? `?token=${encodeURIComponent(token)}` : ""}`;
} }
/** /**
@@ -61,6 +100,8 @@ export async function renderEmailForNotification(
if (!business) return null; if (!business) return null;
const sender: EmailSender = { name: resolveSenderName(business.name) }; const sender: EmailSender = { name: resolveSenderName(business.name) };
const replyTo = business.email?.trim() || undefined;
const format = { timezone: business.timezone, currency: business.currency, locale: business.locale };
const businessInfo = { const businessInfo = {
name: business.name, name: business.name,
@@ -70,11 +111,12 @@ export async function renderEmailForNotification(
: undefined, : undefined,
phone: business.phone ?? undefined, phone: business.phone ?? undefined,
cancellationPolicy: business.settings?.cancellationPolicy ?? undefined, cancellationPolicy: business.settings?.cancellationPolicy ?? undefined,
themeColor: business.themeColor ?? undefined,
}; };
const recipientName = await resolveRecipientName(prisma, notification); const recipientName = await resolveRecipientName(prisma, notification);
const rendered = await renderBody(); const rendered = await renderBody();
return { ...rendered, sender }; return { ...rendered, sender, replyTo };
async function renderBody(): Promise<RenderedEmail> { async function renderBody(): Promise<RenderedEmail> {
switch (notification.type) { switch (notification.type) {
@@ -83,11 +125,19 @@ export async function renderEmailForNotification(
case NotificationType.BOOKING_RESCHEDULED: case NotificationType.BOOKING_RESCHEDULED:
case NotificationType.BOOKING_CANCELLED: case NotificationType.BOOKING_CANCELLED:
case NotificationType.BOOKING_REMINDER: { case NotificationType.BOOKING_REMINDER: {
const data = await buildAppointmentEmailData(prisma, notification, { const manageAppointmentPath = notification.deepLinkPath ?? "/appointments";
const data = await buildAppointmentEmailData(
prisma,
notification,
{
business: businessInfo, business: businessInfo,
recipientName, recipientName,
manageAppointmentPath: notification.deepLinkPath ?? "/appointments", manageAppointmentPath,
}); calendarPath: notification.appointmentId ? calendarPathFor(notification.appointmentId, manageAppointmentPath) : undefined,
preferencesPath: preferencesPathFor(notification),
},
format,
);
if (!data) return renderFallback(); if (!data) return renderFallback();
if (notification.type === NotificationType.BOOKING_CONFIRMED) if (notification.type === NotificationType.BOOKING_CONFIRMED)
return renderBookingConfirmedEmail(data); return renderBookingConfirmedEmail(data);
@@ -123,6 +173,8 @@ export async function renderEmailForNotification(
subject: notification.subject ?? businessInfo.name, subject: notification.subject ?? businessInfo.name,
bodyText: notification.bodyText, bodyText: notification.bodyText,
deepLinkPath: notification.deepLinkPath ?? undefined, deepLinkPath: notification.deepLinkPath ?? undefined,
actionLabel: GENERIC_ACTION_LABEL[notification.type],
preferencesPath: preferencesPathFor(notification),
}); });
} }
} }
@@ -152,7 +204,11 @@ async function resolveRecipientName(
async function buildAppointmentEmailData( async function buildAppointmentEmailData(
prisma: PrismaClient, prisma: PrismaClient,
notification: { appointmentId: string | null }, notification: { appointmentId: string | null },
base: Pick<AppointmentEmailData, "business" | "recipientName" | "manageAppointmentPath">, base: Pick<
AppointmentEmailData,
"business" | "recipientName" | "manageAppointmentPath" | "calendarPath" | "preferencesPath"
>,
format: { timezone: string; currency: string; locale: string },
): Promise<AppointmentEmailData | null> { ): Promise<AppointmentEmailData | null> {
if (!notification.appointmentId) return null; if (!notification.appointmentId) return null;
const appointment = await prisma.appointment.findUnique({ const appointment = await prisma.appointment.findUnique({
@@ -174,17 +230,21 @@ async function buildAppointmentEmailData(
...base, ...base,
staffName: appointment.staff.user.name ?? "your barber", staffName: appointment.staff.user.name ?? "your barber",
serviceNames: appointment.services.map((s: { service: { name: string } }) => s.service.name), serviceNames: appointment.services.map((s: { service: { name: string } }) => s.service.name),
// Always in the business's own timezone: the server runs in UTC, so formatting without
// one showed a 10:00 summer appointment as 09:00 in its own confirmation email.
dateLabel: appointment.startAt.toLocaleDateString("en-GB", { dateLabel: appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short", weekday: "short",
day: "numeric", day: "numeric",
month: "short", month: "short",
timeZone: format.timezone,
}), }),
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", { timeLabel: appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
timeZone: format.timezone,
}), }),
durationLabel: `${totalMinutes} min`, durationLabel: `${totalMinutes} min`,
totalLabel: `£${totalPrice.toFixed(2)}`, totalLabel: formatMoney(totalPrice, format),
appointmentRef: appointment.id.slice(-8), appointmentRef: appointment.id.slice(-8),
}; };
} }
@@ -214,3 +274,12 @@ async function buildLoyaltyRewardEmailData(
loyaltyCardPath: "/home", loyaltyCardPath: "/home",
}; };
} }
/** The business's own currency and locale — a hard-coded "£" was wrong for any other currency. */
export function formatMoney(amount: number, format: { currency: string; locale: string }): string {
try {
return new Intl.NumberFormat(format.locale, { style: "currency", currency: format.currency }).format(amount);
} catch {
return `${format.currency} ${amount.toFixed(2)}`;
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { escapeHtml } from "../layout"; import { escapeHtml, normalizeAccentColor, readableTextOn, renderEmailLayout } from "../layout";
import { import {
renderBookingCancelledEmail, renderBookingCancelledEmail,
renderBookingConfirmedEmail, renderBookingConfirmedEmail,
@@ -9,9 +9,11 @@ import {
} from "../booking-emails"; } from "../booking-emails";
import type { AppointmentEmailData } from "../booking-emails"; import type { AppointmentEmailData } from "../booking-emails";
import { renderGenericNotificationEmail } from "../generic-email"; import { renderGenericNotificationEmail } from "../generic-email";
import { renderLoyaltyRewardEarnedEmail, renderLoyaltyRewardRedeemedEmail } from "../loyalty-emails"; import {
renderLoyaltyRewardEarnedEmail,
renderLoyaltyRewardRedeemedEmail,
} from "../loyalty-emails";
import type { LoyaltyRewardEmailData } from "../loyalty-emails"; import type { LoyaltyRewardEmailData } from "../loyalty-emails";
import { renderStaffInvitationEmail, renderStaffScheduleChangedEmail } from "../staff-emails";
describe("escapeHtml", () => { describe("escapeHtml", () => {
it("escapes all five HTML-significant characters", () => { it("escapes all five HTML-significant characters", () => {
@@ -21,7 +23,11 @@ describe("escapeHtml", () => {
}); });
}); });
const business = { name: "Blake Heritage Barbershop", address: "1 High St, London", phone: "020 1234 5678" }; const business = {
name: "Blake Heritage Barbershop",
address: "1 High St, London",
phone: "020 1234 5678",
};
const appointmentData: AppointmentEmailData = { const appointmentData: AppointmentEmailData = {
business, business,
@@ -66,7 +72,9 @@ describe("booking email templates", () => {
}); });
it("renderBookingCancelledEmail escapes a user-supplied cancellation reason", () => { it("renderBookingCancelledEmail escapes a user-supplied cancellation reason", () => {
const email = renderBookingCancelledEmail(appointmentData, { reason: `<img src=x onerror="alert(1)">` }); const email = renderBookingCancelledEmail(appointmentData, {
reason: `<img src=x onerror="alert(1)">`,
});
expect(email.html).not.toContain("<img src=x"); expect(email.html).not.toContain("<img src=x");
expect(email.html).toContain("&lt;img"); expect(email.html).toContain("&lt;img");
}); });
@@ -98,32 +106,6 @@ describe("loyalty email templates", () => {
}); });
}); });
describe("staff email templates", () => {
it("renderStaffInvitationEmail includes the accept link and role", () => {
const email = renderStaffInvitationEmail({
business,
staffName: "Jamie Wallace",
roleName: "Barber",
invitedByName: "Marcus Reed",
acceptInvitePath: "/invite/accept?token=abc",
});
expect(email.html).toContain("Barber");
expect(email.html).toContain("/invite/accept?token=abc");
});
it("renderStaffScheduleChangedEmail names who made the change", () => {
const email = renderStaffScheduleChangedEmail({
business,
staffName: "Leo Vance",
changedByName: "Marcus Reed",
whatChanged: "your working hours",
viewSchedulePath: "/staff/settings/working-hours",
});
expect(email.html).toContain("Marcus Reed");
expect(email.html).toContain("your working hours");
});
});
describe("generic fallback template", () => { describe("generic fallback template", () => {
it("renders the notification's own subject/bodyText when no bespoke template exists", () => { it("renders the notification's own subject/bodyText when no bespoke template exists", () => {
const email = renderGenericNotificationEmail({ const email = renderGenericNotificationEmail({
@@ -148,3 +130,97 @@ describe("generic fallback template", () => {
expect(email.html).not.toContain("View Details"); expect(email.html).not.toContain("View Details");
}); });
}); });
describe("email layout", () => {
const layout = (overrides: Partial<Parameters<typeof renderEmailLayout>[0]> = {}) =>
renderEmailLayout({
businessName: "Blake Heritage",
headline: "Hello",
bodyHtml: "<p>Body</p>",
...overrides,
});
it("is a mobile-friendly, dark-mode-aware document", () => {
const html = layout();
expect(html).toContain('<html lang="en">');
expect(html).toContain('name="viewport"');
expect(html).toContain('name="color-scheme" content="light dark"');
expect(html).toContain("prefers-color-scheme: dark");
expect(html).toContain("<title>Hello</title>");
});
it("shows the preheader as hidden inbox preview text, escaped", () => {
const html = layout({ preheader: "Mon 3 Aug <at> 16:30" });
expect(html).toContain("display:none");
expect(html).toContain("Mon 3 Aug &lt;at&gt; 16:30");
});
it("renders details in a card and escapes their values", () => {
const html = layout({ details: [{ label: "Barber", value: "Marcus <Reed>" }] });
expect(html).toContain("Barber");
expect(html).toContain("Marcus &lt;Reed&gt;");
expect(html).not.toContain("Marcus <Reed>");
});
it("uses the business colour for the button with readable text on it", () => {
const dark = layout({
actionLabel: "Go",
actionUrl: "https://x.test/a",
accentColor: "#1f3440",
});
expect(dark).toContain("background:#1f3440");
expect(dark).toContain("color:#ffffff");
const light = layout({
actionLabel: "Go",
actionUrl: "https://x.test/a",
accentColor: "#e9c98f",
});
expect(light).toContain("background:#e9c98f");
expect(light).toContain("color:#111111");
});
it("never lets a non-hex colour into an inline style", () => {
expect(normalizeAccentColor("red;background:url(x)")).toBe("#171717");
expect(normalizeAccentColor("#ABCDEF")).toBe("#abcdef");
expect(normalizeAccentColor(undefined)).toBe("#171717");
expect(readableTextOn("#ffffff")).toBe("#111111");
expect(readableTextOn("#000000")).toBe("#ffffff");
});
it("lists secondary and footer links in both the HTML and the plain-text version", () => {
const email = renderBookingConfirmedEmail({
...appointmentData,
calendarPath: "/api/appointments/abc123/calendar.ics?token=raw",
preferencesPath: "/settings/notifications",
});
expect(email.html).toContain("Add to calendar");
expect(email.html).toContain("View on map");
expect(email.html).toContain("Manage notification preferences");
expect(email.text).toContain("Add to calendar:");
expect(email.text).toContain("google.com/maps");
expect(email.text).toContain("/settings/notifications");
});
it("leaves out the calendar and map links on a cancelled booking, and the preferences link for a guest", () => {
const email = renderBookingCancelledEmail({
...appointmentData,
calendarPath: "/api/appointments/abc123/calendar.ics",
});
expect(email.html).not.toContain("Add to calendar");
expect(email.html).not.toContain("View on map");
expect(email.html).not.toContain("Manage notification preferences");
});
it("uses a fitting button label for the generic template when one is given", () => {
const email = renderGenericNotificationEmail({
business,
recipientName: "Jamie",
subject: "You've been invited",
bodyText: "Marcus invited you.",
deepLinkPath: "/invite/accept?token=abc",
actionLabel: "Accept Invitation",
});
expect(email.html).toContain("Accept Invitation");
expect(email.text).toContain("Accept Invitation:");
});
});
@@ -1,5 +1,6 @@
import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout"; import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared"; import type { EmailDetail, EmailLink } from "./layout";
import { absoluteUrl, footerNoteHtml, footerNoteText, mapUrl } from "./shared";
import type { BusinessEmailInfo, RenderedEmail } from "./shared"; import type { BusinessEmailInfo, RenderedEmail } from "./shared";
export interface AppointmentEmailData { export interface AppointmentEmailData {
@@ -12,31 +13,53 @@ export interface AppointmentEmailData {
durationLabel: string; durationLabel: string;
totalLabel: string; totalLabel: string;
appointmentRef: string; appointmentRef: string;
/** Relative path — e.g. "/appointments/xyz?token=..." for a guest, "/appointments" for a registered customer. */ /** Relative path — e.g. "/appointments/xyz?token=[REDACTED] for a guest, "/appointments" for a registered customer. */
manageAppointmentPath: string; manageAppointmentPath: string;
/** Relative path of the .ics download for this appointment (same guest token as the manage link) — omit to leave the "Add to calendar" link out. */
calendarPath?: string;
/** Relative path of this recipient's notification settings — omit for guests, who have no account to manage. */
preferencesPath?: string;
} }
function renderAppointmentEmail( function renderAppointmentEmail(
data: AppointmentEmailData, data: AppointmentEmailData,
copy: { subject: string; headline: string; intro: string; extraNote?: string; actionLabel?: string }, copy: {
subject: string;
headline: string;
intro: string;
extraNote?: string;
actionLabel?: string;
/** Whether the appointment is still going ahead — a cancelled one gets no calendar link. */
live?: boolean;
},
): RenderedEmail { ): RenderedEmail {
const actionUrl = absoluteUrl(data.manageAppointmentPath); const actionUrl = absoluteUrl(data.manageAppointmentPath);
const actionLabel = copy.actionLabel ?? "View Appointment"; const actionLabel = copy.actionLabel ?? "View Appointment";
const detailsHtml = [ const details: EmailDetail[] = [
detailRow("Service", data.serviceNames.join(", ")), { label: "Service", value: data.serviceNames.join(", ") },
detailRow("Barber", data.staffName), { label: "Barber", value: data.staffName },
detailRow("Date", data.dateLabel), { label: "Date", value: data.dateLabel },
detailRow("Time", data.timeLabel), { label: "Time", value: data.timeLabel },
detailRow("Duration", data.durationLabel), { label: "Duration", value: data.durationLabel },
detailRow("Total", data.totalLabel), { label: "Total", value: data.totalLabel },
detailRow("Reference", data.appointmentRef), { label: "Reference", value: data.appointmentRef },
].join(""); ];
const secondaryLinks: EmailLink[] = [];
if (data.calendarPath && copy.live !== false)
secondaryLinks.push({ label: "Add to calendar", url: absoluteUrl(data.calendarPath) });
if (data.business.address && copy.live !== false)
secondaryLinks.push({ label: "View on map", url: mapUrl(data.business.address) });
const footerLinks: EmailLink[] = data.preferencesPath
? [{ label: "Manage notification preferences", url: absoluteUrl(data.preferencesPath) }]
: [];
const preheader = `${data.dateLabel} at ${data.timeLabel} with ${data.staffName} · ${data.serviceNames.join(", ")}`;
const bodyHtml = [ const bodyHtml = [
paragraph(`Hi ${data.recipientName},`), paragraph(`Hi ${data.recipientName},`),
paragraph(copy.intro), paragraph(copy.intro),
detailsHtml,
copy.extraNote ? paragraph(copy.extraNote) : "", copy.extraNote ? paragraph(copy.extraNote) : "",
].join(""); ].join("");
@@ -45,9 +68,14 @@ function renderAppointmentEmail(
businessLogoUrl: data.business.logoUrl, businessLogoUrl: data.business.logoUrl,
headline: copy.headline, headline: copy.headline,
bodyHtml, bodyHtml,
details,
actionLabel, actionLabel,
actionUrl, actionUrl,
secondaryLinks,
accentColor: data.business.themeColor,
preheader,
footerNote: footerNoteHtml(data.business), footerNote: footerNoteHtml(data.business),
footerLinks,
}); });
const text = renderPlainTextLayout({ const text = renderPlainTextLayout({
@@ -57,19 +85,14 @@ function renderAppointmentEmail(
`Hi ${data.recipientName},`, `Hi ${data.recipientName},`,
"", "",
copy.intro, copy.intro,
"",
`Service: ${data.serviceNames.join(", ")}`,
`Barber: ${data.staffName}`,
`Date: ${data.dateLabel}`,
`Time: ${data.timeLabel}`,
`Duration: ${data.durationLabel}`,
`Total: ${data.totalLabel}`,
`Reference: ${data.appointmentRef}`,
...(copy.extraNote ? ["", copy.extraNote] : []), ...(copy.extraNote ? ["", copy.extraNote] : []),
], ],
details,
actionLabel, actionLabel,
actionUrl, actionUrl,
secondaryLinks,
footerNote: footerNoteText(data.business), footerNote: footerNoteText(data.business),
footerLinks,
}); });
return { subject: copy.subject, html, text }; return { subject: copy.subject, html, text };
@@ -113,6 +136,7 @@ export function renderBookingCancelledEmail(
? `Reason: ${options.reason}` ? `Reason: ${options.reason}`
: undefined, : undefined,
actionLabel: "Book Another Appointment", actionLabel: "Book Another Appointment",
live: false,
}); });
} }
@@ -9,6 +9,10 @@ export interface GenericNotificationEmailData {
/** The Notification's own canonical bodyText (see Notification.bodyText in schema.prisma) — this template is the fallback for any type without a bespoke renderer, so it has no more structured data than that. */ /** The Notification's own canonical bodyText (see Notification.bodyText in schema.prisma) — this template is the fallback for any type without a bespoke renderer, so it has no more structured data than that. */
bodyText: string; bodyText: string;
deepLinkPath?: string; deepLinkPath?: string;
/** Button text — defaults to "View Details"; render-email.ts picks a fitting one per notification type. */
actionLabel?: string;
/** Relative path of the recipient's notification settings, for the footer link. */
preferencesPath?: string;
} }
/** /**
@@ -23,6 +27,10 @@ export interface GenericNotificationEmailData {
export function renderGenericNotificationEmail(data: GenericNotificationEmailData): RenderedEmail { export function renderGenericNotificationEmail(data: GenericNotificationEmailData): RenderedEmail {
const actionUrl = data.deepLinkPath ? absoluteUrl(data.deepLinkPath) : undefined; const actionUrl = data.deepLinkPath ? absoluteUrl(data.deepLinkPath) : undefined;
const bodyHtml = [paragraph(`Hi ${data.recipientName},`), paragraph(data.bodyText)].join(""); const bodyHtml = [paragraph(`Hi ${data.recipientName},`), paragraph(data.bodyText)].join("");
const actionLabel = actionUrl ? (data.actionLabel ?? "View Details") : undefined;
const footerLinks = data.preferencesPath
? [{ label: "Manage notification preferences", url: absoluteUrl(data.preferencesPath) }]
: [];
return { return {
subject: data.subject, subject: data.subject,
@@ -31,17 +39,21 @@ export function renderGenericNotificationEmail(data: GenericNotificationEmailDat
businessLogoUrl: data.business.logoUrl, businessLogoUrl: data.business.logoUrl,
headline: data.subject, headline: data.subject,
bodyHtml, bodyHtml,
actionLabel: actionUrl ? "View Details" : undefined, actionLabel,
actionUrl, actionUrl,
accentColor: data.business.themeColor,
preheader: data.bodyText,
footerNote: footerNoteHtml(data.business), footerNote: footerNoteHtml(data.business),
footerLinks,
}), }),
text: renderPlainTextLayout({ text: renderPlainTextLayout({
businessName: data.business.name, businessName: data.business.name,
headline: data.subject, headline: data.subject,
bodyLines: [`Hi ${data.recipientName},`, "", data.bodyText], bodyLines: [`Hi ${data.recipientName},`, "", data.bodyText],
actionLabel: actionUrl ? "View Details" : undefined, actionLabel,
actionUrl, actionUrl,
footerNote: footerNoteText(data.business), footerNote: footerNoteText(data.business),
footerLinks,
}), }),
}; };
} }
+132 -13
View File
@@ -17,6 +17,17 @@ export function escapeHtml(value: string): string {
.replace(/'/g, "&#39;"); .replace(/'/g, "&#39;");
} }
export interface EmailDetail {
label: string;
value: string;
}
export interface EmailLink {
label: string;
/** Absolute URL. */
url: string;
}
export interface EmailLayoutInput { export interface EmailLayoutInput {
businessName: string; businessName: string;
/** Absolute URL — email clients can't resolve a relative path the way a browser can. Omitted entirely (not a broken `<img>`) if not supplied. */ /** Absolute URL — email clients can't resolve a relative path the way a browser can. Omitted entirely (not a broken `<img>`) if not supplied. */
@@ -24,10 +35,83 @@ export interface EmailLayoutInput {
headline: string; headline: string;
/** Pre-escaped/pre-built HTML fragment for the main content — see the per-type renderers, which build this from escaped pieces. */ /** Pre-escaped/pre-built HTML fragment for the main content — see the per-type renderers, which build this from escaped pieces. */
bodyHtml: string; bodyHtml: string;
/** Label/value rows shown in a shaded card under the body (date, time, barber…). Plain text — escaped here. */
details?: EmailDetail[];
actionLabel?: string; actionLabel?: string;
/** Absolute URL — see NEXT_PUBLIC_APP_URL / renderAbsoluteLink in index.ts. */ /** Absolute URL — see NEXT_PUBLIC_APP_URL / renderAbsoluteLink in index.ts. */
actionUrl?: string; actionUrl?: string;
/** Lower-emphasis text links under the button ("Add to calendar", "View on map"). */
secondaryLinks?: EmailLink[];
/** The business's brand colour (#rrggbb) for the button; anything else falls back to near-black. */
accentColor?: string;
/** The grey line inbox lists show after the subject. Plain text — escaped here. */
preheader?: string;
footerNote?: string; footerNote?: string;
/** Small links in the footer, e.g. "Manage notification preferences". */
footerLinks?: EmailLink[];
}
const DEFAULT_ACCENT = "#171717";
/** `#rrggbb` only — the value ends up in an inline style, so nothing else is allowed through. */
export function normalizeAccentColor(input: string | undefined | null): string {
return input && /^#[0-9a-fA-F]{6}$/.test(input.trim())
? input.trim().toLowerCase()
: DEFAULT_ACCENT;
}
/** Black or white text, whichever reads better on `hex` (WCAG relative luminance). */
export function readableTextOn(hex: string): "#ffffff" | "#111111" {
const channel = (offset: number) => {
const value = parseInt(hex.slice(offset, offset + 2), 16) / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
return luminance > 0.4 ? "#111111" : "#ffffff";
}
/** Dark mode on top of inline light styles: clients that honour prefers-color-scheme swap the palette, the rest just show the light one. The accent button is left alone so it keeps the brand colour in both. */
const STYLE_BLOCK = `<style>
@media (prefers-color-scheme: dark) {
.nc-bg { background:#111111 !important; }
.nc-card { background:#1c1c1e !important; }
.nc-heading { color:#ffffff !important; }
.nc-text { color:#e5e5e5 !important; }
.nc-muted { color:#a3a3a3 !important; }
.nc-details { background:#2a2a2d !important; }
.nc-rule { border-color:#3a3a3c !important; }
.nc-link { color:#e5e5e5 !important; }
.nc-btn { border-color:#8a8a8f !important; }
}
@media only screen and (max-width: 520px) {
.nc-card { padding:24px 20px !important; }
}
</style>`;
function detailsCard(details: EmailDetail[] | undefined): string {
if (!details || details.length === 0) return "";
const rows = details
.map(
(d) => `<tr>
<td class="nc-muted" style="padding:6px 16px 6px 0;font-size:12px;color:#737373;vertical-align:top;white-space:nowrap;">${escapeHtml(d.label)}</td>
<td class="nc-heading" style="padding:6px 0;font-size:14px;font-weight:600;color:#171717;">${escapeHtml(d.value)}</td>
</tr>`,
)
.join("");
return `<tr><td style="padding:8px 0 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="nc-details" style="background:#f5f5f4;border-radius:12px;">
<tr><td style="padding:12px 18px;"><table role="presentation" cellpadding="0" cellspacing="0" width="100%">${rows}</table></td></tr>
</table>
</td></tr>`;
}
function linkList(links: EmailLink[] | undefined, separator: string): string {
return (links ?? [])
.map(
(l) =>
`<a class="nc-link" href="${escapeHtml(l.url)}" style="color:#404040;text-decoration:underline;">${escapeHtml(l.label)}</a>`,
)
.join(separator);
} }
/** /**
@@ -38,32 +122,59 @@ export interface EmailLayoutInput {
* blocked, which is the default in most mail clients). * blocked, which is the default in most mail clients).
*/ */
export function renderEmailLayout(input: EmailLayoutInput): string { export function renderEmailLayout(input: EmailLayoutInput): string {
const accent = normalizeAccentColor(input.accentColor);
const logo = input.businessLogoUrl const logo = input.businessLogoUrl
? `<img src="${escapeHtml(input.businessLogoUrl)}" alt="${escapeHtml(input.businessName)}" height="32" style="height:32px;vertical-align:middle;margin-right:8px;" />` ? `<img src="${escapeHtml(input.businessLogoUrl)}" alt="${escapeHtml(input.businessName)}" height="32" style="height:32px;vertical-align:middle;margin-right:8px;" />`
: ""; : "";
const preheader = input.preheader
? `<div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f5f5f4;">${escapeHtml(input.preheader)}${"&nbsp;&zwnj;".repeat(40)}</div>`
: "";
const action = const action =
input.actionLabel && input.actionUrl input.actionLabel && input.actionUrl
? `<tr><td style="padding:24px 0 0;"> ? `<tr><td style="padding:24px 0 ${input.secondaryLinks && input.secondaryLinks.length > 0 ? 0 : 24}px;">
<a href="${escapeHtml(input.actionUrl)}" style="display:inline-block;background:#171717;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:9999px;font-weight:bold;font-size:14px;">${escapeHtml(input.actionLabel)}</a> <a class="nc-btn" href="${escapeHtml(input.actionUrl)}" style="display:inline-block;background:${accent};border:1px solid ${accent};color:${readableTextOn(accent)};text-decoration:none;padding:13px 26px;border-radius:9999px;font-weight:bold;font-size:14px;">${escapeHtml(input.actionLabel)}</a>
</td></tr>` </td></tr>`
: ""; : "";
const footer = input.footerNote const secondary =
? `<tr><td style="padding-top:24px;border-top:1px solid #e5e5e5;color:#737373;font-size:12px;line-height:1.5;">${input.footerNote}</td></tr>` input.secondaryLinks && input.secondaryLinks.length > 0
? `<tr><td class="nc-muted" style="padding:14px 0 24px;font-size:13px;color:#737373;">${linkList(input.secondaryLinks, " &nbsp;·&nbsp; ")}</td></tr>`
: "";
const footerParts = [
input.footerNote ? `<div>${input.footerNote}</div>` : "",
input.footerLinks && input.footerLinks.length > 0
? `<div style="margin-top:8px;">${linkList(input.footerLinks, " &nbsp;·&nbsp; ")}</div>`
: "",
].join("");
const footer = footerParts
? `<tr><td class="nc-muted nc-rule" style="padding-top:24px;border-top:1px solid #e5e5e5;color:#737373;font-size:12px;line-height:1.5;">${footerParts}</td></tr>`
: ""; : "";
return `<!doctype html> return `<!doctype html>
<html> <html lang="en">
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"> <head>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:24px 0;"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title>${escapeHtml(input.headline)}</title>
${STYLE_BLOCK}
</head>
<body class="nc-bg" style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
${preheader}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="nc-bg" style="background:#f5f5f4;padding:24px 0;">
<tr> <tr>
<td align="center"> <td align="center" style="padding:0 12px;">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;padding:32px;max-width:480px;width:100%;"> <table role="presentation" width="520" cellpadding="0" cellspacing="0" class="nc-card" style="background:#ffffff;border-radius:16px;padding:32px;max-width:520px;width:100%;">
<tr><td style="padding-bottom:16px;font-size:16px;font-weight:bold;color:#171717;">${logo}${escapeHtml(input.businessName)}</td></tr> <tr><td class="nc-heading" style="padding-bottom:16px;font-size:16px;font-weight:bold;color:#171717;">${logo}${escapeHtml(input.businessName)}</td></tr>
<tr><td style="font-size:20px;font-weight:bold;color:#171717;padding-bottom:12px;">${escapeHtml(input.headline)}</td></tr> <tr><td class="nc-heading" style="font-size:22px;line-height:1.3;font-weight:bold;color:#171717;padding-bottom:12px;">${escapeHtml(input.headline)}</td></tr>
<tr><td style="font-size:14px;line-height:1.6;color:#404040;">${input.bodyHtml}</td></tr> <tr><td class="nc-text" style="font-size:14px;line-height:1.6;color:#404040;">${input.bodyHtml}</td></tr>
${detailsCard(input.details)}
${action} ${action}
${secondary}
${footer} ${footer}
</table> </table>
</td> </td>
@@ -77,19 +188,27 @@ export interface PlainTextLayoutInput {
businessName: string; businessName: string;
headline: string; headline: string;
bodyLines: string[]; bodyLines: string[];
details?: EmailDetail[];
actionLabel?: string; actionLabel?: string;
actionUrl?: string; actionUrl?: string;
secondaryLinks?: EmailLink[];
footerNote?: string; footerNote?: string;
footerLinks?: EmailLink[];
} }
export function renderPlainTextLayout(input: PlainTextLayoutInput): string { export function renderPlainTextLayout(input: PlainTextLayoutInput): string {
const lines = [input.businessName, "", input.headline, "", ...input.bodyLines]; const lines = [input.businessName, "", input.headline, "", ...input.bodyLines];
if (input.details && input.details.length > 0) {
lines.push("", ...input.details.map((d) => `${d.label}: ${d.value}`));
}
if (input.actionLabel && input.actionUrl) { if (input.actionLabel && input.actionUrl) {
lines.push("", `${input.actionLabel}: ${input.actionUrl}`); lines.push("", `${input.actionLabel}: ${input.actionUrl}`);
} }
for (const link of input.secondaryLinks ?? []) lines.push(`${link.label}: ${link.url}`);
if (input.footerNote) { if (input.footerNote) {
lines.push("", input.footerNote); lines.push("", input.footerNote);
} }
for (const link of input.footerLinks ?? []) lines.push(`${link.label}: ${link.url}`);
return lines.join("\n"); return lines.join("\n");
} }
@@ -98,7 +217,7 @@ export function paragraph(text: string): string {
return `<p style="margin:0 0 12px;">${escapeHtml(text)}</p>`; return `<p style="margin:0 0 12px;">${escapeHtml(text)}</p>`;
} }
/** A label/value row — "Date: Mon 3 Aug", "Barber: Marcus Reed", etc. */ /** A label/value row — "Date: Mon 3 Aug", "Barber: Marcus Reed", etc. Prefer the `details` card on renderEmailLayout; kept for templates that interleave rows with prose. */
export function detailRow(label: string, value: string): string { export function detailRow(label: string, value: string): string {
return `<p style="margin:0 0 4px;"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`; return `<p style="margin:0 0 4px;"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`;
} }
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more