diff --git a/.env.business.example b/.env.business.example index 159ae45..79f329f 100644 --- a/.env.business.example +++ b/.env.business.example @@ -53,10 +53,14 @@ INSTALLATION_TOKEN="replace-me" # generate with: openssl rand -hex 32. Without something actually calling # those on a schedule, queued notifications (staff invites, reminders, # etc.) never get sent — see the root README's "Notification delivery" -# section and stacks/infra/scripts/process-cron.sh for the one-time -# crontab setup this requires. +# section: the Manager's built-in scheduler normally does this (the host +# crontab script is only a fallback). 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) ── # Platform-level account with every Owner permission for THIS business. # Runs automatically on every container start (see docker/entrypoint.sh) diff --git a/README.md b/README.md index a5a6fbe..9a59336 100644 --- a/README.md +++ b/README.md @@ -138,54 +138,62 @@ this for you), then finish its setup by visiting 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, -and Google Reviews sync are **not sent automatically** — the app only -queues them; something has to actually trigger delivery on a schedule by -calling two `CRON_SECRET`-gated endpoints -(`/api/cron/process-notifications`, `/api/cron/sync-google-reviews` — see -their own route files for the full reasoning). Skip this step and every -business's queued notifications just sit as `PENDING` forever — the UI -still says "an invitation email has been sent" the moment it's queued, -regardless of whether anything has actually delivered it yet. +Staff invitation emails, appointment reminders, push notifications, +loyalty reward emails, WhatsApp-reminder nudges and Google Reviews sync are +**not sent by the app itself** — it only queues them; something has to +trigger delivery on a schedule by calling two `CRON_SECRET`-gated +endpoints (`/api/cron/process-notifications`, +`/api/cron/sync-google-reviews` — see their route files for the reasoning). +Without a trigger, every business's queued notifications sit as `PENDING` +forever, even though the UI says "sent" the moment something is queued. -[stacks/infra/scripts/process-cron.sh](stacks/infra/scripts/process-cron.sh) -calls both endpoints for **every** business under `STACKS_DIR` in one -run, so a single crontab entry covers businesses added later too — no -per-business cron line to maintain. +**The Manager's built-in scheduler does this for you** — nothing to +install on the host. Once the Manager is running it calls every +business's notification cron every 30 seconds (adjustable at Settings → +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 -`cron` at all — `crontab -e` failing with `command not found` means this -first: +Each business's `/api/health` also reports whether notifications are +actually going out (`status: "degraded"` plus the reasons, when the +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 -sudo apt update && sudo apt install -y cron -sudo systemctl enable --now cron -``` - -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 +# Deliver queued notifications every minute (the finest a crontab can do). +* * * * * 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. 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 -than `/opt/nextchair`, or don't have write access to it — `/var/log/` -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 ` -process-notifications ok` for every business. +A healthy run logs ` process-notifications ok` for every business. Use +only one of the two mechanisms at a time as your normal setup. ## Updating after a code change diff --git a/docs/phase-6-summary.md b/docs/phase-6-summary.md index 97c3c20..f6ffd92 100644 --- a/docs/phase-6-summary.md +++ b/docs/phase-6-summary.md @@ -157,10 +157,10 @@ npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres ## 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. - **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. - **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. diff --git a/manager/Dockerfile b/manager/Dockerfile index 9219e98..cdaa204 100644 --- a/manager/Dockerfile +++ b/manager/Dockerfile @@ -30,7 +30,7 @@ ENV NODE_ENV=production # stacks/infra/compose.yaml's manager service (docker.sock). 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 # against node:22-alpine, Alpine 3.24: `apk add 7zip` → 7-Zip 26.01). RUN apk add --no-cache 7zip diff --git a/manager/README.md b/manager/README.md index b1e71db..056acba 100644 --- a/manager/README.md +++ b/manager/README.md @@ -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 released. - **Backups** (`src/lib/backup*.ts`, `restore.ts`; UI at a business's - *Backups* page and the global *Restore* page): an encrypted `.7z` - (`7zz`, AES-256, encrypted headers) of the database dump, media and + *Backups* page and the global *Restore* page): a plain `.7z` + (`7zz`, **not password-protected**) of the database dump, media and config, in `STACKS_DIR/backups//`. Written as `.partial`, verified 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 - backups (before a delete) are kept separately and never evict them. One - passphrase protects everything: generated at the first enable / first - *Back up now*, stored encrypted (`MANAGER_SETTINGS_ENCRYPTION_KEY`), - **shown once** — save it, a fresh server needs it to restore. Restore - works onto an existing business (after a safety backup) or a server that + backups (before a delete) are kept separately and never evict them. + Because there is no password, an archive holds the business's secrets in + the clear (its `.env`: database password, auth secret, SMTP password, + VAPID private key) — files are written owner-only (0600) and the + 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 whose last migration is newer than the app image's is refused. - **Alerts** (`src/lib/health-watcher.ts`): per-business ok / degraded / diff --git a/manager/src/app/api/backups/[slug]/[file]/route.ts b/manager/src/app/api/backups/[slug]/[file]/route.ts index fb38291..c0d9197 100644 --- a/manager/src/app/api/backups/[slug]/[file]/route.ts +++ b/manager/src/app/api/backups/[slug]/[file]/route.ts @@ -6,7 +6,7 @@ import { backupFilePath } from "@/lib/backup-store"; 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 }> }) { const { slug, file } = await params; diff --git a/manager/src/app/api/jobs/[id]/route.ts b/manager/src/app/api/jobs/[id]/route.ts index c2f024b..985172a 100644 --- a/manager/src/app/api/jobs/[id]/route.ts +++ b/manager/src/app/api/jobs/[id]/route.ts @@ -6,8 +6,7 @@ export const dynamic = "force-dynamic"; /** * Polled by the JobProgress component. Behind the Manager login like * 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 - * lives in the running job's closure). + * and a result summary — never secrets. */ export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; diff --git a/manager/src/app/dashboard/[slug]/actions.ts b/manager/src/app/dashboard/[slug]/actions.ts index 390dae4..805ff19 100644 --- a/manager/src/app/dashboard/[slug]/actions.ts +++ b/manager/src/app/dashboard/[slug]/actions.ts @@ -4,7 +4,6 @@ import path from "node:path"; import { revalidatePath } from "next/cache"; import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker"; import { upsertEnvValues } from "@/lib/env-file"; -import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings"; import { setIntentionallyStopped } from "@/lib/health-watcher"; import { fetchInstallationStatus } from "@/lib/installation-status"; import { NO_JOB, type JobStartState } from "@/lib/job-state"; @@ -55,10 +54,8 @@ export async function restartStackAction(slug: string): Promise { export async function updateStackAction(slug: string, backupFirst: boolean): Promise { await assertStack(slug); 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); - return { error: null, jobId, newPassphrase }; + return { error: null, jobId }; } catch (error) { if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message }; return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the update." }; diff --git a/manager/src/app/dashboard/[slug]/backups/actions.ts b/manager/src/app/dashboard/[slug]/backups/actions.ts index dc033ba..f3b5516 100644 --- a/manager/src/app/dashboard/[slug]/backups/actions.ts +++ b/manager/src/app/dashboard/[slug]/backups/actions.ts @@ -3,7 +3,6 @@ import { revalidatePath } from "next/cache"; import { saveBackupSettings, validateBackupSettingsInput, type BackupFrequency } from "@/lib/backup-settings"; import { deleteBackup } from "@/lib/backup-store"; -import { ensureBackupPassphrase, hasBackupPassphrase, rotateBackupPassphrase } from "@/lib/global-settings"; import { NO_JOB, type JobStartState } from "@/lib/job-state"; import { assertNotLocked, BusinessLockedError } from "@/lib/locks"; import { startBackupJob } from "@/lib/operations"; @@ -16,8 +15,6 @@ async function assertBusiness(slug: string): Promise { export interface SaveBackupSettingsState { error: string | null; saved: boolean; - /** Plaintext passphrase — only in the one response that just generated it (first enable anywhere). */ - newPassphrase: string | null; } export async function saveBackupSettingsAction( @@ -35,26 +32,18 @@ export async function saveBackupSettingsAction( keep: Number(formData.get("keep") ?? 2), }; const problem = validateBackupSettingsInput(input); - if (problem) return { error: problem, saved: false, newPassphrase: null }; - - // 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; - } + if (problem) return { error: problem, saved: false }; await saveBackupSettings(slug, input); revalidatePath(`/dashboard/${slug}/backups`); - return { error: null, saved: true, newPassphrase }; + return { error: null, saved: true }; } export async function backupNowAction(slug: string): Promise { await assertBusiness(slug); try { - const newPassphrase = (await hasBackupPassphrase()) ? null : (await ensureBackupPassphrase()).passphrase; const { jobId } = await startBackupJob(slug, "regular"); - return { error: null, jobId, newPassphrase }; + return { error: null, jobId }; } catch (error) { if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message }; 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." }; } } - -/** 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 }; - } -} diff --git a/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx b/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx index 04c47d5..8f68501 100644 --- a/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx +++ b/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx @@ -5,11 +5,9 @@ import { useRouter } from "next/navigation"; import { useActionState, useState, useTransition } from "react"; import type { BackupEntry } from "@/lib/backup-store"; import { JobProgress } from "../../job-progress"; -import { PassphraseNotice } from "../../passphrase-notice"; import { backupNowAction, deleteBackupAction, - rotatePassphraseAction, saveBackupSettingsAction, type SaveBackupSettingsState, } from "./actions"; @@ -27,7 +25,7 @@ interface SettingsView { } 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 { if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; @@ -50,16 +48,12 @@ export function BackupsPanel({ slug, settings, backups, - hasPassphrase, - passphraseCreatedAt, activeJobId, timeZone, }: { slug: string; settings: SettingsView; backups: BackupEntry[]; - hasPassphrase: boolean; - passphraseCreatedAt: string | null; activeJobId: string | null; timeZone: string; }) { @@ -70,8 +64,6 @@ export function BackupsPanel({ const [error, setError] = useState(null); const [jobId, setJobId] = useState(activeJobId); const [jobRunning, setJobRunning] = useState(activeJobId !== null); - const [shownPassphrase, setShownPassphrase] = useState(null); - const passphraseToShow = shownPassphrase ?? saveState.newPassphrase; const backupNow = () => { setError(null); @@ -81,7 +73,6 @@ export function BackupsPanel({ setError(result.error); return; } - if (result.newPassphrase) setShownPassphrase(result.newPassphrase); setJobId(result.jobId); 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 (
- {passphraseToShow && } -

