From 68a62e02a4c8786eaf1b5111f0386e8631204487 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Sat, 17 Oct 2026 23:10:18 +0100 Subject: [PATCH] Remaining features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built No-show flag and block: Two new numbers under Booking Rules (blank means off). A symbol appears on the clients list, client page, calendar entries and appointment pages. Online booking is refused at the block number, and customers are told at the details step. Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button. The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile. Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning. Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email. WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting. Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business. Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “ bookings”. --- .env.business.example | 8 +- README.md | 84 ++++--- docs/phase-6-summary.md | 4 +- manager/Dockerfile | 2 +- manager/README.md | 15 +- .../app/api/backups/[slug]/[file]/route.ts | 2 +- manager/src/app/api/jobs/[id]/route.ts | 3 +- manager/src/app/dashboard/[slug]/actions.ts | 5 +- .../app/dashboard/[slug]/backups/actions.ts | 26 +- .../[slug]/backups/backups-panel.tsx | 41 +-- .../src/app/dashboard/[slug]/backups/page.tsx | 7 +- .../app/dashboard/[slug]/business-actions.tsx | 4 - .../app/dashboard/[slug]/delete/actions.ts | 12 +- .../[slug]/delete/delete-business-form.tsx | 4 +- .../src/app/dashboard/passphrase-notice.tsx | 33 --- manager/src/app/dashboard/restore/actions.ts | 34 +-- manager/src/app/dashboard/restore/page.tsx | 7 +- .../app/dashboard/restore/restore-wizard.tsx | 30 +-- manager/src/app/dashboard/settings/actions.ts | 14 +- .../settings/operator-settings-form.tsx | 12 + manager/src/lib/__tests__/backup.test.ts | 21 +- .../src/lib/__tests__/docker-commands.test.ts | 29 +-- manager/src/lib/__tests__/operations.test.ts | 3 - .../lib/__tests__/operator-rollout.test.ts | 79 ++++++ manager/src/lib/__tests__/restore.test.ts | 36 ++- manager/src/lib/backup.ts | 13 +- manager/src/lib/compose-template.ts | 3 + manager/src/lib/global-settings.ts | 60 +---- manager/src/lib/job-state.ts | 4 +- manager/src/lib/operations.ts | 21 +- manager/src/lib/operator-rollout.ts | 51 ++++ manager/src/lib/process.ts | 2 +- manager/src/lib/provisioning.ts | 2 + manager/src/lib/restore.ts | 27 +- manager/src/lib/sevenzip.ts | 43 ++-- .../migration.sql | 22 ++ prisma/schema.prisma | 30 +++ .../(owner)/owner/appointments/[id]/page.tsx | 7 + src/app/(owner)/owner/calendar/page.tsx | 10 +- .../owner/settings/booking-rules/page.tsx | 2 + .../(owner)/owner/settings/business/page.tsx | 1 + .../(owner)/owner/settings/loyalty/page.tsx | 4 - .../(staff)/staff/appointments/[id]/page.tsx | 7 + src/app/(staff)/staff/clients/[id]/page.tsx | 25 ++ src/app/(staff)/staff/clients/page.tsx | 16 +- src/app/(staff)/staff/page.tsx | 10 +- src/app/(staff)/staff/reminders/page.tsx | 92 +++++-- .../(staff)/staff/settings/calendar/page.tsx | 1 + .../staff/settings/notifications/page.tsx | 1 + .../api/cron/process-notifications/route.ts | 15 +- src/app/api/health/route.ts | 33 ++- .../staff/calendar/google/callback/route.ts | 29 ++- src/components/ui/appointment-card.tsx | 8 +- .../booking/actions/customer-appointments.ts | 1 + src/features/booking/actions/draft.ts | 16 +- src/features/booking/error-messages.ts | 2 + src/features/calendar/actions/calendar.ts | 4 + .../components/calendar-connection-panel.tsx | 24 +- src/features/owner/actions/appointments.ts | 11 +- src/features/owner/actions/business.ts | 5 + .../owner/components/booking-rules-form.tsx | 27 ++ .../components/business-details-form.tsx | 18 ++ .../owner/components/loyalty-config-form.tsx | 12 - .../staff/actions/appointment-actions.ts | 5 +- src/features/staff/actions/no-show.ts | 45 ++++ .../staff/components/day-calendar.tsx | 5 + .../staff/components/no-show-badge.tsx | 38 +++ .../staff/components/reminders-link.tsx | 24 +- .../components/reset-no-shows-button.tsx | 60 +++++ src/instrumentation.ts | 21 ++ src/lib/auth/otp.service.ts | 5 +- .../monitoring/__tests__/monitoring.test.ts | 31 +++ .../monitoring/__tests__/report-error.test.ts | 38 +++ src/lib/monitoring/alert-limiter.ts | 39 +++ src/lib/monitoring/report-error.ts | 90 +++++++ src/services/business/business.service.ts | 2 + .../google-calendar.provider.test.ts | 153 +++++++++++- src/services/calendar/calendar-provider.ts | 6 + .../calendar/google-calendar.provider.ts | 39 ++- .../calendar/mock-calendar.provider.ts | 10 + .../__tests__/no-show.integration.test.ts | 234 ++++++++++++++++++ .../__tests__/no-show.service.test.ts | 91 +++++++ src/services/customer/no-show.service.ts | 199 +++++++++++++++ .../loyalty/__tests__/loyalty-config.test.ts | 2 - .../loyalty-owner.integration.test.ts | 19 +- src/services/loyalty/loyalty.service.ts | 12 +- .../notification.service.integration.test.ts | 23 +- .../queue-health.integration.test.ts | 91 +++++++ .../__tests__/queue-health.test.ts | 94 +++++++ .../notifications/__tests__/types.test.ts | 26 +- src/services/notifications/failure-reasons.ts | 11 + .../providers/brevo-email-provider.ts | 2 +- .../notifications/providers/email-provider.ts | 6 + src/services/notifications/queue-health.ts | 140 +++++++++++ src/services/notifications/reminder-worker.ts | 23 +- src/services/notifications/render-email.ts | 85 ++++++- .../templates/__tests__/templates.test.ts | 138 ++++++++--- .../notifications/templates/booking-emails.ts | 68 +++-- .../notifications/templates/generic-email.ts | 16 +- .../notifications/templates/layout.ts | 145 ++++++++++- .../notifications/templates/loyalty-emails.ts | 22 +- .../notifications/templates/otp-email.ts | 2 +- .../notifications/templates/shared.ts | 11 +- .../notifications/templates/staff-emails.ts | 91 ------- src/services/notifications/types.ts | 32 +-- .../reporting/owner-reporting.service.ts | 2 - .../booking-notifications.integration.test.ts | 139 ++++++++++- .../__tests__/booking-rule.service.test.ts | 22 ++ .../whatsapp-reminders.integration.test.ts | 110 +++++++- .../scheduling/appointment.service.ts | 9 + .../scheduling/booking-notifications.ts | 57 +++-- .../scheduling/booking-rule.service.ts | 15 ++ src/services/scheduling/booking.service.ts | 27 +- .../scheduling/business-rules.service.ts | 3 + src/services/scheduling/types.ts | 1 + .../scheduling/whatsapp-reminders.service.ts | 102 ++++++++ 116 files changed, 3089 insertions(+), 766 deletions(-) delete mode 100644 manager/src/app/dashboard/passphrase-notice.tsx create mode 100644 manager/src/lib/__tests__/operator-rollout.test.ts create mode 100644 manager/src/lib/operator-rollout.ts create mode 100644 prisma/migrations/20261018090000_no_show_whatsapp_lead_cron_status_calendar_mode/migration.sql create mode 100644 src/features/staff/actions/no-show.ts create mode 100644 src/features/staff/components/no-show-badge.tsx create mode 100644 src/features/staff/components/reset-no-shows-button.tsx create mode 100644 src/instrumentation.ts create mode 100644 src/lib/monitoring/__tests__/monitoring.test.ts create mode 100644 src/lib/monitoring/__tests__/report-error.test.ts create mode 100644 src/lib/monitoring/alert-limiter.ts create mode 100644 src/lib/monitoring/report-error.ts create mode 100644 src/services/customer/__tests__/no-show.integration.test.ts create mode 100644 src/services/customer/__tests__/no-show.service.test.ts create mode 100644 src/services/customer/no-show.service.ts create mode 100644 src/services/notifications/__tests__/queue-health.integration.test.ts create mode 100644 src/services/notifications/__tests__/queue-health.test.ts create mode 100644 src/services/notifications/failure-reasons.ts create mode 100644 src/services/notifications/queue-health.ts delete mode 100644 src/services/notifications/templates/staff-emails.ts 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} +
-