Schedule

@@ -251,17 +225,12 @@ export function BackupsPanel({
-

Passphrase

+

Handle with care

- {hasPassphrase - ? `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.` - : "No passphrase yet — one is generated (and shown once) the first time you enable backups or press Back up now."} + Backups are not password-protected. Each one contains this business's secrets (its .env: database + 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, + keep it somewhere as private as the server itself.

- {hasPassphrase && ( - - )}
); diff --git a/manager/src/app/dashboard/[slug]/backups/page.tsx b/manager/src/app/dashboard/[slug]/backups/page.tsx index 975345a..c2b65dc 100644 --- a/manager/src/app/dashboard/[slug]/backups/page.tsx +++ b/manager/src/app/dashboard/[slug]/backups/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { readBackupSettings } from "@/lib/backup-settings"; import { listBackups } from "@/lib/backup-store"; -import { backupPassphraseCreatedAt, hasBackupPassphrase } from "@/lib/global-settings"; import { getActiveLock } from "@/lib/locks"; import { listBusinessStacks } from "@/lib/stacks"; 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); if (!stack) notFound(); - const [settings, backups, hasPassphrase, passphraseCreatedAt, lock] = await Promise.all([ + const [settings, backups, lock] = await Promise.all([ readBackupSettings(slug), listBackups(slug), - hasBackupPassphrase(), - backupPassphraseCreatedAt(), getActiveLock(slug), ]); @@ -56,8 +53,6 @@ export default async function BackupsPage({ params }: { params: Promise<{ slug: lastMessage: settings.lastMessage, }} backups={backups} - hasPassphrase={hasPassphrase} - passphraseCreatedAt={passphraseCreatedAt} activeJobId={lock?.jobId ?? null} timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone} /> diff --git a/manager/src/app/dashboard/[slug]/business-actions.tsx b/manager/src/app/dashboard/[slug]/business-actions.tsx index 7eabef5..dfb8f62 100644 --- a/manager/src/app/dashboard/[slug]/business-actions.tsx +++ b/manager/src/app/dashboard/[slug]/business-actions.tsx @@ -2,7 +2,6 @@ import { useState, useTransition } from "react"; import { JobProgress } from "../job-progress"; -import { PassphraseNotice } from "../passphrase-notice"; import { fetchLogsAction, restartStackAction, @@ -19,7 +18,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob const [backupFirst, setBackupFirst] = useState(true); const [jobId, setJobId] = useState(activeJobId); const [jobRunning, setJobRunning] = useState(activeJobId !== null); - const [newPassphrase, setNewPassphrase] = useState(null); // Quick actions are refused server-side while a job holds the business; the buttons just mirror that. const busy = isPending || jobRunning; @@ -44,7 +42,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob setError(result.error); return; } - setNewPassphrase(result.newPassphrase); setJobId(result.jobId); setJobRunning(true); setUpdateOpen(false); @@ -139,7 +136,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob {error}

)} - {newPassphrase && } {jobId && setJobRunning(false)} />} {logs !== null && (
{logs}
diff --git a/manager/src/app/dashboard/[slug]/delete/actions.ts b/manager/src/app/dashboard/[slug]/delete/actions.ts index a41b400..4f2d73e 100644 --- a/manager/src/app/dashboard/[slug]/delete/actions.ts +++ b/manager/src/app/dashboard/[slug]/delete/actions.ts @@ -1,6 +1,5 @@ "use server"; -import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings"; import { BusinessLockedError } from "@/lib/locks"; import { startDeleteJob } from "@/lib/operations"; @@ -8,8 +7,6 @@ export interface DeleteBusinessState { error: string | null; /** Set once the delete job has started — the form then follows it with . */ 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 { const confirmation = String(formData.get("confirmation") ?? ""); 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"; try { - const newPassphrase = finalBackup && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null; const { jobId } = await startDeleteJob(slug, finalBackup); - return { error: null, jobId, newPassphrase }; + return { error: null, jobId }; } catch (error) { - if (error instanceof BusinessLockedError) return { error: error.message, jobId: null, newPassphrase: null }; - return { error: error instanceof Error ? error.message : "Failed to delete this business.", 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 }; } } diff --git a/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx b/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx index 40517b3..fcf3462 100644 --- a/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx +++ b/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx @@ -3,10 +3,9 @@ import Link from "next/link"; import { useActionState, useState } from "react"; import { JobProgress } from "../../job-progress"; -import { PassphraseNotice } from "../../passphrase-notice"; 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 }) { const boundAction = deleteBusinessAction.bind(null, slug); @@ -17,7 +16,6 @@ export function DeleteBusinessForm({ slug }: { slug: string }) { if (state.jobId) { return (
- {state.newPassphrase && } Back to businesses diff --git a/manager/src/app/dashboard/passphrase-notice.tsx b/manager/src/app/dashboard/passphrase-notice.tsx deleted file mode 100644 index d9b28d3..0000000 --- a/manager/src/app/dashboard/passphrase-notice.tsx +++ /dev/null @@ -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 ( -
-

Save this backup passphrase now — it won't be shown again.

-

- Every backup is encrypted with it. On a new server you'll need to type it to restore; without it the backups - cannot be opened by anyone. -

-
- {passphrase} - -
-
- ); -} diff --git a/manager/src/app/dashboard/restore/actions.ts b/manager/src/app/dashboard/restore/actions.ts index af91d60..5320b52 100644 --- a/manager/src/app/dashboard/restore/actions.ts +++ b/manager/src/app/dashboard/restore/actions.ts @@ -1,6 +1,5 @@ "use server"; -import { ensureBackupPassphrase, hasBackupPassphrase, readBackupPassphrase } from "@/lib/global-settings"; import { BusinessLockedError } from "@/lib/locks"; import { startRestoreJob } from "@/lib/operations"; import { planRestore, readArchiveManifest, resolveArchivePath, type RestoreSource } from "@/lib/restore"; @@ -30,17 +29,11 @@ export interface InspectRestoreState { preview: RestorePreview | null; } -async function passphraseFor(input: string): Promise { - const passphrase = input.trim() || (await readBackupPassphrase()) || ""; - if (!passphrase) throw new Error("Enter the backup passphrase (this server has no stored one)."); - return passphrase; -} - -/** Opens only the encrypted header (fast) and reports what restoring would do — nothing is changed. A wrong passphrase fails here. */ -export async function inspectRestoreAction(sourceKey: string, passphraseInput: string): Promise { +/** Reads only the manifest (fast) and reports what restoring would do — nothing is changed. */ +export async function inspectRestoreAction(sourceKey: string): Promise { try { const source = parseSourceKey(sourceKey); - const manifest = await readArchiveManifest(resolveArchivePath(source), await passphraseFor(passphraseInput)); + const manifest = await readArchiveManifest(resolveArchivePath(source)); const plan = await planRestore(manifest); return { error: null, @@ -57,11 +50,13 @@ export async function inspectRestoreAction(sourceKey: string, passphraseInput: s }, }; } 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 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 { - 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, }; } @@ -70,20 +65,17 @@ export async function inspectRestoreAction(sourceKey: string, passphraseInput: s export interface StartRestoreState { error: string | null; jobId: string | null; - newPassphrase: string | null; } export async function startRestoreAction( sourceKey: string, - passphraseInput: string, replace: boolean, confirmSlug: string, ): Promise { - const fail = (error: string): StartRestoreState => ({ error, jobId: null, newPassphrase: null }); + const fail = (error: string): StartRestoreState => ({ error, jobId: null }); try { const source = parseSourceKey(sourceKey); - const passphrase = await passphraseFor(passphraseInput); - const manifest = await readArchiveManifest(resolveArchivePath(source), passphrase); + const manifest = await readArchiveManifest(resolveArchivePath(source)); // Re-checked here, not trusted from the preview the browser holds. 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.`); } - // Replacing takes a safety backup, which needs a stored passphrase; mint one (shown once) if this server has none yet. - const newPassphrase = plan.slugExists && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null; - const { jobId } = await startRestoreJob({ source, passphrase, replace }); - return { error: null, jobId, newPassphrase }; + const { jobId } = await startRestoreJob({ source, replace }); + return { error: null, jobId }; } catch (error) { if (error instanceof BusinessLockedError) return fail(error.message); return fail(error instanceof Error ? error.message : "Couldn't start the restore."); diff --git a/manager/src/app/dashboard/restore/page.tsx b/manager/src/app/dashboard/restore/page.tsx index d29181b..4a7d151 100644 --- a/manager/src/app/dashboard/restore/page.tsx +++ b/manager/src/app/dashboard/restore/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; import { listAllBackups } from "@/lib/backup-store"; -import { hasBackupPassphrase } from "@/lib/global-settings"; import { RestoreWizard } from "./restore-wizard"; export const metadata: Metadata = { title: "Restore" }; @@ -9,7 +8,7 @@ export const dynamic = "force-dynamic"; export default async function RestorePage({ searchParams }: { searchParams: Promise<{ slug?: string; file?: string }> }) { const { slug, file } = await searchParams; - const [backups, hasPassphrase] = await Promise.all([listAllBackups(), hasBackupPassphrase()]); + const backups = await listAllBackups(); const preselected = slug && file ? `backup:${slug}:${file}` : null; const configuredMax = Number(process.env.BACKUP_UPLOAD_MAX_BYTES); const maxUploadBytes = configuredMax > 0 ? configuredMax : 4 * 1024 * 1024 * 1024; @@ -23,8 +22,7 @@ export default async function RestorePage({ searchParams }: { searchParams: Prom

Restore a business

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 .7z, enter the passphrase, check what it contains, then - confirm. + backup or upload a .7z, check what it contains, then confirm.

diff --git a/manager/src/app/dashboard/restore/restore-wizard.tsx b/manager/src/app/dashboard/restore/restore-wizard.tsx index 92d7407..4b04c12 100644 --- a/manager/src/app/dashboard/restore/restore-wizard.tsx +++ b/manager/src/app/dashboard/restore/restore-wizard.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { useRef, useState, useTransition } from "react"; import { JobProgress } from "../job-progress"; -import { PassphraseNotice } from "../passphrase-notice"; import { inspectRestoreAction, startRestoreAction, type RestorePreview } from "./actions"; interface BackupOption { @@ -24,12 +23,10 @@ function formatBytes(bytes: number): string { export function RestoreWizard({ backups, preselected, - hasStoredPassphrase, maxUploadBytes, }: { backups: BackupOption[]; preselected: string | null; - hasStoredPassphrase: boolean; maxUploadBytes: number; }) { const [mode, setMode] = useState<"stored" | "upload">(backups.length === 0 ? "upload" : "stored"); @@ -37,14 +34,12 @@ export function RestoreWizard({ const [uploadKey, setUploadKey] = useState(null); const [uploadProgress, setUploadProgress] = useState(null); const [uploadName, setUploadName] = useState(null); - const [passphrase, setPassphrase] = useState(""); const [preview, setPreview] = useState(null); const [error, setError] = useState(null); const [replace, setReplace] = useState(false); const [confirmSlug, setConfirmSlug] = useState(""); const [jobId, setJobId] = useState(null); const [restoredSlug, setRestoredSlug] = useState(null); - const [newPassphrase, setNewPassphrase] = useState(null); const [isPending, startTransition] = useTransition(); const xhrRef = useRef(null); @@ -91,7 +86,7 @@ export function RestoreWizard({ if (!sourceKey) return; resetPreview(); startTransition(async () => { - const result = await inspectRestoreAction(sourceKey, passphrase); + const result = await inspectRestoreAction(sourceKey); if (result.error) setError(result.error); setPreview(result.preview); }); @@ -101,12 +96,11 @@ export function RestoreWizard({ if (!sourceKey || !preview) return; setError(null); startTransition(async () => { - const result = await startRestoreAction(sourceKey, passphrase, replace, confirmSlug); + const result = await startRestoreAction(sourceKey, replace, confirmSlug); if (result.error) { setError(result.error); return; } - setNewPassphrase(result.newPassphrase); setRestoredSlug(preview.slug); setJobId(result.jobId); }); @@ -118,7 +112,6 @@ export function RestoreWizard({ if (jobId) { return (
- {newPassphrase && } {restoredSlug && ( @@ -199,28 +192,13 @@ export function RestoreWizard({
- - { - 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" - />
diff --git a/manager/src/app/dashboard/settings/actions.ts b/manager/src/app/dashboard/settings/actions.ts index 9a56d4a..b418fcf 100644 --- a/manager/src/app/dashboard/settings/actions.ts +++ b/manager/src/app/dashboard/settings/actions.ts @@ -2,6 +2,7 @@ import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-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 { generateVapidKeys, @@ -16,6 +17,7 @@ import { resolvePushSettings, saveEmailSettings, saveGoogleCalendarSettings, + readOperatorSettings, saveOperatorSettings, savePushSubject, saveSuperAdminSettings, @@ -367,6 +369,8 @@ export async function reapplyGoogleCalendarSettingsAction( export interface SaveOperatorSettingsState { error: string | null; 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( @@ -386,7 +390,9 @@ export async function saveOperatorSettingsAction( return { error: "Health check interval must be between 1 and 60 minutes.", saved: false }; } + let previousAlertEmail = ""; try { + previousAlertEmail = (await readOperatorSettings()).alertEmail; await saveOperatorSettings({ alertEmail, alertOnHealth: formData.get("alertOnHealth") === "on", @@ -399,7 +405,13 @@ export async function saveOperatorSettingsAction( } catch (error) { 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 { diff --git a/manager/src/app/dashboard/settings/operator-settings-form.tsx b/manager/src/app/dashboard/settings/operator-settings-form.tsx index fd665a8..48ddcd1 100644 --- a/manager/src/app/dashboard/settings/operator-settings-form.tsx +++ b/manager/src/app/dashboard/settings/operator-settings-form.tsx @@ -109,6 +109,18 @@ export function OperatorSettingsForm({ initial, status }: { initial: OperatorSet

)} {saveState.saved && !saveState.error &&

Saved.

} + {saveState.rollout && saveState.rollout.length > 0 && ( +
+

The alert address was sent to your businesses so they can email you about server errors:

+
    + {saveState.rollout.map((r) => ( +
  • + {r.slug}: {r.ok ? (r.unchanged ? "already set" : "updated and restarted") : `failed — ${r.error}`} +
  • + ))} +
+
+ )}
- +
@@ -283,6 +290,7 @@ export default async function OwnerCalendarPage({ )} hasNotes={!!a.notes} hasAvailableReward={rewardFlags.get(a.customerId) ?? false} + noShowFlagged={noShowStandings.get(a.customerId)?.flagged ?? false} actions={ diff --git a/src/app/(owner)/owner/settings/business/page.tsx b/src/app/(owner)/owner/settings/business/page.tsx index e19d4b2..89acc24 100644 --- a/src/app/(owner)/owner/settings/business/page.tsx +++ b/src/app/(owner)/owner/settings/business/page.tsx @@ -55,6 +55,7 @@ export default async function OwnerBusinessDetailsPage() { cancellationPolicy: business.settings?.cancellationPolicy ?? "", legalNameDisplay: business.settings?.legalNameDisplay ?? "NEXTCHAIR", legalContactEmail: business.settings?.legalContactEmail ?? "NEXTCHAIR", + whatsappReminderLeadHours: business.settings?.whatsappReminderLeadHours ?? 24, }} /> diff --git a/src/app/(owner)/owner/settings/loyalty/page.tsx b/src/app/(owner)/owner/settings/loyalty/page.tsx index e420629..9dbba67 100644 --- a/src/app/(owner)/owner/settings/loyalty/page.tsx +++ b/src/app/(owner)/owner/settings/loyalty/page.tsx @@ -41,8 +41,6 @@ export default async function OwnerLoyaltyConfigPage() { rewardLabel: programme.rewardLabel ?? undefined, maxRewardValue: programme.maxRewardValue?.toString(), freeServiceId: programme.freeServiceId ?? undefined, - autoApplyReward: programme.autoApplyReward, - requiresStaffApproval: programme.requiresStaffApproval, stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined, rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined, earnOnCancelled: programme.earnOnCancelled, @@ -63,8 +61,6 @@ export default async function OwnerLoyaltyConfigPage() { stampsRequired: 10, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS, rewardType: "PERCENTAGE" as const, - autoApplyReward: true, - requiresStaffApproval: false, earnOnCancelled: false, earnOnNoShow: false, cardFont: LoyaltyCardFont.SANS, diff --git a/src/app/(staff)/staff/appointments/[id]/page.tsx b/src/app/(staff)/staff/appointments/[id]/page.tsx index 1c07445..f552366 100644 --- a/src/app/(staff)/staff/appointments/[id]/page.tsx +++ b/src/app/(staff)/staff/appointments/[id]/page.tsx @@ -13,6 +13,8 @@ import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; import { getAppointmentById } from "@/services/scheduling/appointment.service"; import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization"; 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 { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button"; import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button"; @@ -51,6 +53,10 @@ export default async function StaffAppointmentDetailsPage({ throw error; } + const noShow = (await getNoShowStandings(prisma, { businessId: staff.businessId, customerIds: [appointment.customerId] })).get( + appointment.customerId, + ); + const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([ prisma.customerNote.findMany({ where: { customerId: appointment.customerId }, @@ -101,6 +107,7 @@ export default async function StaffAppointmentDetailsPage({ > {appointment.customer.firstName} {appointment.customer.lastName} + {noShow && }

{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}

diff --git a/src/app/(staff)/staff/clients/[id]/page.tsx b/src/app/(staff)/staff/clients/[id]/page.tsx index f0d1322..a69b434 100644 --- a/src/app/(staff)/staff/clients/[id]/page.tsx +++ b/src/app/(staff)/staff/clients/[id]/page.tsx @@ -18,6 +18,9 @@ import { getActingBusinessContext } from "@/lib/business/get-acting-business-con import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service"; import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; 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 { CorrectStampButton } from "@/features/staff/components/correct-stamp-button"; import { CustomerNotes } from "@/features/staff/components/customer-notes"; @@ -89,6 +92,13 @@ export default async function StaffCustomerDetailsPage({ 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 ? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 10 }) : []; @@ -174,8 +184,23 @@ export default async function StaffCustomerDetailsPage({

{customer.isGuest ? "Guest customer" : "Registered customer"}

+
+ {noShowStanding.count > 0 && ( +
+

+ {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."} +

+ {canResetNoShows && } +
+ )}

{customer.email}

diff --git a/src/app/(staff)/staff/clients/page.tsx b/src/app/(staff)/staff/clients/page.tsx index 64d397e..6116f6a 100644 --- a/src/app/(staff)/staff/clients/page.tsx +++ b/src/app/(staff)/staff/clients/page.tsx @@ -12,6 +12,8 @@ import { getUnreadNotificationCount } from "@/services/notifications/notificatio import { clientDirectoryWhere } from "@/services/customer/client-directory"; import { matchesCustomerSearch } from "@/services/customer/customer.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 { cn } from "@/lib/utils/cn"; @@ -49,6 +51,7 @@ export default async function StaffClientsPage({ lastName: true, email: true, phone: true, + noShowResetAt: true, appointments: { where: { businessId: context.businessId }, orderBy: { startAt: "desc" }, @@ -57,6 +60,8 @@ export default async function StaffClientsPage({ }, }); + const noShowThresholds = await getNoShowThresholds(prisma, context.businessId); + const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, { businessId: context.businessId, customerIds: customers.map((c) => c.id), @@ -166,6 +171,10 @@ export default async function StaffClientsPage({ (a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now, ); const loyalty = loyaltySummaries.get(customer.id); + const noShow = evaluateStanding( + countNoShows(customer.appointments, customer.noShowResetAt), + noShowThresholds, + ); return (
-

- {customer.firstName} {customer.lastName} +

+ + {customer.firstName} {customer.lastName} + +

{customer.phone ?? customer.email} diff --git a/src/app/(staff)/staff/page.tsx b/src/app/(staff)/staff/page.tsx index f628019..eae509f 100644 --- a/src/app/(staff)/staff/page.tsx +++ b/src/app/(staff)/staff/page.tsx @@ -24,6 +24,7 @@ import { RemindersLink } from "@/features/staff/components/reminders-link"; import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar"; import { ViewSwitch } from "@/features/staff/components/view-switch"; 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 type { AppointmentStatus } from "@/types/domain"; @@ -87,6 +88,11 @@ export default async function StaffCalendarPage({ customerIds: [...new Set(appointments.map((a) => a.customerId))], }); + const noShowStandings = await getNoShowStandings(prisma, { + businessId: staff.businessId, + customerIds: appointments.map((a) => a.customerId), + }); + const workingWindow = workingHours && !workingHours.isOff && @@ -123,6 +129,7 @@ export default async function StaffCalendarPage({ status: STATUS_MAP[a.status] ?? "confirmed", hasNotes: !!a.notes, hasAvailableReward: rewardFlags.get(a.customerId) ?? false, + noShowFlagged: noShowStandings.get(a.customerId)?.flagged ?? false, })); const currentTimeMinutes = @@ -180,7 +187,7 @@ export default async function StaffCalendarPage({

- +
@@ -213,6 +220,7 @@ export default async function StaffCalendarPage({ formattedTime={formatRange(a.startMinutes, a.endMinutes)} hasNotes={a.hasNotes} hasAvailableReward={a.hasAvailableReward} + noShowFlagged={a.noShowFlagged} actions={ { + 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 staffName = appointment.staff.user.name ?? "your barber"; return { id: appointment.id, + dayLabel, time: appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone }), customerName: `${appointment.customer.firstName} ${appointment.customer.lastName}`.trim(), customerId: appointment.customerId, @@ -76,12 +97,33 @@ export default async function WhatsAppRemindersPage({ return (
- + {!dueNow && } +
+ {dueNow ? ( +

+ Due now: starting within the next {leadHours} hours +

+ ) : ( + + ← Due now + + )} + {dueNow && ( + + Browse by day + + )} +
+ {rows.length > 0 && (

- {toSend} to send · {sentCount} sent + {toSend} to send + {!dueNow && ` · ${sentCount} sent`} {noPhoneCount > 0 && ` · ${noPhoneCount} with no usable phone number`}

)} @@ -89,14 +131,30 @@ export default async function WhatsAppRemindersPage({ {rows.length === 0 ? ( ) : (
    - {rows.map((row) => ( -
  • -

    {row.time}

    + {rows.map((row, index) => ( +
  • +
    +

    {row.time}

    + {dueNow && ( +

    + {index === 0 ? `Next · ${row.dayLabel}` : row.dayLabel} +

    + )} +
    {row.customerName} diff --git a/src/app/(staff)/staff/settings/calendar/page.tsx b/src/app/(staff)/staff/settings/calendar/page.tsx index d817daf..fe27574 100644 --- a/src/app/(staff)/staff/settings/calendar/page.tsx +++ b/src/app/(staff)/staff/settings/calendar/page.tsx @@ -39,6 +39,7 @@ export default async function StaffCalendarSettingsPage() { connected: true, accountEmail: connection.accountEmail, selectedCalendarName: connection.selectedCalendarName, + appCreatedCalendar: connection.appCreatedCalendar, lastSyncAt: connection.lastSyncAt?.toISOString(), lastSyncError: connection.lastSyncError, } diff --git a/src/app/(staff)/staff/settings/notifications/page.tsx b/src/app/(staff)/staff/settings/notifications/page.tsx index daf617f..5eb5c9b 100644 --- a/src/app/(staff)/staff/settings/notifications/page.tsx +++ b/src/app/(staff)/staff/settings/notifications/page.tsx @@ -22,6 +22,7 @@ const STAFF_GROUPS: NotificationPreferenceGroup[] = [ { type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED, label: "Appointment rescheduled" }, { type: NotificationType.STAFF_APPOINTMENT_CANCELLED, label: "Appointment cancelled" }, { 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_INVITATION_RECEIVED, label: "Team invitations" }, ]; diff --git a/src/app/api/cron/process-notifications/route.ts b/src/app/api/cron/process-notifications/route.ts index 651b3fc..06b4406 100644 --- a/src/app/api/cron/process-notifications/route.ts +++ b/src/app/api/cron/process-notifications/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; 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 { expireOverdueRewards } from "@/services/loyalty/loyalty.service"; +import { nudgeStaffWhatsAppReminders } from "@/services/scheduling/whatsapp-reminders.service"; import { getEmailProvider, getPushProvider } from "@/services/notifications/providers"; import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker"; @@ -48,6 +49,11 @@ export async function POST(request: Request) { 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, { emailProvider: getEmailProvider(), pushProvider: getPushProvider(), @@ -55,5 +61,12 @@ export async function POST(request: Request) { // Piggybacks the loyalty reward expiry sweep on the same scheduled trigger // rather than standing up a second cron endpoint for one small job. 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); } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 0ea2d92..8c84b9b 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/db/prisma"; 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 @@ -17,6 +19,11 @@ import { getInstallationState } from "@/lib/installation/installation-state"; * installation token, rather than Manager needing its own authenticated * status endpoint. Deliberately still just a boolean + a timestamp: never * 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() { try { @@ -27,10 +34,34 @@ export async function GET() { 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 = {}; + 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({ - status: "healthy", + status, database: "reachable", installed, installedAt: installedAt ? installedAt.toISOString() : null, + ...queueReport, }); } diff --git a/src/app/api/staff/calendar/google/callback/route.ts b/src/app/api/staff/calendar/google/callback/route.ts index bc0eba7..4fb5b83 100644 --- a/src/app/api/staff/calendar/google/callback/route.ts +++ b/src/app/api/staff/calendar/google/callback/route.ts @@ -40,7 +40,10 @@ export async function GET(request: Request) { if (!session?.user?.id) { 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) { return settingsRedirect("error", "not_staff"); } @@ -57,11 +60,23 @@ export async function GET(request: Request) { try { const provider = new GoogleCalendarProvider(); const tokens = await provider.exchangeCodeForTokens({ code, redirectUri: getRedirectUri() }); - const calendars = await provider.listWritableCalendars(tokens.accessToken); - const primaryCalendar = calendars.find((c) => c.primary) ?? calendars[0]; - if (!primaryCalendar) { - return settingsRedirect("error", "no_writable_calendar"); - } + // The narrow scope can't list a person's calendars (that is the point of it), so events go to a + // calendar this app creates. Reconnecting reuses the one it made before, if it's still there, + // rather than adding another "bookings" calendar every time. + 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({ where: { staffId: staffProfile.id }, @@ -74,6 +89,7 @@ export async function GET(request: Request) { tokenExpiresAt: tokens.expiresAt, selectedCalendarId: primaryCalendar.id, selectedCalendarName: primaryCalendar.name, + appCreatedCalendar: true, }, update: { externalAccountId: tokens.externalAccountId, @@ -83,6 +99,7 @@ export async function GET(request: Request) { tokenExpiresAt: tokens.expiresAt, selectedCalendarId: primaryCalendar.id, selectedCalendarName: primaryCalendar.name, + appCreatedCalendar: true, revokedAt: null, lastSyncError: null, }, diff --git a/src/components/ui/appointment-card.tsx b/src/components/ui/appointment-card.tsx index 6fa1837..ca772d3 100644 --- a/src/components/ui/appointment-card.tsx +++ b/src/components/ui/appointment-card.tsx @@ -1,5 +1,5 @@ import Image from "next/image"; -import { Gift, NotebookText } from "lucide-react"; +import { Gift, NotebookText, UserX } from "lucide-react"; import type { ReactNode } from "react"; import { Card } from "@/components/ui/card"; import { StatusPill } from "@/components/ui/status-pill"; @@ -20,6 +20,8 @@ export interface AppointmentCardProps { hasNotes?: boolean; /** Small loyalty-reward-available indicator — same. */ 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 * component stays agnostic of which actions apply to which status. */ actions?: ReactNode; @@ -35,6 +37,7 @@ export function AppointmentCard({ formattedTime, hasNotes, hasAvailableReward, + noShowFlagged, actions, }: AppointmentCardProps) { return ( @@ -72,6 +75,9 @@ export function AppointmentCard({ aria-label="Reward available" /> )} + {noShowFlagged && ( + + )}

    {personLabel} {person.name} diff --git a/src/features/booking/actions/customer-appointments.ts b/src/features/booking/actions/customer-appointments.ts index c00da8a..d490ad1 100644 --- a/src/features/booking/actions/customer-appointments.ts +++ b/src/features/booking/actions/customer-appointments.ts @@ -86,6 +86,7 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi appointmentId: parsed.data.appointmentId, staffId: parsed.data.staffId, startAt: new Date(parsed.data.startAt), + actorUserId: session?.user?.id, }); if (!result.success) { diff --git a/src/features/booking/actions/draft.ts b/src/features/booking/actions/draft.ts index 10eb1cd..1da27f0 100644 --- a/src/features/booking/actions/draft.ts +++ b/src/features/booking/actions/draft.ts @@ -5,10 +5,11 @@ import { auth } from "@/auth"; import { clearDraft, readDraft, writeDraft } from "@/lib/booking-draft/cookie"; import { guestDetailsSchema } from "@/lib/booking-draft/schema"; import { prisma } from "@/lib/db/prisma"; +import { getNoShowStanding } from "@/services/customer/no-show.service"; import { getAppointmentById } from "@/services/scheduling/appointment.service"; import { validateBookingSlot } from "@/services/scheduling/availability.service"; import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization"; -import { getFirstFriendlyMessage } from "../error-messages"; +import { getFirstFriendlyMessage, getFriendlyMessage } from "../error-messages"; import { preselectServiceSchema, saveBarberTimeStepSchema, @@ -170,6 +171,14 @@ export async function saveDetailsStepAction(input: unknown): Promise = { 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_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_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.", diff --git a/src/features/calendar/actions/calendar.ts b/src/features/calendar/actions/calendar.ts index c4d76e5..d557e75 100644 --- a/src/features/calendar/actions/calendar.ts +++ b/src/features/calendar/actions/calendar.ts @@ -72,6 +72,10 @@ export async function listAvailableCalendarsAction(): Promise )}

    - Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately - from sign-in — you can use a different one if you like. + Sync your confirmed appointments to Google Calendar. We create a calendar for them in your Google account + 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.

    {message &&

    {message}

    } {configured ? ( @@ -108,7 +114,11 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn

    Google Calendar

    Connected as {state.accountEmail}

    -

    Syncing to “{state.selectedCalendarName}”

    +

    + {state.appCreatedCalendar ? "Appointments are added to the calendar " : "Syncing to "}“ + {state.selectedCalendarName}” + {state.appCreatedCalendar && " in your Google account — show or hide it from Google Calendar's sidebar."} +

    {state.lastSyncAt &&

    Last synced {new Date(state.lastSyncAt).toLocaleString()}

    } {state.lastSyncError &&

    Last sync error: {state.lastSyncError}

    }
    @@ -135,9 +145,11 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn - + {!state.appCreatedCalendar && ( + + )} diff --git a/src/features/owner/actions/appointments.ts b/src/features/owner/actions/appointments.ts index c51b79e..27b0651 100644 --- a/src/features/owner/actions/appointments.ts +++ b/src/features/owner/actions/appointments.ts @@ -170,6 +170,7 @@ export async function ownerRescheduleAppointmentAction(params: { appointmentId: params.appointmentId, staffId: params.staffId, startAt: new Date(params.startAt), + actorUserId: owner.userId, }); if (!result.success) { return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this appointment." }; @@ -183,9 +184,13 @@ export async function ownerRescheduleAppointmentAction(params: { entityId: params.appointmentId, metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId }, }); - await notifyBookingBarberChanged(prisma, result.appointment, { - customerSelectedStaff: appointment.staffSelectedByCustomer, - }); + // 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, { + customerSelectedStaff: appointment.staffSelectedByCustomer, + }); + } } await syncAppointmentSafely(prisma, params.appointmentId); diff --git a/src/features/owner/actions/business.ts b/src/features/owner/actions/business.ts index a565e0b..9fceef5 100644 --- a/src/features/owner/actions/business.ts +++ b/src/features/owner/actions/business.ts @@ -13,6 +13,7 @@ import { parseSplashScreens } from "@/lib/business/branding"; import type { SplashScreenEntry } from "@/lib/business/branding"; import { MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage"; import { createMediaAsset } from "@/services/media/media.service"; +import { MAX_WHATSAPP_LEAD_HOURS, validateWhatsAppLeadHours } from "@/services/scheduling/whatsapp-reminders.service"; import { setOpeningHours, updateAppIconSettings, @@ -104,6 +105,10 @@ export async function updateBusinessSettingsAction(settings: BusinessSettingsInp const { staff, error } = await requireBusinessManager(); 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 writeAuditLog(prisma, { diff --git a/src/features/owner/components/booking-rules-form.tsx b/src/features/owner/components/booking-rules-form.tsx index c204a21..da318a7 100644 --- a/src/features/owner/components/booking-rules-form.tsx +++ b/src/features/owner/components/booking-rules-form.tsx @@ -126,6 +126,33 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
+
+

No-shows

+
+

+ 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. +

+ set("noShowFlagThreshold", e.target.value ? Number(e.target.value) : null)} + /> + set("noShowBlockThreshold", e.target.value ? Number(e.target.value) : null)} + /> +
+
+

Customer Options

diff --git a/src/features/owner/components/business-details-form.tsx b/src/features/owner/components/business-details-form.tsx index 6e5f5dd..1fe7c0d 100644 --- a/src/features/owner/components/business-details-form.tsx +++ b/src/features/owner/components/business-details-form.tsx @@ -39,6 +39,7 @@ export interface BusinessDetailsInitial { cancellationPolicy: string; legalNameDisplay: LegalNameDisplay; legalContactEmail: LegalContactEmail; + whatsappReminderLeadHours: number; } export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) { @@ -92,6 +93,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti cancellationPolicy: form.cancellationPolicy || undefined, legalNameDisplay: form.legalNameDisplay, legalContactEmail: form.legalContactEmail, + whatsappReminderLeadHours: form.whatsappReminderLeadHours, }); if (!settingsResult.success) { setError(settingsResult.error ?? "Couldn't save business policies."); @@ -213,6 +215,22 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti value={form.cancellationPolicy} onChange={(e) => set("cancellationPolicy", e.target.value)} /> +
+ +

+ Staff see these on the Reminders list and get a push when some are ready to send. +

+

Your site's{" "} diff --git a/src/features/owner/components/loyalty-config-form.tsx b/src/features/owner/components/loyalty-config-form.tsx index c340bfd..57f5a25 100644 --- a/src/features/owner/components/loyalty-config-form.tsx +++ b/src/features/owner/components/loyalty-config-form.tsx @@ -190,18 +190,6 @@ export function LoyaltyConfigForm({

Rules & Expiry

- set("autoApplyReward", v)} - /> - set("requiresStaffApproval", v)} - /> set("earnOnCancelled", v)} /> set("earnOnNoShow", v)} />
diff --git a/src/features/staff/actions/appointment-actions.ts b/src/features/staff/actions/appointment-actions.ts index 8a42691..b4d519a 100644 --- a/src/features/staff/actions/appointment-actions.ts +++ b/src/features/staff/actions/appointment-actions.ts @@ -155,13 +155,14 @@ export async function rescheduleStaffAppointmentAction(params: { staffId?: string; startAt: string; }): Promise { - const { appointment, error } = await loadOwnAppointment(params.appointmentId); - if (!appointment) return { success: false, error: error ?? "Not found." }; + const { staff, appointment, error } = await loadOwnAppointment(params.appointmentId); + if (!appointment || !staff) return { success: false, error: error ?? "Not found." }; const result = await rescheduleBooking(prisma, { appointmentId: params.appointmentId, staffId: params.staffId, startAt: new Date(params.startAt), + actorUserId: staff.user.id, }); if (!result.success) { return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment." }; diff --git a/src/features/staff/actions/no-show.ts b/src/features/staff/actions/no-show.ts new file mode 100644 index 0000000..0043c1d --- /dev/null +++ b/src/features/staff/actions/no-show.ts @@ -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 { + 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 }; +} diff --git a/src/features/staff/components/day-calendar.tsx b/src/features/staff/components/day-calendar.tsx index 23df8fd..6ec61e5 100644 --- a/src/features/staff/components/day-calendar.tsx +++ b/src/features/staff/components/day-calendar.tsx @@ -41,6 +41,8 @@ export interface CalendarAppointment { status: AppointmentStatus; hasNotes: boolean; hasAvailableReward: boolean; + /** Customer has reached the no-show flag threshold (see no-show.service.ts). */ + noShowFlagged?: boolean; } export interface CalendarBlock { @@ -413,6 +415,9 @@ function EventContent({ event }: { event: CalendarEvent }) { {appointment.hasAvailableReward && ( )} + {appointment.noShowFlagged && ( + + )}
{height >= 44 && (

diff --git a/src/features/staff/components/no-show-badge.tsx b/src/features/staff/components/no-show-badge.tsx new file mode 100644 index 0000000..6735db5 --- /dev/null +++ b/src/features/staff/components/no-show-badge.tsx @@ -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 ( + + + ); +} diff --git a/src/features/staff/components/reminders-link.tsx b/src/features/staff/components/reminders-link.tsx index f213d13..ba6f620 100644 --- a/src/features/staff/components/reminders-link.tsx +++ b/src/features/staff/components/reminders-link.tsx @@ -3,36 +3,28 @@ import Link from "next/link"; import { getBusinessCountry } from "@/lib/business/get-business-country"; import { prisma } from "@/lib/db/prisma"; import { toWhatsAppNumber } from "@/lib/messaging/whatsapp"; -import { addDays, todayIn } from "@/lib/time/business-time"; -import { listReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service"; +import { getWhatsAppLeadHours, listDueReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service"; /** * 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 - * relying on someone remembering to look. + * number due to send right now (starting within the business's reminder + * lead time) — so the job announces itself instead of relying on someone + * remembering to look. */ export async function RemindersLink({ businessId, - timezone, staffId, }: { businessId: string; - timezone: string; /** Narrow to one barber's bookings; omit for the whole shop. */ staffId?: string; }) { + const leadHours = await getWhatsAppLeadHours(prisma, businessId); const [appointments, country] = await Promise.all([ - listReminderCandidates(prisma, { - businessId, - date: addDays(todayIn(timezone), 1), - businessTimezone: timezone, - staffId, - }), + listDueReminderCandidates(prisma, { businessId, leadHours, staffId }), getBusinessCountry(businessId), ]); - const toSend = appointments.filter( - (a) => !a.whatsappReminderSentAt && toWhatsAppNumber(a.customer.phone, country), - ).length; + const toSend = appointments.filter((a) => toWhatsAppNumber(a.customer.phone, country)).length; return ( WhatsApp reminders - {toSend > 0 ? · {toSend} to send tomorrow : null} + {toSend > 0 ? · {toSend} to send now : null}

+

+ Their no-shows stop counting from now. Their history is kept. +

+
+ + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..1482c80 --- /dev/null +++ b/src/instrumentation.ts @@ -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. + } +}; diff --git a/src/lib/auth/otp.service.ts b/src/lib/auth/otp.service.ts index 7c48e1a..777c01b 100644 --- a/src/lib/auth/otp.service.ts +++ b/src/lib/auth/otp.service.ts @@ -2,6 +2,7 @@ import { randomInt } from "node:crypto"; import bcrypt from "bcryptjs"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; 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 // throws under Vitest's plain Node runtime (see reminder-worker.ts's own // established pattern: never call getEmailProvider() from inside a @@ -152,12 +153,12 @@ export async function requestOtp(db: Db, options: RequestOtpOptions): Promise { + 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); + }); +}); diff --git a/src/lib/monitoring/__tests__/report-error.test.ts b/src/lib/monitoring/__tests__/report-error.test.ts new file mode 100644 index 0000000..b1b4d3b --- /dev/null +++ b/src/lib/monitoring/__tests__/report-error.test.ts @@ -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", + ); + }); +}); diff --git a/src/lib/monitoring/alert-limiter.ts b/src/lib/monitoring/alert-limiter.ts new file mode 100644 index 0000000..fe66b23 --- /dev/null +++ b/src/lib/monitoring/alert-limiter.ts @@ -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(); + 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; + }, + }; +} diff --git a/src/lib/monitoring/report-error.ts b/src/lib/monitoring/report-error.ts new file mode 100644 index 0000000..4a3da0e --- /dev/null +++ b/src/lib/monitoring/report-error.ts @@ -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 { + 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: `
${alert.text.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]!)}
`, + text: alert.text, + }); + } catch { + // An alert that can't be sent must never turn into a second error. + } +} diff --git a/src/services/business/business.service.ts b/src/services/business/business.service.ts index 3b53ea2..fcbcb08 100644 --- a/src/services/business/business.service.ts +++ b/src/services/business/business.service.ts @@ -70,6 +70,8 @@ export interface BusinessSettingsInput { legalNameDisplay?: LegalNameDisplay; /** Whose inbox those pages give for legal/privacy requests. */ 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. */ reviewsMinRating?: number | null; reviewsExcludeRatingOnly?: boolean; diff --git a/src/services/calendar/__tests__/google-calendar.provider.test.ts b/src/services/calendar/__tests__/google-calendar.provider.test.ts index d3fe6c8..edecd08 100644 --- a/src/services/calendar/__tests__/google-calendar.provider.test.ts +++ b/src/services/calendar/__tests__/google-calendar.provider.test.ts @@ -5,7 +5,10 @@ import { CalendarProviderError } from "../types"; describe("mapGoogleError", () => { 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". - 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.retryable).toBe(false); @@ -25,7 +28,12 @@ describe("mapGoogleError", () => { }); 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.retryable).toBe(false); @@ -38,12 +46,21 @@ describe("mapGoogleError", () => { }); it("keeps the other mappings", () => { - expect(mapGoogleError(401, { error: { errors: [{ reason: "authError" }] } }).code).toBe("TOKEN_EXPIRED"); - 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(401, { error: { errors: [{ reason: "authError" }] } }).code).toBe( + "TOKEN_EXPIRED", + ); + 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(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"); }); @@ -52,13 +69,19 @@ describe("mapGoogleError", () => { }); 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"); }); }); 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(() => { 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) => - 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 () => { - 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).toMatchObject({ code: "TOKEN_REVOKED", retryable: false }); }); 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( - new GoogleCalendarProvider().deleteEvent({ accessToken: "t", calendarId: "primary", providerEventId: "gone" }), + new GoogleCalendarProvider().deleteEvent({ + accessToken: "t", + calendarId: "primary", + providerEventId: "gone", + }), ).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" }); + }); +}); diff --git a/src/services/calendar/calendar-provider.ts b/src/services/calendar/calendar-provider.ts index 8d76e3e..48f4289 100644 --- a/src/services/calendar/calendar-provider.ts +++ b/src/services/calendar/calendar-provider.ts @@ -21,6 +21,12 @@ export interface CalendarProvider { /** Only ever returns calendars the account can write to — see RemoteCalendarInfo.writable. */ listWritableCalendars(accessToken: string): Promise; + /** Creates a secondary calendar owned by this app in the person's account. */ + createCalendar(params: { accessToken: string; name: string; timeZone: string }): Promise; + + /** True while a calendar this app created earlier still exists. */ + calendarExists(params: { accessToken: string; calendarId: string }): Promise; + createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }>; updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise; diff --git a/src/services/calendar/google-calendar.provider.ts b/src/services/calendar/google-calendar.provider.ts index 483dba1..6ad2c41 100644 --- a/src/services/calendar/google-calendar.provider.ts +++ b/src/services/calendar/google-calendar.provider.ts @@ -8,17 +8,22 @@ import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteC * existing pattern of building directly against a documented REST * contract rather than adding a heavy client library (see * brevo-email-provider.ts's own header for the same reasoning applied to - * email). Minimum scopes: `calendar.events` (create/update/delete events) - * and `calendar.readonly` (list calendars to choose a destination) — - * deliberately not the full `calendar` scope, which also grants calendar - * *settings* management this app never needs. + * email). One calendar scope only: `calendar.app.created` — create a + * secondary calendar and read/write events on calendars THIS APP created, + * and nothing else. It cannot see or touch a person's existing calendars or + * 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 " 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 * OAuth credentials were available to test against. See * 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 { 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 })); } + /** Creates the secondary calendar this app writes events to. Needs only `calendar.app.created`. */ + async createCalendar(params: { accessToken: string; name: string; timeZone: string }): Promise { + 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 { + 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 }> { const response = await googleFetch( `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events`, diff --git a/src/services/calendar/mock-calendar.provider.ts b/src/services/calendar/mock-calendar.provider.ts index 76919d3..62a76b0 100644 --- a/src/services/calendar/mock-calendar.provider.ts +++ b/src/services/calendar/mock-calendar.provider.ts @@ -37,6 +37,16 @@ export class MockCalendarProvider implements CalendarProvider { return this.calendars; } + async createCalendar(params: { name: string }): Promise { + 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 { + return this.calendars.some((c) => c.id === params.calendarId); + } + async createEvent(params: { event: CalendarEventInput }): Promise<{ providerEventId: string }> { const id = `mock-event-${this.nextEventId++}`; this.events.set(id, params.event); diff --git a/src/services/customer/__tests__/no-show.integration.test.ts b/src/services/customer/__tests__/no-show.integration.test.ts new file mode 100644 index 0000000..5af0072 --- /dev/null +++ b/src/services/customer/__tests__/no-show.integration.test.ts @@ -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 }); + }); +}); diff --git a/src/services/customer/__tests__/no-show.service.test.ts b/src/services/customer/__tests__/no-show.service.test.ts new file mode 100644 index 0000000..2b22fdd --- /dev/null +++ b/src/services/customer/__tests__/no-show.service.test.ts @@ -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"); + }); +}); diff --git a/src/services/customer/no-show.service.ts b/src/services/customer/no-show.service.ts new file mode 100644 index 0000000..11707c3 --- /dev/null +++ b/src/services/customer/no-show.service.ts @@ -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 { + 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(); + const phones = new Set(); + 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 { + 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( + (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> { + const standings = new Map(); + 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(); + 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 { + 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; +} diff --git a/src/services/loyalty/__tests__/loyalty-config.test.ts b/src/services/loyalty/__tests__/loyalty-config.test.ts index 07b6e5a..bd9bb64 100644 --- a/src/services/loyalty/__tests__/loyalty-config.test.ts +++ b/src/services/loyalty/__tests__/loyalty-config.test.ts @@ -9,8 +9,6 @@ const base: ProgrammeConfigInput = { rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS, rewardType: "PERCENTAGE", rewardValue: "50", - autoApplyReward: true, - requiresStaffApproval: false, earnOnCancelled: false, earnOnNoShow: false, cardFont: LoyaltyCardFont.SANS, diff --git a/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts b/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts index 4b2fe7c..874e67f 100644 --- a/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts +++ b/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts @@ -36,8 +36,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)", rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS, rewardType: "PERCENTAGE", rewardValue: "25", - autoApplyReward: true, - requiresStaffApproval: false, earnOnCancelled: false, earnOnNoShow: false, cardFont: LoyaltyCardFont.SANS, @@ -58,8 +56,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)", rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS, rewardType: "PERCENTAGE", rewardValue: "40", - autoApplyReward: true, - requiresStaffApproval: false, earnOnCancelled: false, earnOnNoShow: false, cardFont: LoyaltyCardFont.SANS, @@ -87,8 +83,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)", rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS, rewardType: "FREE_SERVICE", freeServiceId: inactiveService.id, - autoApplyReward: true, - requiresStaffApproval: false, earnOnCancelled: false, earnOnNoShow: false, cardFont: LoyaltyCardFont.SANS, @@ -342,21 +336,18 @@ describe("LoyaltyService — owner configuration and redemption (integration)", 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)); - await expireOverdueRewards(prisma); // 5 days out → the "7d" 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.8 * 24 * 60 * 60 * 1000)); // still the last day — no third + await expireOverdueRewards(prisma); // 5 days out → the one 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 second const notifications = await prisma.notification.findMany({ where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id }, select: { idempotencyKey: true }, }); - expect(notifications.map((n) => n.idempotencyKey).sort()).toEqual([ - `notify:loyalty-reward-expiring:${reward.id}:1d`, - `notify:loyalty-reward-expiring:${reward.id}:7d`, - ]); + expect(notifications.map((n) => n.idempotencyKey)).toEqual([`notify:loyalty-reward-expiring:${reward.id}:7d`]); }); }); }); diff --git a/src/services/loyalty/loyalty.service.ts b/src/services/loyalty/loyalty.service.ts index f37d062..84f6b72 100644 --- a/src/services/loyalty/loyalty.service.ts +++ b/src/services/loyalty/loyalty.service.ts @@ -549,8 +549,6 @@ export interface ProgrammeConfigInput { rewardLabel?: string; maxRewardValue?: string; freeServiceId?: string; - autoApplyReward: boolean; - requiresStaffApproval: boolean; stampsExpireAfterDays?: number; rewardExpiresAfterDays?: number; earnOnCancelled: boolean; @@ -630,8 +628,6 @@ export async function updateProgrammeConfig( rewardLabel: params.input.rewardLabel?.trim() || null, maxRewardValue: params.input.maxRewardValue || 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, rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null, 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 * directly, but the sweep is what makes "Expired" actually show up in * history/UI instead of staying stuck at AVAILABLE forever), then warn - * about rewards still AVAILABLE and expiring within the next week — twice - * per reward at most (7 days out, then the last day; see - * notifyLoyaltyRewardExpiring), not on every sweep. + * about rewards still AVAILABLE and expiring within the next week — once + * per reward (see notifyLoyaltyRewardExpiring), not on every sweep. A second + * "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 { await prisma.loyaltyReward.updateMany({ @@ -890,7 +886,7 @@ export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new rewardId: reward.id, rewardLabel: reward.label, expiresAt: reward.expiresAt, - bucket: reward.expiresAt.getTime() - now.getTime() <= 86_400_000 ? "1d" : "7d", + bucket: "7d", }); } } diff --git a/src/services/notifications/__tests__/notification.service.integration.test.ts b/src/services/notifications/__tests__/notification.service.integration.test.ts index 3cee1b0..e738ac7 100644 --- a/src/services/notifications/__tests__/notification.service.integration.test.ts +++ b/src/services/notifications/__tests__/notification.service.integration.test.ts @@ -231,15 +231,9 @@ describe("NotificationService (integration)", () => { 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 disabledByDefault = [ - NotificationType.STAFF_NEW_APPOINTMENT, - NotificationType.STAFF_APPOINTMENT_RESCHEDULED, - NotificationType.OWNER_NEW_BOOKING, - NotificationType.OWNER_NO_SHOW, - ]; - for (const type of disabledByDefault) { + for (const type of [NotificationType.OWNER_NEW_BOOKING, NotificationType.OWNER_NO_SHOW]) { const { notification } = await notify(prisma, { businessId: business.businessId, 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 () => { const { userId } = await createTestCustomer(business); const params = { diff --git a/src/services/notifications/__tests__/queue-health.integration.test.ts b/src/services/notifications/__tests__/queue-health.integration.test.ts new file mode 100644 index 0000000..4a4f12c --- /dev/null +++ b/src/services/notifications/__tests__/queue-health.integration.test.ts @@ -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", + ); + }); +}); diff --git a/src/services/notifications/__tests__/queue-health.test.ts b/src/services/notifications/__tests__/queue-health.test.ts new file mode 100644 index 0000000..a70e907 --- /dev/null +++ b/src/services/notifications/__tests__/queue-health.test.ts @@ -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[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); + } + }); +}); diff --git a/src/services/notifications/__tests__/types.test.ts b/src/services/notifications/__tests__/types.test.ts index 6cc7fae..4939f6f 100644 --- a/src/services/notifications/__tests__/types.test.ts +++ b/src/services/notifications/__tests__/types.test.ts @@ -53,13 +53,33 @@ describe("defaultPreferenceIsEnabled", () => { 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 [ NotificationType.STAFF_NEW_APPOINTMENT, NotificationType.STAFF_APPOINTMENT_RESCHEDULED, - NotificationType.OWNER_NEW_BOOKING, - NotificationType.OWNER_NO_SHOW, + NotificationType.STAFF_APPOINTMENT_REMINDER, + 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); } }); diff --git a/src/services/notifications/failure-reasons.ts b/src/services/notifications/failure-reasons.ts new file mode 100644 index 0000000..c1bdb9e --- /dev/null +++ b/src/services/notifications/failure-reasons.ts @@ -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, + ); +} diff --git a/src/services/notifications/providers/brevo-email-provider.ts b/src/services/notifications/providers/brevo-email-provider.ts index 52b83c8..90ee9ee 100644 --- a/src/services/notifications/providers/brevo-email-provider.ts +++ b/src/services/notifications/providers/brevo-email-provider.ts @@ -153,7 +153,7 @@ export class BrevoEmailProvider implements EmailProvider { // display name comes from the caller. from: { address: this.fromAddress, name: params.sender.name }, to: { address: params.to.email, name: params.to.name }, - replyTo: this.replyTo, + replyTo: params.replyTo || this.replyTo, subject: params.subject, html: params.html, text: params.text, diff --git a/src/services/notifications/providers/email-provider.ts b/src/services/notifications/providers/email-provider.ts index 6feea88..f34001b 100644 --- a/src/services/notifications/providers/email-provider.ts +++ b/src/services/notifications/providers/email-provider.ts @@ -32,6 +32,12 @@ export interface SendEmailParams { /** The resolved business name (or fallback) to show as the visible sender — see render-email.ts. */ sender: EmailSender; 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; /** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */ text: string; diff --git a/src/services/notifications/queue-health.ts b/src/services/notifications/queue-health.ts new file mode 100644 index 0000000..2536313 --- /dev/null +++ b/src/services/notifications/queue-health.ts @@ -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 { + 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 }; +} diff --git a/src/services/notifications/reminder-worker.ts b/src/services/notifications/reminder-worker.ts index 52fa4a4..72dd4e1 100644 --- a/src/services/notifications/reminder-worker.ts +++ b/src/services/notifications/reminder-worker.ts @@ -1,5 +1,6 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums"; +import { isSystemicFailureReason } from "./failure-reasons"; import { notifySafely, resolveActivePushSubscriptions, resolveEmailAddress } from "./notification.service"; import type { EmailProvider } from "./providers/email-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. */ 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 { claimed: number; sent: number; @@ -130,6 +134,17 @@ export async function processDueNotificationDeliveries( const now = params.now ?? new Date(); 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({ where: { channel: { in: [NotificationChannel.EMAIL, NotificationChannel.PUSH] }, @@ -152,6 +167,8 @@ export async function processDueNotificationDeliveries( inAppReleased: 0, }; const failuresByBusiness = new Map(); + const systemicFailureBusinesses = new Set(); + const inApp = await releaseDueInAppReminders(prisma, now); result.inAppReleased = inApp.released; @@ -230,11 +247,12 @@ export async function processDueNotificationDeliveries( delivery.notification.businessId, (failuresByBusiness.get(delivery.notification.businessId) ?? 0) + 1, ); + if (isSystemicFailureReason(outcome.reason)) systemicFailureBusinesses.add(delivery.notification.businessId); } } for (const [businessId, count] of failuresByBusiness) { - if (count >= REPEATED_FAILURE_THRESHOLD) { + if (count >= REPEATED_FAILURE_THRESHOLD || systemicFailureBusinesses.has(businessId)) { await raiseRepeatedFailureNotice(prisma, businessId, now); } } @@ -266,6 +284,7 @@ async function processEmailDelivery(prisma: PrismaClient, delivery: DueDelivery, const sendResult = await emailProvider.sendEmail({ to: { email: recipientEmail }, sender: email.sender, + replyTo: email.replyTo, subject: email.subject, html: email.html, text: email.text, @@ -353,7 +372,7 @@ async function raiseRepeatedFailureNotice(prisma: PrismaClient, businessId: stri recipientUserId: ownerUserId, subject: "Some notifications failed to send", 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}`, }); } diff --git a/src/services/notifications/render-email.ts b/src/services/notifications/render-email.ts index 7fc7710..991daa0 100644 --- a/src/services/notifications/render-email.ts +++ b/src/services/notifications/render-email.ts @@ -1,5 +1,6 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { NotificationType } from "@/generated/prisma/enums"; +import { recipientKindOf } from "./types"; import { renderBookingCancelledEmail, renderBookingConfirmedEmail, @@ -21,6 +22,44 @@ import type { EmailSender } from "./providers/email-provider"; export interface RenderedEmailWithSender extends RenderedEmail { /** Resolved fresh on every call from the notification's own business — see resolveSenderName. */ 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> = { + 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; 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 = { name: business.name, @@ -70,11 +111,12 @@ export async function renderEmailForNotification( : undefined, phone: business.phone ?? undefined, cancellationPolicy: business.settings?.cancellationPolicy ?? undefined, + themeColor: business.themeColor ?? undefined, }; const recipientName = await resolveRecipientName(prisma, notification); const rendered = await renderBody(); - return { ...rendered, sender }; + return { ...rendered, sender, replyTo }; async function renderBody(): Promise { switch (notification.type) { @@ -83,11 +125,19 @@ export async function renderEmailForNotification( case NotificationType.BOOKING_RESCHEDULED: case NotificationType.BOOKING_CANCELLED: case NotificationType.BOOKING_REMINDER: { - const data = await buildAppointmentEmailData(prisma, notification, { - business: businessInfo, - recipientName, - manageAppointmentPath: notification.deepLinkPath ?? "/appointments", - }); + const manageAppointmentPath = notification.deepLinkPath ?? "/appointments"; + const data = await buildAppointmentEmailData( + prisma, + notification, + { + business: businessInfo, + recipientName, + manageAppointmentPath, + calendarPath: notification.appointmentId ? calendarPathFor(notification.appointmentId, manageAppointmentPath) : undefined, + preferencesPath: preferencesPathFor(notification), + }, + format, + ); if (!data) return renderFallback(); if (notification.type === NotificationType.BOOKING_CONFIRMED) return renderBookingConfirmedEmail(data); @@ -123,6 +173,8 @@ export async function renderEmailForNotification( subject: notification.subject ?? businessInfo.name, bodyText: notification.bodyText, deepLinkPath: notification.deepLinkPath ?? undefined, + actionLabel: GENERIC_ACTION_LABEL[notification.type], + preferencesPath: preferencesPathFor(notification), }); } } @@ -152,7 +204,11 @@ async function resolveRecipientName( async function buildAppointmentEmailData( prisma: PrismaClient, notification: { appointmentId: string | null }, - base: Pick, + base: Pick< + AppointmentEmailData, + "business" | "recipientName" | "manageAppointmentPath" | "calendarPath" | "preferencesPath" + >, + format: { timezone: string; currency: string; locale: string }, ): Promise { if (!notification.appointmentId) return null; const appointment = await prisma.appointment.findUnique({ @@ -174,17 +230,21 @@ async function buildAppointmentEmailData( ...base, staffName: appointment.staff.user.name ?? "your barber", 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", { weekday: "short", day: "numeric", month: "short", + timeZone: format.timezone, }), timeLabel: appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", + timeZone: format.timezone, }), durationLabel: `${totalMinutes} min`, - totalLabel: `£${totalPrice.toFixed(2)}`, + totalLabel: formatMoney(totalPrice, format), appointmentRef: appointment.id.slice(-8), }; } @@ -214,3 +274,12 @@ async function buildLoyaltyRewardEmailData( 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)}`; + } +} diff --git a/src/services/notifications/templates/__tests__/templates.test.ts b/src/services/notifications/templates/__tests__/templates.test.ts index e2a3069..11b69c1 100644 --- a/src/services/notifications/templates/__tests__/templates.test.ts +++ b/src/services/notifications/templates/__tests__/templates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { escapeHtml } from "../layout"; +import { escapeHtml, normalizeAccentColor, readableTextOn, renderEmailLayout } from "../layout"; import { renderBookingCancelledEmail, renderBookingConfirmedEmail, @@ -9,9 +9,11 @@ import { } from "../booking-emails"; import type { AppointmentEmailData } from "../booking-emails"; import { renderGenericNotificationEmail } from "../generic-email"; -import { renderLoyaltyRewardEarnedEmail, renderLoyaltyRewardRedeemedEmail } from "../loyalty-emails"; +import { + renderLoyaltyRewardEarnedEmail, + renderLoyaltyRewardRedeemedEmail, +} from "../loyalty-emails"; import type { LoyaltyRewardEmailData } from "../loyalty-emails"; -import { renderStaffInvitationEmail, renderStaffScheduleChangedEmail } from "../staff-emails"; describe("escapeHtml", () => { 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 = { business, @@ -66,7 +72,9 @@ describe("booking email templates", () => { }); it("renderBookingCancelledEmail escapes a user-supplied cancellation reason", () => { - const email = renderBookingCancelledEmail(appointmentData, { reason: `` }); + const email = renderBookingCancelledEmail(appointmentData, { + reason: ``, + }); expect(email.html).not.toContain(" { }); }); -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", () => { it("renders the notification's own subject/bodyText when no bespoke template exists", () => { const email = renderGenericNotificationEmail({ @@ -148,3 +130,97 @@ describe("generic fallback template", () => { expect(email.html).not.toContain("View Details"); }); }); + +describe("email layout", () => { + const layout = (overrides: Partial[0]> = {}) => + renderEmailLayout({ + businessName: "Blake Heritage", + headline: "Hello", + bodyHtml: "

Body

", + ...overrides, + }); + + it("is a mobile-friendly, dark-mode-aware document", () => { + const html = layout(); + expect(html).toContain(''); + expect(html).toContain('name="viewport"'); + expect(html).toContain('name="color-scheme" content="light dark"'); + expect(html).toContain("prefers-color-scheme: dark"); + expect(html).toContain("Hello"); + }); + + it("shows the preheader as hidden inbox preview text, escaped", () => { + const html = layout({ preheader: "Mon 3 Aug 16:30" }); + expect(html).toContain("display:none"); + expect(html).toContain("Mon 3 Aug <at> 16:30"); + }); + + it("renders details in a card and escapes their values", () => { + const html = layout({ details: [{ label: "Barber", value: "Marcus " }] }); + expect(html).toContain("Barber"); + expect(html).toContain("Marcus <Reed>"); + expect(html).not.toContain("Marcus "); + }); + + 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:"); + }); +}); diff --git a/src/services/notifications/templates/booking-emails.ts b/src/services/notifications/templates/booking-emails.ts index 7437291..5462a9c 100644 --- a/src/services/notifications/templates/booking-emails.ts +++ b/src/services/notifications/templates/booking-emails.ts @@ -1,5 +1,6 @@ -import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout"; -import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared"; +import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout"; +import type { EmailDetail, EmailLink } from "./layout"; +import { absoluteUrl, footerNoteHtml, footerNoteText, mapUrl } from "./shared"; import type { BusinessEmailInfo, RenderedEmail } from "./shared"; export interface AppointmentEmailData { @@ -12,31 +13,53 @@ export interface AppointmentEmailData { durationLabel: string; totalLabel: 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; + /** 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( 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 { const actionUrl = absoluteUrl(data.manageAppointmentPath); const actionLabel = copy.actionLabel ?? "View Appointment"; - const detailsHtml = [ - detailRow("Service", data.serviceNames.join(", ")), - detailRow("Barber", data.staffName), - detailRow("Date", data.dateLabel), - detailRow("Time", data.timeLabel), - detailRow("Duration", data.durationLabel), - detailRow("Total", data.totalLabel), - detailRow("Reference", data.appointmentRef), - ].join(""); + const details: EmailDetail[] = [ + { label: "Service", value: data.serviceNames.join(", ") }, + { label: "Barber", value: data.staffName }, + { label: "Date", value: data.dateLabel }, + { label: "Time", value: data.timeLabel }, + { label: "Duration", value: data.durationLabel }, + { label: "Total", value: data.totalLabel }, + { label: "Reference", value: data.appointmentRef }, + ]; + 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 = [ paragraph(`Hi ${data.recipientName},`), paragraph(copy.intro), - detailsHtml, copy.extraNote ? paragraph(copy.extraNote) : "", ].join(""); @@ -45,9 +68,14 @@ function renderAppointmentEmail( businessLogoUrl: data.business.logoUrl, headline: copy.headline, bodyHtml, + details, actionLabel, actionUrl, + secondaryLinks, + accentColor: data.business.themeColor, + preheader, footerNote: footerNoteHtml(data.business), + footerLinks, }); const text = renderPlainTextLayout({ @@ -57,19 +85,14 @@ function renderAppointmentEmail( `Hi ${data.recipientName},`, "", 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] : []), ], + details, actionLabel, actionUrl, + secondaryLinks, footerNote: footerNoteText(data.business), + footerLinks, }); return { subject: copy.subject, html, text }; @@ -113,6 +136,7 @@ export function renderBookingCancelledEmail( ? `Reason: ${options.reason}` : undefined, actionLabel: "Book Another Appointment", + live: false, }); } diff --git a/src/services/notifications/templates/generic-email.ts b/src/services/notifications/templates/generic-email.ts index 6138c73..dd56785 100644 --- a/src/services/notifications/templates/generic-email.ts +++ b/src/services/notifications/templates/generic-email.ts @@ -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. */ bodyText: 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 { const actionUrl = data.deepLinkPath ? absoluteUrl(data.deepLinkPath) : undefined; 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 { subject: data.subject, @@ -31,17 +39,21 @@ export function renderGenericNotificationEmail(data: GenericNotificationEmailDat businessLogoUrl: data.business.logoUrl, headline: data.subject, bodyHtml, - actionLabel: actionUrl ? "View Details" : undefined, + actionLabel, actionUrl, + accentColor: data.business.themeColor, + preheader: data.bodyText, footerNote: footerNoteHtml(data.business), + footerLinks, }), text: renderPlainTextLayout({ businessName: data.business.name, headline: data.subject, bodyLines: [`Hi ${data.recipientName},`, "", data.bodyText], - actionLabel: actionUrl ? "View Details" : undefined, + actionLabel, actionUrl, footerNote: footerNoteText(data.business), + footerLinks, }), }; } diff --git a/src/services/notifications/templates/layout.ts b/src/services/notifications/templates/layout.ts index f5ea99b..b608f8a 100644 --- a/src/services/notifications/templates/layout.ts +++ b/src/services/notifications/templates/layout.ts @@ -17,6 +17,17 @@ export function escapeHtml(value: string): string { .replace(/'/g, "'"); } +export interface EmailDetail { + label: string; + value: string; +} + +export interface EmailLink { + label: string; + /** Absolute URL. */ + url: string; +} + export interface EmailLayoutInput { businessName: string; /** Absolute URL — email clients can't resolve a relative path the way a browser can. Omitted entirely (not a broken ``) if not supplied. */ @@ -24,10 +35,83 @@ export interface EmailLayoutInput { headline: string; /** Pre-escaped/pre-built HTML fragment for the main content — see the per-type renderers, which build this from escaped pieces. */ bodyHtml: string; + /** Label/value rows shown in a shaded card under the body (date, time, barber…). Plain text — escaped here. */ + details?: EmailDetail[]; actionLabel?: string; /** Absolute URL — see NEXT_PUBLIC_APP_URL / renderAbsoluteLink in index.ts. */ 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; + /** 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 = ``; + +function detailsCard(details: EmailDetail[] | undefined): string { + if (!details || details.length === 0) return ""; + const rows = details + .map( + (d) => ` + ${escapeHtml(d.label)} + ${escapeHtml(d.value)} + `, + ) + .join(""); + return ` + + + + `; +} + +function linkList(links: EmailLink[] | undefined, separator: string): string { + return (links ?? []) + .map( + (l) => + `${escapeHtml(l.label)}`, + ) + .join(separator); } /** @@ -38,32 +122,59 @@ export interface EmailLayoutInput { * blocked, which is the default in most mail clients). */ export function renderEmailLayout(input: EmailLayoutInput): string { + const accent = normalizeAccentColor(input.accentColor); const logo = input.businessLogoUrl ? `${escapeHtml(input.businessName)}` : ""; + const preheader = input.preheader + ? `
${escapeHtml(input.preheader)}${" ‌".repeat(40)}
` + : ""; + const action = input.actionLabel && input.actionUrl - ? ` - ${escapeHtml(input.actionLabel)} + ? ` + ${escapeHtml(input.actionLabel)} ` : ""; - const footer = input.footerNote - ? `${input.footerNote}` + const secondary = + input.secondaryLinks && input.secondaryLinks.length > 0 + ? `${linkList(input.secondaryLinks, "  ·  ")}` + : ""; + + const footerParts = [ + input.footerNote ? `
${input.footerNote}
` : "", + input.footerLinks && input.footerLinks.length > 0 + ? `
${linkList(input.footerLinks, "  ·  ")}
` + : "", + ].join(""); + const footer = footerParts + ? `${footerParts}` : ""; return ` - - - + + + + + + + ${escapeHtml(input.headline)} + ${STYLE_BLOCK} + + + ${preheader} +
-