From 546613e677614c4153bba26c7755e5e47c566345 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Mon, 3 Aug 2026 21:15:24 +0100 Subject: [PATCH] Phase 7 --- .env.example | 37 +- .gitea/workflows/ci.yml | 112 +++ docs/phase-7-summary.md | 138 ++++ e2e/customer-journeys.spec.ts | 61 +- e2e/helpers.ts | 12 + e2e/owner-journeys.spec.ts | 55 +- e2e/staff-journeys.spec.ts | 33 +- next.config.ts | 29 + package-lock.json | 423 ++++++----- package.json | 9 +- .../20260802161213_init/migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration_lock.toml | 3 + .../migration.sql | 665 ++++++++++++++++++ .../migration.sql | 18 + .../migration.sql | 101 +++ .../migration.sql | 11 + .../migration.sql | 28 + .../migration.sql | 2 + prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 276 +++++++- prisma/seed.ts | 67 +- public/icons/icon-192.png | Bin 0 -> 4463 bytes public/icons/icon-512.png | Bin 0 -> 16664 bytes public/icons/icon-maskable-192.png | Bin 0 -> 2919 bytes public/icons/icon-maskable-512.png | Bin 0 -> 12049 bytes public/sw.js | 120 ++++ src/app/(auth)/layout.tsx | 2 +- src/app/(customer)/appointments/[id]/page.tsx | 13 +- src/app/(customer)/error.tsx | 8 + src/app/(customer)/home/page.tsx | 68 +- src/app/(customer)/layout.tsx | 17 +- src/app/(customer)/loyalty/page.tsx | 135 ++++ src/app/(customer)/notifications/page.tsx | 38 + .../settings/notifications/page.tsx | 41 ++ src/app/(customer)/settings/page.tsx | 25 +- src/app/(owner)/error.tsx | 8 + src/app/(owner)/layout.tsx | 11 +- .../(owner)/owner/appointments/[id]/page.tsx | 6 +- src/app/(owner)/owner/calendar/page.tsx | 6 +- src/app/(owner)/owner/notifications/page.tsx | 27 + src/app/(owner)/owner/page.tsx | 12 +- src/app/(owner)/owner/reports/page.tsx | 201 ++++++ src/app/(owner)/owner/services/page.tsx | 3 +- .../owner/settings/booking-rules/page.tsx | 3 +- .../(owner)/owner/settings/business/page.tsx | 3 +- src/app/(owner)/owner/settings/hours/page.tsx | 3 +- .../(owner)/owner/settings/loyalty/page.tsx | 3 +- src/app/(owner)/owner/settings/page.tsx | 6 + .../(owner)/owner/team/[id]/breaks/page.tsx | 53 ++ .../(owner)/owner/team/[id]/hours/page.tsx | 12 +- src/app/(owner)/owner/team/[id]/page.tsx | 13 +- .../(owner)/owner/team/[id]/time-off/page.tsx | 61 ++ src/app/(owner)/owner/team/add/page.tsx | 3 +- src/app/(owner)/owner/team/page.tsx | 3 +- src/app/(staff)/error.tsx | 8 + src/app/(staff)/layout.tsx | 9 +- .../(staff)/staff/appointments/[id]/page.tsx | 26 +- src/app/(staff)/staff/clients/[id]/page.tsx | 2 +- src/app/(staff)/staff/clients/page.tsx | 20 +- src/app/(staff)/staff/notifications/page.tsx | 26 + .../staff/settings/notifications/page.tsx | 24 +- src/app/(staff)/staff/settings/page.tsx | 6 + .../appointments/[id]/calendar.ics/route.ts | 5 +- .../api/cron/process-notifications/route.ts | 58 ++ src/app/apple-icon.png | Bin 0 -> 2273 bytes .../appointments/[id]/reschedule/layout.tsx | 6 +- src/app/appointments/[id]/reschedule/page.tsx | 16 +- src/app/book/confirmation/page.tsx | 25 +- src/app/book/layout.tsx | 6 +- src/app/book/review/page.tsx | 14 + src/app/error.tsx | 23 + src/app/globals.css | 25 +- src/app/icon.png | Bin 0 -> 16664 bytes src/app/layout.tsx | 15 + src/app/manifest.ts | 28 + src/app/offline/page.tsx | 25 + .../appointments/[id]/reschedule/layout.tsx | 6 +- src/app/page.tsx | 2 +- .../appointments/[id]/reschedule/layout.tsx | 6 +- src/app/staff/book/layout.tsx | 6 +- src/auth.ts | 33 +- src/components/navigation/staff-shell.tsx | 8 +- src/components/providers.tsx | 8 +- src/components/ui/loyalty-card.tsx | 5 +- src/components/ui/status-pill.tsx | 10 +- src/features/auth/actions/register.ts | 8 + .../booking/__tests__/authorization.test.ts | 109 ++- .../customer-booking.integration.test.ts | 112 ++- src/features/booking/actions/confirm.ts | 15 +- .../booking/actions/customer-appointments.ts | 10 +- src/features/booking/actions/draft.ts | 5 +- src/features/booking/authorization.ts | 47 +- .../booking/components/book-again-button.tsx | 12 +- .../components/cancel-appointment-dialog.tsx | 9 +- src/features/booking/schemas.ts | 3 + .../notifications/actions/mark-read.ts | 41 ++ .../notifications/actions/preferences.ts | 35 + .../actions/push-subscriptions.ts | 51 ++ .../components/notification-bell.tsx | 27 + .../components/notification-list.tsx | 112 +++ .../components/push-subscribe-toggle.tsx | 153 ++++ src/features/notifications/lib/vapid.ts | 11 + .../breaks-time-off.integration.test.ts | 94 +++ src/features/owner/actions/appointments.ts | 19 +- .../owner/actions/break-action-set.ts | 26 + src/features/owner/actions/breaks.ts | 155 ++++ src/features/owner/actions/staff.ts | 49 +- .../owner/actions/time-off-action-set.ts | 17 + src/features/owner/actions/time-off.ts | 146 ++++ .../owner/components/booking-rules-form.tsx | 2 +- .../components/business-details-form.tsx | 2 +- .../owner/components/loyalty-config-form.tsx | 9 +- .../owner/components/opening-hours-form.tsx | 2 +- .../owner/components/staff-detail-panel.tsx | 14 +- .../pwa/components/install-app-button.tsx | 89 +++ .../pwa/components/service-worker-manager.tsx | 81 +++ .../staff/actions/appointment-actions.ts | 18 +- src/features/staff/actions/profile.ts | 17 +- src/features/staff/actions/staff-booking.ts | 2 +- src/features/staff/actions/working-hours.ts | 31 +- .../staff/components/break-form-sheet.tsx | 26 +- src/features/staff/components/breaks-list.tsx | 14 +- .../notification-preferences-form.tsx | 119 ++-- .../staff/components/profile-form.tsx | 2 +- .../staff/components/time-off-form-sheet.tsx | 33 +- .../staff/components/time-off-list.tsx | 8 +- .../staff/components/working-hours-form.tsx | 69 +- src/lib/auth/permissions.ts | 11 +- src/lib/booking-draft/cookie.ts | 20 +- src/lib/db/mysql-pool-config.ts | 31 + src/lib/db/prisma.ts | 28 +- src/lib/security/__tests__/rate-limit.test.ts | 50 ++ src/lib/security/rate-limit.ts | 60 ++ src/lib/security/request-ip.ts | 22 + .../business-time.mysql.integration.test.ts | 149 ++++ src/proxy.ts | 48 +- .../catalogue/service-catalogue.service.ts | 4 +- .../loyalty-notifications.integration.test.ts | 148 ++++ .../loyalty-owner.integration.test.ts | 87 +++ src/services/loyalty/loyalty-notifications.ts | 94 +++ src/services/loyalty/loyalty.service.ts | 110 ++- .../notification.service.integration.test.ts | 226 ++++++ ...h-subscription.service.integration.test.ts | 88 +++ .../reminder-worker.integration.test.ts | 261 +++++++ .../notifications/__tests__/types.test.ts | 33 + .../notifications/notification.service.ts | 289 ++++++++ .../__tests__/brevo-email-provider.test.ts | 75 ++ .../__tests__/web-push-provider.test.ts | 62 ++ .../providers/brevo-email-provider.ts | 131 ++++ .../providers/console-email-provider.ts | 28 + .../providers/console-push-provider.ts | 19 + .../notifications/providers/email-provider.ts | 43 ++ src/services/notifications/providers/index.ts | 53 ++ .../providers/mock-email-provider.ts | 36 + .../providers/mock-push-provider.ts | 28 + .../notifications/providers/push-provider.ts | 34 + .../providers/web-push-provider.ts | 62 ++ .../push-subscription.service.ts | 57 ++ src/services/notifications/recipients.ts | 20 + src/services/notifications/reminder-worker.ts | 248 +++++++ src/services/notifications/render-email.ts | 149 ++++ .../templates/__tests__/templates.test.ts | 150 ++++ .../notifications/templates/booking-emails.ts | 125 ++++ .../notifications/templates/generic-email.ts | 47 ++ .../notifications/templates/layout.ts | 104 +++ .../notifications/templates/loyalty-emails.ts | 92 +++ .../notifications/templates/shared.ts | 37 + .../notifications/templates/staff-emails.ts | 90 +++ src/services/notifications/types.ts | 58 ++ .../reports.service.integration.test.ts | 207 ++++++ .../reporting/owner-reporting.service.ts | 32 +- src/services/reporting/reports.service.ts | 322 +++++++++ .../booking-engine.integration.test.ts | 44 ++ .../booking-notifications.integration.test.ts | 208 ++++++ src/services/scheduling/__tests__/fixtures.ts | 20 +- .../reminder-scheduling.integration.test.ts | 158 +++++ .../scheduling/booking-notifications.ts | 235 +++++++ src/services/scheduling/booking.service.ts | 26 +- .../scheduling/guest-access.service.ts | 89 +++ .../scheduling/reminder-scheduling.ts | 98 +++ src/services/scheduling/scheduling.service.ts | 57 +- .../staff.service.integration.test.ts | 73 ++ .../staff/staff-invitation.service.ts | 54 +- src/services/staff/staff-notifications.ts | 32 + src/services/staff/staff.service.ts | 125 ++-- 189 files changed, 9873 insertions(+), 655 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 docs/phase-7-summary.md rename prisma/{migrations => migrations-postgresql-archive}/20260802161213_init/migration.sql (100%) rename prisma/{migrations => migrations-postgresql-archive}/20260802165351_booking_engine/migration.sql (100%) rename prisma/{migrations => migrations-postgresql-archive}/20260802204707_staff_operations/migration.sql (100%) rename prisma/{migrations => migrations-postgresql-archive}/20260803070350_owner_administration/migration.sql (100%) rename prisma/{migrations => migrations-postgresql-archive}/20260803071240_loyalty_free_service/migration.sql (100%) create mode 100644 prisma/migrations-postgresql-archive/migration_lock.toml create mode 100644 prisma/migrations/20260803120655_mysql_baseline/migration.sql create mode 100644 prisma/migrations/20260803140032_guest_appointment_access_token/migration.sql create mode 100644 prisma/migrations/20260803150000_notifications_v2/migration.sql create mode 100644 prisma/migrations/20260803160000_staff_invitation_notification_type/migration.sql create mode 100644 prisma/migrations/20260803170000_push_subscriptions/migration.sql create mode 100644 prisma/migrations/20260803192355_break_staff_id_index/migration.sql create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon-maskable-192.png create mode 100644 public/icons/icon-maskable-512.png create mode 100644 public/sw.js create mode 100644 src/app/(customer)/error.tsx create mode 100644 src/app/(customer)/loyalty/page.tsx create mode 100644 src/app/(customer)/notifications/page.tsx create mode 100644 src/app/(customer)/settings/notifications/page.tsx create mode 100644 src/app/(owner)/error.tsx create mode 100644 src/app/(owner)/owner/notifications/page.tsx create mode 100644 src/app/(owner)/owner/reports/page.tsx create mode 100644 src/app/(owner)/owner/team/[id]/breaks/page.tsx create mode 100644 src/app/(owner)/owner/team/[id]/time-off/page.tsx create mode 100644 src/app/(staff)/error.tsx create mode 100644 src/app/(staff)/staff/notifications/page.tsx create mode 100644 src/app/api/cron/process-notifications/route.ts create mode 100644 src/app/apple-icon.png create mode 100644 src/app/error.tsx create mode 100644 src/app/icon.png create mode 100644 src/app/manifest.ts create mode 100644 src/app/offline/page.tsx create mode 100644 src/features/notifications/actions/mark-read.ts create mode 100644 src/features/notifications/actions/preferences.ts create mode 100644 src/features/notifications/actions/push-subscriptions.ts create mode 100644 src/features/notifications/components/notification-bell.tsx create mode 100644 src/features/notifications/components/notification-list.tsx create mode 100644 src/features/notifications/components/push-subscribe-toggle.tsx create mode 100644 src/features/notifications/lib/vapid.ts create mode 100644 src/features/owner/__tests__/breaks-time-off.integration.test.ts create mode 100644 src/features/owner/actions/break-action-set.ts create mode 100644 src/features/owner/actions/breaks.ts create mode 100644 src/features/owner/actions/time-off-action-set.ts create mode 100644 src/features/owner/actions/time-off.ts create mode 100644 src/features/pwa/components/install-app-button.tsx create mode 100644 src/features/pwa/components/service-worker-manager.tsx create mode 100644 src/lib/db/mysql-pool-config.ts create mode 100644 src/lib/security/__tests__/rate-limit.test.ts create mode 100644 src/lib/security/rate-limit.ts create mode 100644 src/lib/security/request-ip.ts create mode 100644 src/lib/time/__tests__/business-time.mysql.integration.test.ts create mode 100644 src/services/loyalty/__tests__/loyalty-notifications.integration.test.ts create mode 100644 src/services/loyalty/loyalty-notifications.ts create mode 100644 src/services/notifications/__tests__/notification.service.integration.test.ts create mode 100644 src/services/notifications/__tests__/push-subscription.service.integration.test.ts create mode 100644 src/services/notifications/__tests__/reminder-worker.integration.test.ts create mode 100644 src/services/notifications/__tests__/types.test.ts create mode 100644 src/services/notifications/notification.service.ts create mode 100644 src/services/notifications/providers/__tests__/brevo-email-provider.test.ts create mode 100644 src/services/notifications/providers/__tests__/web-push-provider.test.ts create mode 100644 src/services/notifications/providers/brevo-email-provider.ts create mode 100644 src/services/notifications/providers/console-email-provider.ts create mode 100644 src/services/notifications/providers/console-push-provider.ts create mode 100644 src/services/notifications/providers/email-provider.ts create mode 100644 src/services/notifications/providers/index.ts create mode 100644 src/services/notifications/providers/mock-email-provider.ts create mode 100644 src/services/notifications/providers/mock-push-provider.ts create mode 100644 src/services/notifications/providers/push-provider.ts create mode 100644 src/services/notifications/providers/web-push-provider.ts create mode 100644 src/services/notifications/push-subscription.service.ts create mode 100644 src/services/notifications/recipients.ts create mode 100644 src/services/notifications/reminder-worker.ts create mode 100644 src/services/notifications/render-email.ts create mode 100644 src/services/notifications/templates/__tests__/templates.test.ts create mode 100644 src/services/notifications/templates/booking-emails.ts create mode 100644 src/services/notifications/templates/generic-email.ts create mode 100644 src/services/notifications/templates/layout.ts create mode 100644 src/services/notifications/templates/loyalty-emails.ts create mode 100644 src/services/notifications/templates/shared.ts create mode 100644 src/services/notifications/templates/staff-emails.ts create mode 100644 src/services/notifications/types.ts create mode 100644 src/services/reporting/__tests__/reports.service.integration.test.ts create mode 100644 src/services/reporting/reports.service.ts create mode 100644 src/services/scheduling/__tests__/booking-notifications.integration.test.ts create mode 100644 src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts create mode 100644 src/services/scheduling/booking-notifications.ts create mode 100644 src/services/scheduling/guest-access.service.ts create mode 100644 src/services/scheduling/reminder-scheduling.ts create mode 100644 src/services/staff/staff-notifications.ts diff --git a/.env.example b/.env.example index 84a3c2e..92ea499 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ -# Database (PostgreSQL). Prisma schema avoids Postgres-only features so this -# can be pointed at MySQL later with a provider change + `prisma migrate`. -DATABASE_URL="postgresql://user:password@localhost:5432/nextchair?schema=public" +# Database (MySQL 8.0 — migrated from PostgreSQL in Phase 7, see +# docs/phase-7-summary.md; the production host only supports MySQL). +DATABASE_URL="mysql://user:password@localhost:3306/nextchair" +# Optional: cap the connection pool (see src/lib/db/prisma.ts). Lower this +# for a hosting provider with a small max-connections limit. +DATABASE_CONNECTION_LIMIT="5" # Auth.js (NextAuth v5). Generate with: npx auth secret AUTH_SECRET="replace-me-with-a-32-byte-random-string" @@ -11,3 +14,31 @@ AUTH_GOOGLE_SECRET="" # Base URL of the app (used for callback URLs, email links, etc.) NEXT_PUBLIC_APP_URL="http://localhost:3000" + +# Email (Phase 7) — see src/services/notifications/providers. +# "console" logs would-be emails to the server console instead of sending +# (safe default — never send real email from an unconfigured environment). +# "brevo" sends for real over Brevo's SMTP relay via nodemailer (see +# brevo-email-provider.ts for why SMTP rather than Brevo's REST API). +EMAIL_PROVIDER="console" +EMAIL_SMTP_HOST="smtp-relay.brevo.com" +EMAIL_SMTP_PORT="587" +EMAIL_SMTP_USERNAME="" +EMAIL_SMTP_PASSWORD="" +EMAIL_FROM_ADDRESS="" +EMAIL_FROM_NAME="NextChair" + +# Web Push (Phase 7) — generate with: npx web-push generate-vapid-keys +# NEXT_PUBLIC_VAPID_PUBLIC_KEY must be the same value as VAPID_PUBLIC_KEY — +# it's duplicated because the browser needs it for pushManager.subscribe. +# PUSH_PROVIDER="web-push" to actually send; defaults to "console" (logs only). +PUSH_PROVIDER="console" +VAPID_PUBLIC_KEY="" +NEXT_PUBLIC_VAPID_PUBLIC_KEY="" +VAPID_PRIVATE_KEY="" +VAPID_SUBJECT="mailto:owner@example.com" + +# Background notification processing (Phase 7) — shared secret an external +# scheduler presents as `Authorization: Bearer ` to +# /api/cron/process-notifications. Generate with: openssl rand -hex 32 +CRON_SECRET="" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..5c99bf9 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,112 @@ +# NextChair CI — lint, typecheck, unit + integration + E2E tests, and a +# production build, all against a real MySQL 8.0 service container (this +# app has no SQLite/mock-DB fallback — see docs/implementation-plan.md and +# the Phase 7 Postgres→MySQL migration notes in prisma/schema.prisma). +# +# Lives under .gitea/workflows/ because this repo's origin is a +# self-hosted Gitea instance (see `git remote -v`), and Gitea Actions only +# auto-discovers workflows there — not .github/workflows/. The YAML syntax +# itself is GitHub Actions-compatible, so this file works unchanged if the +# repo is ever pushed to GitHub too; only the directory matters for +# auto-discovery. If Gitea Actions isn't enabled for this instance, no +# harm — the workflow simply never triggers. + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Cancel a still-running CI run for the same branch/PR once a newer commit +# lands, instead of letting both finish. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # A throwaway, non-secret DB the mysql service creates for this run only + # — never a real deployment's database. + DATABASE_URL: mysql://root:root@127.0.0.1:3306/nextchair + # Any fixed value works (see src/lib/booking-draft/sign.ts) — only needs + # to be consistent for the lifetime of this one CI run. + AUTH_SECRET: ci-run-secret-do-not-use-in-production + NEXT_PUBLIC_APP_URL: http://localhost:3000 + CRON_SECRET: ci-run-cron-secret-do-not-use-in-production + # Explicit, even though these already default to "console" — CI must + # never attempt a real send regardless of what the environment defaults + # to (see src/services/notifications/providers/index.ts). + EMAIL_PROVIDER: console + PUSH_PROVIDER: console + +jobs: + ci: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: nextchair + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npx prisma generate + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Unit tests + run: npm run test + + - name: Apply migrations + run: npx prisma migrate deploy + + - name: Seed database + run: npm run prisma:seed + + - name: Integration tests + run: npm run test:integration + + - name: Production build + run: npm run build + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: E2E tests + run: npm run test:e2e + + - name: Upload Playwright traces/screenshots on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + # playwright.config.ts uses the "list" reporter (no HTML report + # dir) but does keep a trace + screenshot per failed test here — + # see trace/screenshot settings in that file. + path: test-results/ + retention-days: 7 + if-no-files-found: ignore diff --git a/docs/phase-7-summary.md b/docs/phase-7-summary.md new file mode 100644 index 0000000..b19c951 --- /dev/null +++ b/docs/phase-7-summary.md @@ -0,0 +1,138 @@ +# Phase 7 — Notifications, Reporting, PWA and Production Polish: Summary + +The closing production-readiness phase: finishing the two features Phase 5/6 deliberately left partial (the customer-facing loyalty experience and owner reporting), turning the app into an installable, offline-tolerant PWA, and a hardening pass across security, accessibility, performance and CI that touches nearly every route in the app without changing any of its domain logic. The notification and push-subscription infrastructure itself (`NotificationService`, email/push providers, reminder scheduling, the in-app notification centre) was built earlier in this same phase, ahead of this window's context; this document covers the remainder — loyalty completion, reports, PWA, hardening, and the phase-closing test/verification work. + +--- + +## 1. What was built + +- **Customer Loyalty page** (`/loyalty`) — the customer-facing counterpart to the barber's stamp UI and the owner's programme config: current stamp count, an available-reward banner (with expiry date, if any), and a full ledger-style transaction history. Sign-in-gated with a friendly prompt, not a hard redirect, matching the same pattern `/appointments` and `/settings` already use. +- **Reward expiry sweep** (`expireOverdueRewards`) — a scheduled job, not a UI feature: bulk-expires `AVAILABLE` rewards past `expiresAt`, then notifies customers whose reward expires within the next 7 days. Piggybacks on the existing notification cron rather than a second scheduled endpoint. +- **Owner Reports** (`/owner/reports`) — six data-backed sections (Appointments, Services, Staff, Customers, Loyalty, Notifications) over a selectable date range, with 7/30/90-day presets. Deliberately separate from the Phase 6 dashboard's single-day operational summary — this is historical, range-based reporting, not a live-today view. +- **PWA install experience** — a real `manifest.webmanifest` (via Next's file-convention `manifest.ts`), app icons (including maskable variants), and an `InstallAppButton` that captures the browser's `beforeinstallprompt` event and surfaces it as a real in-app action, added to all three settings pages (customer/staff/owner) rather than a fourth new route. +- **Service worker: offline shell + update handshake** — precaches a small static app shell (offline fallback page, manifest, icons) so a lost connection shows a real "you're offline" screen instead of the browser's own error page; a new SW version installs and waits rather than taking over immediately, so a banner can prompt the user before switching. Deliberately does **not** attempt to cache the dynamic, per-session booking/calendar/loyalty content — see §7 for why. +- **Security hardening** — a real `Content-Security-Policy` and standard security response headers on every route, in-memory rate limiting on sign-in/registration/guest-booking, constant-time comparisons for the cron shared secret, and a timing-safe dummy-hash comparison in `authorize()` so a failed sign-in takes the same time whether or not the email exists. +- **Accessibility, responsive and UX polish** — a sweep across the whole app driven by a dedicated audit (see §9), fixing concrete issues rather than a generic pass. +- **Performance and index review** — a dedicated audit (see §10) that found and fixed a genuine missing index and eliminated a few `select`/`include` over-fetches; most of the audit's findings were about things already done correctly (`cache()`-deduped lookups, no N+1 patterns in the hot paths), and are recorded as "reviewed, no change needed" rather than invented busywork. +- **CI pipeline** (`.gitea/workflows/ci.yml`) — lint, typecheck, unit tests, a real MySQL 8.0 service container, migrate + seed, integration tests, build, and the full Playwright E2E suite, on every push. Gitea Actions, not GitHub Actions — this repo's actual remote is self-hosted Gitea (confirmed via `git remote -v`), and Gitea Actions workflow YAML is GitHub-Actions-compatible. +- **Seed data extension** — a few additional seeded notifications (booking confirmed, loyalty stamp added, staff/owner new-appointment) and a second loyalty customer (Sam Osei) seeded at exactly the reward threshold, so the loyalty and notification UIs have real, varied data to render against out of the box. + +## 2. Routes created + +Inside `(customer)`: `/loyalty`. + +Inside `(owner)`: `/owner/reports`. + +No other new routes this phase — PWA install/offline behavior and security headers apply app-wide rather than introducing new pages, and the notification centre/push-preferences routes were built earlier in this phase, ahead of this window. + +## 3. Components + +**New:** +- `src/features/pwa/components/install-app-button.tsx` — captures `beforeinstallprompt`, renders nothing if the browser never fires it (already installed, or unsupported) or once installed (`appinstalled` event). +- `src/features/pwa/components/service-worker-manager.tsx` — registers the service worker and renders the update-available banner when `registration.waiting` appears; mounted once in `src/components/providers.tsx` inside `` so it runs on every authenticated and public page alike. +- Loyalty page composes the existing `LoyaltyCard` component (built earlier this phase) rather than introducing a new card — the customer page is the first real consumer of what had, until now, only been used elsewhere. + +**Fixed, not new:** `src/features/notifications/components/push-subscribe-toggle.tsx` — a genuine hydration bug (see §11) where `useState`'s lazy initializer read `window`/`Notification.permission` directly; moved all browser-API detection into `useEffect`. + +## 4. Domain services + +**New:** +- **`src/services/reporting/reports.service.ts`** — six pure, read-only aggregation functions (`getAppointmentReport`, `getServiceReport`, `getStaffReport`, `getCustomerReport`, `getLoyaltyReport`, `getNotificationReport`), each `(prisma, {businessId, range: {from, to}})`. Kept as a sibling to, not a merge into, `owner-reporting.service.ts` — that file answers "what does today look like," this one answers "what happened in a chosen range," and the two have no overlapping callers. +- **`src/lib/security/rate-limit.ts`** — `checkRateLimit(key, {maxAttempts, windowMs})`, an in-memory fixed-window counter with a lazy-sweep bound (`MAX_TRACKED_KEYS = 50_000`) so an unbounded set of keys (e.g. one per attacker-controlled email) can't grow the map forever. Explicit, documented trade-off: single-process only, no shared store — appropriate for this app's one-Node.js-server deployment shape, not appropriate unchanged if a future phase scales horizontally. +- **`src/lib/security/request-ip.ts`** — `getRequestIp()` via `next/headers`, the one place every rate-limited Server Action gets the caller's IP from. + +**Extended:** +- **`LoyaltyService`** — `expireOverdueRewards(prisma, now)`: bulk `updateMany` (`AVAILABLE` → `EXPIRED` where `expiresAt` has passed), then notifies rewards expiring within 7 days (day-scoped idempotency key, so the same reward doesn't re-notify every time the cron runs that day). `redeemReward` gained a defense-in-depth expiry check — a reward that's `AVAILABLE` in the database but technically past its `expiresAt` (the sweep hasn't run yet) is still refused at redemption time, not just relied on the sweep to catch first. + +**Reused as-is:** every scheduling/business/staff/loyalty/notification service from Phases 3–6/earlier-Phase-7 — this phase added no new mutation surface to the booking or scheduling core, only read-side reporting, security middleware, and PWA plumbing around the existing domain. + +## 5. Schema changes + +One migration this window: **`20260803192355_break_staff_id_index`** — adds `@@index([staffId])` to the `Break` model (see §10 for why). + +No other schema changes were needed this phase — loyalty expiry uses the existing `LoyaltyReward.status`/`expiresAt` columns, and reporting is read-only aggregation over existing tables. + +## 6. Reports approach + +Each of the six report functions runs its own targeted Prisma aggregation/groupBy scoped to `{businessId, range}` — no shared "fetch everything then slice in memory" base query, since the six sections have genuinely different shapes (status counts, per-service/per-staff revenue rollups, new-vs-returning customer classification, loyalty ledger sums, notification delivery-status breakdown by type). `getCustomerReport`'s new-vs-returning split is the one non-trivial piece: a customer counts as "returning" if they have any `COMPLETED` appointment *before* `range.from`, not merely more than one appointment inside the range. Money fields are returned as decimal strings throughout, matching the money-handling discipline established since Phase 6 — revenue is never a JS `number` anywhere in the aggregation or the page that renders it. The owner reports page itself is a plain Server Component reading query-string `from`/`to` params (falling back to the last-7-days preset), so — like the Phase 6 owner calendar — there's no client-side date-range state to keep in sync with the URL. + +## 7. PWA approach + +The service worker's scope is deliberately narrow, stated up front in its own file header: this app is almost entirely dynamic, signed-in, per-request server-rendered content (appointments, calendars, loyalty balances), so caching *that* would risk serving stale data or, worse, another signed-in user's data on a shared device. What it does do: precache a small static app shell (`/offline`, the manifest, two icon sizes) with a network-first strategy for page navigations that only falls back to the offline page when the network genuinely fails; cache-first for the app-shell assets themselves, since they're versioned by `CACHE_NAME` and only change on a new SW deploy; and it never intercepts non-GET requests at all, so Server Actions and API mutations are guaranteed to never be silently served from a cache. The update-available handshake follows the documented `registration.waiting` pattern: a new SW installs and calls neither `skipWaiting()` nor takes over immediately; `ServiceWorkerManager` detects `registration.waiting`, shows a banner, and only on explicit user action sends `postMessage({type: "SKIP_WAITING"})`, which the SW's own `message` handler honors, followed by a `controllerchange`-triggered reload — so an already-open tab is never switched out from under the user without their say-so. + +The install button follows the standard `beforeinstallprompt` capture pattern: the event is intercepted and `preventDefault()`-ed on `window`, stashed, and only replayed (`.prompt()`) when the user clicks the button in Settings — browsers that never fire the event (iOS Safari, or an already-installed app) simply render nothing, rather than a button that does nothing when clicked. + +## 8. Security hardening + +**Headers**: a real `Content-Security-Policy`, built per-request in `src/proxy.ts` (Next 16's renamed `middleware` convention) alongside standard headers (`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, a locked-down `Permissions-Policy`, HSTS) added globally via `next.config.ts`'s `headers()`. The CSP's `script-src` has a documented history worth keeping: a nonce-gated `'strict-dynamic'` policy (Next's own documented pattern) was tried first, and reverted after live production-build testing showed it broke hydration on every page — Next's own inline RSC-hydration payload scripts never receive the generated nonce, so `'strict-dynamic'` refused to run them (confirmed via `BAILOUT_TO_CLIENT_SIDE_RENDERING` markers with `"nonce":"$undefined"` in the served HTML). `script-src 'self' 'unsafe-inline'` (with `'unsafe-eval'` only in development, for Fast Refresh) is the reliable fallback — Next's own code loads via external ``)).toBe( + "<script>alert("x & y's")</script>", + ); + }); +}); + +const business = { name: "Blake Heritage Barbershop", address: "1 High St, London", phone: "020 1234 5678" }; + +const appointmentData: AppointmentEmailData = { + business, + recipientName: "Alex Chen", + staffName: "Marcus Reed", + serviceNames: ["Haircut & Style"], + dateLabel: "Mon 3 Aug", + timeLabel: "16:30", + durationLabel: "30 min", + totalLabel: "£22.00", + appointmentRef: "abc12345", + manageAppointmentPath: "/appointments/abc123?token=raw-token-value", +}; + +describe("booking email templates", () => { + it("renderBookingConfirmedEmail includes appointment details and a working action link", () => { + const email = renderBookingConfirmedEmail(appointmentData); + expect(email.subject).toContain("Booking confirmed"); + expect(email.html).toContain("Marcus Reed"); + expect(email.html).toContain("Haircut & Style"); + expect(email.html).toContain("/appointments/abc123?token=raw-token-value"); + expect(email.text).toContain("Marcus Reed"); + expect(email.text).not.toMatch(/<[a-z]/i); // no stray HTML tags in the plain-text fallback + }); + + it("renderBookingPendingApprovalEmail mentions approval is still needed", () => { + const email = renderBookingPendingApprovalEmail(appointmentData); + expect(email.html.toLowerCase()).toContain("approved"); + }); + + it("renderBookingRescheduledEmail mentions the updated time", () => { + const email = renderBookingRescheduledEmail(appointmentData); + expect(email.html.toLowerCase()).toContain("rescheduled"); + }); + + it("renderBookingCancelledEmail includes a late-cancellation note only when flagged", () => { + const late = renderBookingCancelledEmail(appointmentData, { wasLateCancellation: true }); + expect(late.html.toLowerCase()).toContain("late cancellation"); + + const notLate = renderBookingCancelledEmail(appointmentData); + expect(notLate.html.toLowerCase()).not.toContain("late cancellation"); + }); + + it("renderBookingCancelledEmail escapes a user-supplied cancellation reason", () => { + const email = renderBookingCancelledEmail(appointmentData, { reason: `` }); + expect(email.html).not.toContain(" { + const email = renderBookingReminderEmail(appointmentData); + expect(email.subject.toLowerCase()).toContain("reminder"); + }); +}); + +describe("loyalty email templates", () => { + const rewardData: LoyaltyRewardEmailData = { + business, + recipientName: "Sam Osei", + rewardLabel: "50% Off Any Service", + rewardDescription: "50% off your next visit", + loyaltyCardPath: "/home", + }; + + it("renderLoyaltyRewardEarnedEmail announces the new reward", () => { + const email = renderLoyaltyRewardEarnedEmail(rewardData); + expect(email.html).toContain("50% Off Any Service"); + expect(email.subject.toLowerCase()).toContain("reward"); + }); + + it("renderLoyaltyRewardRedeemedEmail confirms it was applied", () => { + const email = renderLoyaltyRewardRedeemedEmail(rewardData); + expect(email.html.toLowerCase()).toContain("applied"); + }); +}); + +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({ + business, + recipientName: "Marcus Reed", + subject: "New appointment booked", + bodyText: "A new appointment was booked for you.", + deepLinkPath: "/staff/appointments/xyz", + }); + expect(email.subject).toBe("New appointment booked"); + expect(email.html).toContain("A new appointment was booked for you."); + expect(email.html).toContain("/staff/appointments/xyz"); + }); + + it("omits the action button when there's no deep link", () => { + const email = renderGenericNotificationEmail({ + business, + recipientName: "Marcus Reed", + subject: "Heads up", + bodyText: "Something happened.", + }); + expect(email.html).not.toContain("View Details"); + }); +}); diff --git a/src/services/notifications/templates/booking-emails.ts b/src/services/notifications/templates/booking-emails.ts new file mode 100644 index 0000000..7437291 --- /dev/null +++ b/src/services/notifications/templates/booking-emails.ts @@ -0,0 +1,125 @@ +import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout"; +import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared"; +import type { BusinessEmailInfo, RenderedEmail } from "./shared"; + +export interface AppointmentEmailData { + business: BusinessEmailInfo; + recipientName: string; + staffName: string; + serviceNames: string[]; + dateLabel: string; + timeLabel: string; + durationLabel: string; + totalLabel: string; + appointmentRef: string; + /** Relative path — e.g. "/appointments/xyz?token=..." for a guest, "/appointments" for a registered customer. */ + manageAppointmentPath: string; +} + +function renderAppointmentEmail( + data: AppointmentEmailData, + copy: { subject: string; headline: string; intro: string; extraNote?: string; actionLabel?: string }, +): 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 bodyHtml = [ + paragraph(`Hi ${data.recipientName},`), + paragraph(copy.intro), + detailsHtml, + copy.extraNote ? paragraph(copy.extraNote) : "", + ].join(""); + + const html = renderEmailLayout({ + businessName: data.business.name, + businessLogoUrl: data.business.logoUrl, + headline: copy.headline, + bodyHtml, + actionLabel, + actionUrl, + footerNote: footerNoteHtml(data.business), + }); + + const text = renderPlainTextLayout({ + businessName: data.business.name, + headline: copy.headline, + bodyLines: [ + `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] : []), + ], + actionLabel, + actionUrl, + footerNote: footerNoteText(data.business), + }); + + return { subject: copy.subject, html, text }; +} + +export function renderBookingConfirmedEmail(data: AppointmentEmailData): RenderedEmail { + return renderAppointmentEmail(data, { + subject: `Booking confirmed — ${data.business.name}`, + headline: "Your booking is confirmed", + intro: `We've confirmed your appointment with ${data.staffName}. Here are the details:`, + }); +} + +export function renderBookingPendingApprovalEmail(data: AppointmentEmailData): RenderedEmail { + return renderAppointmentEmail(data, { + subject: `Booking received — ${data.business.name}`, + headline: "Your booking request has been received", + intro: `We've received your appointment request with ${data.staffName}. It needs to be approved before it's confirmed — we'll let you know as soon as it is.`, + }); +} + +export function renderBookingRescheduledEmail(data: AppointmentEmailData): RenderedEmail { + return renderAppointmentEmail(data, { + subject: `Booking rescheduled — ${data.business.name}`, + headline: "Your appointment has been rescheduled", + intro: "Here's the updated time for your appointment:", + }); +} + +export function renderBookingCancelledEmail( + data: AppointmentEmailData, + options: { wasLateCancellation?: boolean; reason?: string } = {}, +): RenderedEmail { + return renderAppointmentEmail(data, { + subject: `Booking cancelled — ${data.business.name}`, + headline: "Your appointment has been cancelled", + intro: "This appointment has been cancelled. It's kept below for your records.", + extraNote: options.wasLateCancellation + ? "This was a late cancellation, outside the free-cancellation window. The business's cancellation policy may apply." + : options.reason + ? `Reason: ${options.reason}` + : undefined, + actionLabel: "Book Another Appointment", + }); +} + +export function renderBookingReminderEmail(data: AppointmentEmailData): RenderedEmail { + return renderAppointmentEmail(data, { + subject: `Reminder: your appointment is coming up — ${data.business.name}`, + headline: "See you soon!", + intro: "This is a reminder about your upcoming appointment:", + }); +} diff --git a/src/services/notifications/templates/generic-email.ts b/src/services/notifications/templates/generic-email.ts new file mode 100644 index 0000000..6138c73 --- /dev/null +++ b/src/services/notifications/templates/generic-email.ts @@ -0,0 +1,47 @@ +import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout"; +import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared"; +import type { BusinessEmailInfo, RenderedEmail } from "./shared"; + +export interface GenericNotificationEmailData { + business: BusinessEmailInfo; + recipientName: string; + subject: string; + /** 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; +} + +/** + * Fallback for every NotificationType that doesn't have one of the nine + * bespoke templates the Phase 7 brief named explicitly (booking + * confirmed/pending/rescheduled/cancelled/reminder, loyalty reward + * earned/redeemed, staff invitation, staff schedule changed) — mainly the + * barber/owner operational types (new appointment, no-show, etc.). Still + * branded, still escaped, still has a working action link; just without a + * bespoke layout for data this generic renderer was never given. + */ +export function renderGenericNotificationEmail(data: GenericNotificationEmailData): RenderedEmail { + const actionUrl = data.deepLinkPath ? absoluteUrl(data.deepLinkPath) : undefined; + const bodyHtml = [paragraph(`Hi ${data.recipientName},`), paragraph(data.bodyText)].join(""); + + return { + subject: data.subject, + html: renderEmailLayout({ + businessName: data.business.name, + businessLogoUrl: data.business.logoUrl, + headline: data.subject, + bodyHtml, + actionLabel: actionUrl ? "View Details" : undefined, + actionUrl, + footerNote: footerNoteHtml(data.business), + }), + text: renderPlainTextLayout({ + businessName: data.business.name, + headline: data.subject, + bodyLines: [`Hi ${data.recipientName},`, "", data.bodyText], + actionLabel: actionUrl ? "View Details" : undefined, + actionUrl, + footerNote: footerNoteText(data.business), + }), + }; +} diff --git a/src/services/notifications/templates/layout.ts b/src/services/notifications/templates/layout.ts new file mode 100644 index 0000000..f5ea99b --- /dev/null +++ b/src/services/notifications/templates/layout.ts @@ -0,0 +1,104 @@ +/** + * Shared HTML/plain-text shell every email template renders through — the + * one place that owns escaping, inline-CSS email-client compatibility, + * and "still readable with images blocked". No template in this folder + * builds raw HTML strings on its own outside this file's helpers; that's + * the whole point of "reusable typed renderer functions... not + * concatenated HTML strings in Server Actions" from the Phase 7 brief. + */ + +/** Escapes the five HTML-significant characters — every piece of user/business-supplied text (names, notes, addresses) must go through this before landing in a template's HTML output. */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +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. */ + businessLogoUrl?: string; + 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; + actionLabel?: string; + /** Absolute URL — see NEXT_PUBLIC_APP_URL / renderAbsoluteLink in index.ts. */ + actionUrl?: string; + footerNote?: string; +} + +/** + * A deliberately plain, table-based, inline-styled layout — no external + * stylesheet (most email clients strip it), no background images (the + * business name is always rendered as real text right next to/instead of + * the logo, so the email is still identifiable and readable with images + * blocked, which is the default in most mail clients). + */ +export function renderEmailLayout(input: EmailLayoutInput): string { + const logo = input.businessLogoUrl + ? `${escapeHtml(input.businessName)}` + : ""; + + const action = + input.actionLabel && input.actionUrl + ? ` + ${escapeHtml(input.actionLabel)} + ` + : ""; + + const footer = input.footerNote + ? `${input.footerNote}` + : ""; + + return ` + + + + + + +
+ + + + + ${action} + ${footer} +
${logo}${escapeHtml(input.businessName)}
${escapeHtml(input.headline)}
${input.bodyHtml}
+
+ +`; +} + +export interface PlainTextLayoutInput { + businessName: string; + headline: string; + bodyLines: string[]; + actionLabel?: string; + actionUrl?: string; + footerNote?: string; +} + +export function renderPlainTextLayout(input: PlainTextLayoutInput): string { + const lines = [input.businessName, "", input.headline, "", ...input.bodyLines]; + if (input.actionLabel && input.actionUrl) { + lines.push("", `${input.actionLabel}: ${input.actionUrl}`); + } + if (input.footerNote) { + lines.push("", input.footerNote); + } + return lines.join("\n"); +} + +/** A `

`-wrapped, escaped line — the smallest building block the per-type renderers compose bodyHtml from. */ +export function paragraph(text: string): string { + return `

${escapeHtml(text)}

`; +} + +/** A label/value row — "Date: Mon 3 Aug", "Barber: Marcus Reed", etc. */ +export function detailRow(label: string, value: string): string { + return `

${escapeHtml(label)}: ${escapeHtml(value)}

`; +} diff --git a/src/services/notifications/templates/loyalty-emails.ts b/src/services/notifications/templates/loyalty-emails.ts new file mode 100644 index 0000000..86fe7e7 --- /dev/null +++ b/src/services/notifications/templates/loyalty-emails.ts @@ -0,0 +1,92 @@ +import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout"; +import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared"; +import type { BusinessEmailInfo, RenderedEmail } from "./shared"; + +export interface LoyaltyRewardEmailData { + business: BusinessEmailInfo; + recipientName: string; + rewardLabel: string; + /** e.g. "50% off your next visit", already formatted for display. */ + rewardDescription: string; + expiresLabel?: string; + /** Relative path to the customer's loyalty card. */ + loyaltyCardPath: string; +} + +export function renderLoyaltyRewardEarnedEmail(data: LoyaltyRewardEmailData): RenderedEmail { + const actionUrl = absoluteUrl(data.loyaltyCardPath); + const bodyHtml = [ + paragraph(`Hi ${data.recipientName},`), + paragraph(`You've earned a reward: ${data.rewardLabel}!`), + detailRow("Reward", data.rewardDescription), + data.expiresLabel ? detailRow("Expires", data.expiresLabel) : "", + paragraph("Show this to your barber on your next visit to redeem it."), + ].join(""); + + return { + subject: `You've earned a reward — ${data.business.name}`, + html: renderEmailLayout({ + businessName: data.business.name, + businessLogoUrl: data.business.logoUrl, + headline: "You've earned a reward!", + bodyHtml, + actionLabel: "View Loyalty Card", + actionUrl, + footerNote: footerNoteHtml(data.business), + }), + text: renderPlainTextLayout({ + businessName: data.business.name, + headline: "You've earned a reward!", + bodyLines: [ + `Hi ${data.recipientName},`, + "", + `You've earned a reward: ${data.rewardLabel}!`, + `Reward: ${data.rewardDescription}`, + ...(data.expiresLabel ? [`Expires: ${data.expiresLabel}`] : []), + "", + "Show this to your barber on your next visit to redeem it.", + ], + actionLabel: "View Loyalty Card", + actionUrl, + footerNote: footerNoteText(data.business), + }), + }; +} + +export function renderLoyaltyRewardRedeemedEmail(data: LoyaltyRewardEmailData): RenderedEmail { + const actionUrl = absoluteUrl(data.loyaltyCardPath); + const bodyHtml = [ + paragraph(`Hi ${data.recipientName},`), + paragraph(`Your reward has been applied: ${data.rewardLabel}.`), + detailRow("Reward", data.rewardDescription), + paragraph("Thanks for being a loyal customer!"), + ].join(""); + + return { + subject: `Reward applied — ${data.business.name}`, + html: renderEmailLayout({ + businessName: data.business.name, + businessLogoUrl: data.business.logoUrl, + headline: "Your reward has been applied", + bodyHtml, + actionLabel: "View Loyalty Card", + actionUrl, + footerNote: footerNoteHtml(data.business), + }), + text: renderPlainTextLayout({ + businessName: data.business.name, + headline: "Your reward has been applied", + bodyLines: [ + `Hi ${data.recipientName},`, + "", + `Your reward has been applied: ${data.rewardLabel}.`, + `Reward: ${data.rewardDescription}`, + "", + "Thanks for being a loyal customer!", + ], + actionLabel: "View Loyalty Card", + actionUrl, + footerNote: footerNoteText(data.business), + }), + }; +} diff --git a/src/services/notifications/templates/shared.ts b/src/services/notifications/templates/shared.ts new file mode 100644 index 0000000..1b92822 --- /dev/null +++ b/src/services/notifications/templates/shared.ts @@ -0,0 +1,37 @@ +import { escapeHtml } from "./layout"; + +export interface BusinessEmailInfo { + name: string; + logoUrl?: string; + address?: string; + phone?: string; + cancellationPolicy?: string; +} + +/** Absolute URL — email clients can't resolve a relative path, unlike a browser looking at the current origin. */ +export function absoluteUrl(path: string): string { + const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + return path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`; +} + +export function footerNoteHtml(business: BusinessEmailInfo): string | undefined { + const parts: string[] = []; + if (business.address) parts.push(business.address); + if (business.phone) parts.push(business.phone); + if (business.cancellationPolicy) parts.push(business.cancellationPolicy); + return parts.length > 0 ? parts.map((p) => `${escapeHtml(p)}`).join("
") : undefined; +} + +export function footerNoteText(business: BusinessEmailInfo): string | undefined { + const parts: string[] = []; + if (business.address) parts.push(business.address); + if (business.phone) parts.push(business.phone); + if (business.cancellationPolicy) parts.push(business.cancellationPolicy); + return parts.length > 0 ? parts.join("\n") : undefined; +} + +export interface RenderedEmail { + subject: string; + html: string; + text: string; +} diff --git a/src/services/notifications/templates/staff-emails.ts b/src/services/notifications/templates/staff-emails.ts new file mode 100644 index 0000000..fd16e26 --- /dev/null +++ b/src/services/notifications/templates/staff-emails.ts @@ -0,0 +1,90 @@ +import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout"; +import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared"; +import type { BusinessEmailInfo, RenderedEmail } from "./shared"; + +export interface StaffInvitationEmailData { + business: BusinessEmailInfo; + staffName: string; + roleName: string; + invitedByName: string; + /** Relative path — /invite/accept?token=... */ + acceptInvitePath: string; +} + +export function renderStaffInvitationEmail(data: StaffInvitationEmailData): RenderedEmail { + const actionUrl = absoluteUrl(data.acceptInvitePath); + const bodyHtml = [ + paragraph(`Hi ${data.staffName},`), + paragraph( + `${data.invitedByName} has added you as ${data.roleName === "Owner" ? "an" : "a"} ${data.roleName} at ${data.business.name} on NextChair. Set your password to get started.`, + ), + ].join(""); + + return { + subject: `You've been invited to join ${data.business.name}`, + html: renderEmailLayout({ + businessName: data.business.name, + businessLogoUrl: data.business.logoUrl, + headline: "You've been invited", + bodyHtml, + actionLabel: "Set Your Password", + actionUrl, + footerNote: footerNoteHtml(data.business), + }), + text: renderPlainTextLayout({ + businessName: data.business.name, + headline: "You've been invited", + bodyLines: [ + `Hi ${data.staffName},`, + "", + `${data.invitedByName} has added you as a ${data.roleName} at ${data.business.name} on NextChair. Set your password to get started.`, + ], + actionLabel: "Set Your Password", + actionUrl, + footerNote: footerNoteText(data.business), + }), + }; +} + +export interface StaffScheduleChangedEmailData { + business: BusinessEmailInfo; + staffName: string; + changedByName: string; + /** e.g. "your working hours", "your breaks", "your time off" */ + whatChanged: string; + /** Relative path — e.g. /staff/settings/working-hours */ + viewSchedulePath: string; +} + +export function renderStaffScheduleChangedEmail(data: StaffScheduleChangedEmailData): RenderedEmail { + const actionUrl = absoluteUrl(data.viewSchedulePath); + const bodyHtml = [ + paragraph(`Hi ${data.staffName},`), + paragraph(`${data.changedByName} has updated ${data.whatChanged} at ${data.business.name}. Take a look to see what changed.`), + ].join(""); + + return { + subject: `Your schedule was updated — ${data.business.name}`, + html: renderEmailLayout({ + businessName: data.business.name, + businessLogoUrl: data.business.logoUrl, + headline: "Your schedule was updated", + bodyHtml, + actionLabel: "View Schedule", + actionUrl, + footerNote: footerNoteHtml(data.business), + }), + text: renderPlainTextLayout({ + businessName: data.business.name, + headline: "Your schedule was updated", + bodyLines: [ + `Hi ${data.staffName},`, + "", + `${data.changedByName} has updated ${data.whatChanged} at ${data.business.name}. Take a look to see what changed.`, + ], + actionLabel: "View Schedule", + actionUrl, + footerNote: footerNoteText(data.business), + }), + }; +} diff --git a/src/services/notifications/types.ts b/src/services/notifications/types.ts new file mode 100644 index 0000000..8bf6299 --- /dev/null +++ b/src/services/notifications/types.ts @@ -0,0 +1,58 @@ +import { NotificationType } from "@/generated/prisma/enums"; + +/** + * Static metadata about each NotificationType — who it's for, and whether + * it's the kind of thing a user gets to turn off. Kept as plain lookup + * tables here rather than columns on the enum/schema, so policy can change + * without a migration (see the NotificationPreference model comment in + * schema.prisma). + */ + +export type NotificationRecipientKind = "CUSTOMER" | "STAFF" | "OWNER"; + +const RECIPIENT_KIND: Record = { + BOOKING_CONFIRMED: "CUSTOMER", + BOOKING_PENDING_APPROVAL: "CUSTOMER", + BOOKING_APPROVED: "CUSTOMER", + BOOKING_REMINDER: "CUSTOMER", + BOOKING_RESCHEDULED: "CUSTOMER", + BOOKING_CANCELLED: "CUSTOMER", + BOOKING_BARBER_CHANGED: "CUSTOMER", + BOOKING_COMPLETED: "CUSTOMER", + LOYALTY_STAMP_ADDED: "CUSTOMER", + LOYALTY_REWARD_EARNED: "CUSTOMER", + LOYALTY_REWARD_REDEEMED: "CUSTOMER", + LOYALTY_REWARD_EXPIRING: "CUSTOMER", + STAFF_NEW_APPOINTMENT: "STAFF", + STAFF_APPOINTMENT_RESCHEDULED: "STAFF", + STAFF_APPOINTMENT_CANCELLED: "STAFF", + STAFF_APPOINTMENT_CHECKED_IN: "STAFF", + STAFF_APPOINTMENT_REMINDER: "STAFF", + STAFF_SCHEDULE_CHANGED: "STAFF", + STAFF_INVITATION_RECEIVED: "STAFF", + OWNER_NEW_BOOKING: "OWNER", + OWNER_BOOKING_CANCELLED: "OWNER", + OWNER_NO_SHOW: "OWNER", + OWNER_STAFF_INVITATION_ACCEPTED: "OWNER", + OWNER_REPEATED_DELIVERY_FAILURE: "OWNER", +}; + +export function recipientKindOf(type: NotificationType): NotificationRecipientKind { + return RECIPIENT_KIND[type]; +} + +/** + * Every other type is REQUIRED — always sent on every channel it has a + * valid target for, with no user-facing toggle. Only these two are ever + * looked up in NotificationPreference (see NotificationService.notify). + * Deliberately a short, explicit list rather than a "category" column: + * the set of optional types is a product decision, not a schema fact. + */ +const PREFERENCE_CONTROLLED_TYPES = new Set([ + NotificationType.BOOKING_REMINDER, + NotificationType.STAFF_APPOINTMENT_REMINDER, +]); + +export function isPreferenceControlled(type: NotificationType): boolean { + return PREFERENCE_CONTROLLED_TYPES.has(type); +} diff --git a/src/services/reporting/__tests__/reports.service.integration.test.ts b/src/services/reporting/__tests__/reports.service.integration.test.ts new file mode 100644 index 0000000..9a49fdb --- /dev/null +++ b/src/services/reporting/__tests__/reports.service.integration.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + cleanupTestBusiness, + createTestBusiness, + createTestCustomer, + createTestStaff, + prisma, +} from "@/services/scheduling/__tests__/fixtures"; +import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures"; +import { + getAppointmentReport, + getCustomerReport, + getLoyaltyReport, + getNotificationReport, + getServiceReport, + getStaffReport, +} from "../reports.service"; +import { notify } from "@/services/notifications/notification.service"; +import { NotificationType } from "@/generated/prisma/enums"; + +describe("reports.service (integration)", () => { + let business: TestBusiness; + let staffId: string; + let customerId: string; + let customerUserId: string; + + const range = { from: new Date(Date.now() - 24 * 60 * 60 * 1000), to: new Date(Date.now() + 60 * 60 * 1000) }; + + beforeEach(async () => { + business = await createTestBusiness(); + const staff = await createTestStaff(business); + staffId = staff.staffId; + const customer = await createTestCustomer(business); + customerId = customer.customerId; + customerUserId = customer.userId; + }); + + afterEach(async () => { + await cleanupTestBusiness(business); + }); + + async function createAppointment(overrides: { + status: "COMPLETED" | "CANCELLED" | "NO_SHOW" | "CONFIRMED"; + discountAmount?: string; + }) { + return prisma.appointment.create({ + data: { + businessId: business.businessId, + staffId, + customerId, + status: overrides.status, + startAt: new Date(), + endAt: new Date(Date.now() + 30 * 60_000), + discountAmount: overrides.discountAmount, + services: { + create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }], + }, + }, + }); + } + + describe("getAppointmentReport", () => { + it("totals status counts and computes gross/discount/net revenue from completed appointments only", async () => { + await createAppointment({ status: "COMPLETED" }); + await createAppointment({ status: "COMPLETED", discountAmount: "5.00" }); + await createAppointment({ status: "CANCELLED" }); + await createAppointment({ status: "NO_SHOW" }); + + const report = await getAppointmentReport(prisma, { businessId: business.businessId, range }); + + expect(report.totalAppointments).toBe(4); + expect(report.completed).toBe(2); + expect(report.cancelled).toBe(1); + expect(report.noShow).toBe(1); + // £20 + £20 = £40 gross across the two completed appointments; only one had a £5 discount. + expect(report.grossRevenue).toBe("40.00"); + expect(report.discountsGiven).toBe("5.00"); + expect(report.netRevenue).toBe("35.00"); + }); + + it("returns all-zero figures when there are no appointments in range", async () => { + const report = await getAppointmentReport(prisma, { businessId: business.businessId, range }); + expect(report.totalAppointments).toBe(0); + expect(report.grossRevenue).toBe("0.00"); + }); + }); + + describe("getServiceReport", () => { + it("aggregates revenue and completed count per service, ignoring non-completed appointments", async () => { + await createAppointment({ status: "COMPLETED" }); + await createAppointment({ status: "COMPLETED" }); + await createAppointment({ status: "CANCELLED" }); + + const rows = await getServiceReport(prisma, { businessId: business.businessId, range }); + + expect(rows).toHaveLength(1); + expect(rows[0]?.timesCompleted).toBe(2); + expect(rows[0]?.revenue).toBe("40.00"); + }); + }); + + describe("getStaffReport", () => { + it("attributes net revenue (subtotal minus discount) to the assigned staff member", async () => { + await createAppointment({ status: "COMPLETED", discountAmount: "5.00" }); + await createAppointment({ status: "NO_SHOW" }); + + const rows = await getStaffReport(prisma, { businessId: business.businessId, range }); + + expect(rows).toHaveLength(1); + expect(rows[0]?.staffId).toBe(staffId); + expect(rows[0]?.completed).toBe(1); + expect(rows[0]?.noShow).toBe(1); + expect(rows[0]?.revenue).toBe("15.00"); + }); + }); + + describe("getCustomerReport", () => { + it("counts a customer with no prior visit as new, and lists them in topCustomers", async () => { + await createAppointment({ status: "COMPLETED" }); + + const report = await getCustomerReport(prisma, { businessId: business.businessId, range }); + + expect(report.newCustomers).toBe(1); + expect(report.returningCustomers).toBe(0); + expect(report.topCustomers).toHaveLength(1); + expect(report.topCustomers[0]?.customerId).toBe(customerId); + expect(report.topCustomers[0]?.spend).toBe("20.00"); + }); + + it("counts a customer with a visit before the range as returning", async () => { + // A completed visit well before `range.from`. + await prisma.appointment.create({ + data: { + businessId: business.businessId, + staffId, + customerId, + status: "COMPLETED", + startAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + endAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000 + 30 * 60_000), + services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] }, + }, + }); + await createAppointment({ status: "COMPLETED" }); + + const report = await getCustomerReport(prisma, { businessId: business.businessId, range }); + + expect(report.newCustomers).toBe(0); + expect(report.returningCustomers).toBe(1); + }); + }); + + describe("getLoyaltyReport", () => { + it("sums stamp transactions and counts rewards earned/redeemed within range", async () => { + const programme = await prisma.loyaltyProgramme.create({ + data: { businessId: business.businessId, isEnabled: true, stampsRequired: 5, rewardLabel: "Free cut" }, + }); + const account = await prisma.loyaltyAccount.create({ + data: { customerId, loyaltyProgrammeId: programme.id, currentStamps: 5 }, + }); + await prisma.loyaltyTransaction.createMany({ + data: [ + { loyaltyAccountId: account.id, type: "STAMP_ADDED", amount: 1 }, + { loyaltyAccountId: account.id, type: "STAMP_ADDED", amount: 1 }, + { loyaltyAccountId: account.id, type: "STAMP_REMOVED", amount: -1 }, + ], + }); + const reward = await prisma.loyaltyReward.create({ + data: { loyaltyAccountId: account.id, rewardType: "FIXED_AMOUNT", value: 5, label: "Free cut", status: "REDEEMED", redeemedAt: new Date() }, + }); + + const report = await getLoyaltyReport(prisma, { businessId: business.businessId, range }); + + expect(report.stampsIssued).toBe(2); + expect(report.stampsRemoved).toBe(1); + expect(report.rewardsEarned).toBe(1); + expect(report.rewardsRedeemed).toBe(1); + // Cleanup isn't handled by cleanupTestBusiness for loyalty rows — delete explicitly. + await prisma.loyaltyReward.delete({ where: { id: reward.id } }); + await prisma.loyaltyTransaction.deleteMany({ where: { loyaltyAccountId: account.id } }); + await prisma.loyaltyAccount.delete({ where: { id: account.id } }); + await prisma.loyaltyProgramme.delete({ where: { id: programme.id } }); + }); + }); + + describe("getNotificationReport", () => { + it("splits sent/failed counts by notification type and channel", async () => { + const { notification } = await notify(prisma, { + businessId: business.businessId, + type: NotificationType.BOOKING_CONFIRMED, + recipientUserId: customerUserId, + recipientCustomerId: customerId, + subject: "Booking confirmed", + bodyText: "Your booking is confirmed.", + idempotencyKey: `test-notify-${customerId}`, + }); + await prisma.notificationDelivery.updateMany({ + where: { notificationId: notification.id }, + data: { status: "SENT", sentAt: new Date() }, + }); + + const report = await getNotificationReport(prisma, { businessId: business.businessId, range }); + + expect(report.totalSent).toBeGreaterThanOrEqual(1); + expect(report.byType.some((t) => t.type === "BOOKING_CONFIRMED" && t.sent >= 1)).toBe(true); + }); + }); +}); diff --git a/src/services/reporting/owner-reporting.service.ts b/src/services/reporting/owner-reporting.service.ts index 358e124..6360d12 100644 --- a/src/services/reporting/owner-reporting.service.ts +++ b/src/services/reporting/owner-reporting.service.ts @@ -252,15 +252,33 @@ async function findUpcomingTimeOffConflicts(prisma: PrismaClient, businessId: st }); if (upcomingTimeOff.length === 0) return []; + // One query for every affected staff member's upcoming appointments, + // then the per-time-off overlap check happens in memory — rather than + // one `count()` round-trip per time-off row (see the Phase 7 + // performance pass). Each time-off row has its own [startDate, endDate) + // window, so the ranges can't be folded into a single SQL WHERE; the + // one shared lower bound below still lets MySQL use the + // (staffId, startAt) index to avoid scanning appointments already in + // the past. + const staffIds = [...new Set(upcomingTimeOff.map((t) => t.staffId))]; + const earliestStart = upcomingTimeOff.reduce( + (min, t) => (t.startDate < min ? t.startDate : min), + upcomingTimeOff[0]!.startDate, + ); + const candidateAppointments = await prisma.appointment.findMany({ + where: { + staffId: { in: staffIds }, + status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] }, + startAt: { gte: earliestStart }, + }, + select: { staffId: true, startAt: true }, + }); + const warnings: ConfigWarning[] = []; for (const timeOff of upcomingTimeOff) { - const conflictCount = await prisma.appointment.count({ - where: { - staffId: timeOff.staffId, - status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] }, - startAt: { lt: timeOff.endDate, gte: timeOff.startDate }, - }, - }); + const conflictCount = candidateAppointments.filter( + (a) => a.staffId === timeOff.staffId && a.startAt >= timeOff.startDate && a.startAt < timeOff.endDate, + ).length; if (conflictCount > 0) { warnings.push({ code: "TIME_OFF_CONFLICT", diff --git a/src/services/reporting/reports.service.ts b/src/services/reporting/reports.service.ts new file mode 100644 index 0000000..3930109 --- /dev/null +++ b/src/services/reporting/reports.service.ts @@ -0,0 +1,322 @@ +import type { PrismaClient } from "@/generated/prisma/client"; + +/** + * Date-range operational reports for the owner — distinct from + * owner-reporting.service.ts's single-day dashboard snapshot. Every + * function here is read-only and scoped to one businessId + [from, to] + * window; nothing writes anything. + */ + +export interface ReportRange { + from: Date; + to: Date; +} + +export interface AppointmentReport { + totalAppointments: number; + completed: number; + cancelled: number; + noShow: number; + completionRate: number; + cancellationRate: number; + noShowRate: number; + grossRevenue: string; + discountsGiven: string; + netRevenue: string; +} + +export async function getAppointmentReport( + prisma: PrismaClient, + params: { businessId: string; range: ReportRange }, +): Promise { + const appointments = await prisma.appointment.findMany({ + where: { businessId: params.businessId, startAt: { gte: params.range.from, lte: params.range.to } }, + include: { services: true }, + }); + + const completed = appointments.filter((a) => a.status === "COMPLETED"); + const cancelled = appointments.filter((a) => a.status === "CANCELLED"); + const noShow = appointments.filter((a) => a.status === "NO_SHOW"); + const total = appointments.length; + + const grossRevenue = completed.reduce( + (sum, a) => sum + a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0), + 0, + ); + const discountsGiven = completed.reduce((sum, a) => sum + (a.discountAmount ? Number(a.discountAmount) : 0), 0); + + return { + totalAppointments: total, + completed: completed.length, + cancelled: cancelled.length, + noShow: noShow.length, + completionRate: total > 0 ? Math.round((completed.length / total) * 100) : 0, + cancellationRate: total > 0 ? Math.round((cancelled.length / total) * 100) : 0, + noShowRate: total > 0 ? Math.round((noShow.length / total) * 100) : 0, + grossRevenue: grossRevenue.toFixed(2), + discountsGiven: discountsGiven.toFixed(2), + netRevenue: (grossRevenue - discountsGiven).toFixed(2), + }; +} + +export interface ServiceReportRow { + serviceId: string; + name: string; + timesCompleted: number; + revenue: string; +} + +/** Only counts COMPLETED appointments — a cancelled booking never earned revenue for a service. */ +export async function getServiceReport( + prisma: PrismaClient, + params: { businessId: string; range: ReportRange }, +): Promise { + const lines = await prisma.appointmentService.findMany({ + where: { + appointment: { + businessId: params.businessId, + status: "COMPLETED", + startAt: { gte: params.range.from, lte: params.range.to }, + }, + }, + include: { service: { select: { id: true, name: true } } }, + }); + + const byService = new Map(); + for (const line of lines) { + const existing = byService.get(line.serviceId); + const revenue = Number(line.priceAtBooking); + if (existing) { + existing.timesCompleted += 1; + existing.revenue = (Number(existing.revenue) + revenue).toFixed(2); + } else { + byService.set(line.serviceId, { + serviceId: line.serviceId, + name: line.service.name, + timesCompleted: 1, + revenue: revenue.toFixed(2), + }); + } + } + return [...byService.values()].sort((a, b) => Number(b.revenue) - Number(a.revenue)); +} + +export interface StaffReportRow { + staffId: string; + name: string; + completed: number; + cancelled: number; + noShow: number; + revenue: string; +} + +/** Revenue attributed to a staff member is their completed appointments' net total (subtotal minus any redeemed reward), same figure the appointment detail pages show as "Total". */ +export async function getStaffReport( + prisma: PrismaClient, + params: { businessId: string; range: ReportRange }, +): Promise { + const appointments = await prisma.appointment.findMany({ + where: { businessId: params.businessId, startAt: { gte: params.range.from, lte: params.range.to } }, + include: { services: true, staff: { include: { user: { select: { name: true } } } } }, + }); + + const byStaff = new Map(); + for (const a of appointments) { + const row = byStaff.get(a.staffId) ?? { + staffId: a.staffId, + name: a.staff.user.name ?? "Staff", + completed: 0, + cancelled: 0, + noShow: 0, + revenue: "0.00", + }; + if (a.status === "COMPLETED") { + row.completed += 1; + const subtotal = a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0); + const discount = a.discountAmount ? Number(a.discountAmount) : 0; + row.revenue = (Number(row.revenue) + (subtotal - discount)).toFixed(2); + } else if (a.status === "CANCELLED") { + row.cancelled += 1; + } else if (a.status === "NO_SHOW") { + row.noShow += 1; + } + byStaff.set(a.staffId, row); + } + return [...byStaff.values()].sort((a, b) => Number(b.revenue) - Number(a.revenue)); +} + +export interface CustomerReport { + newCustomers: number; + returningCustomers: number; + topCustomers: { customerId: string; name: string; visits: number; spend: string }[]; +} + +/** + * "New" = a customer whose first-ever appointment with this business falls + * inside the range; "returning" = a customer who visited inside the range + * but already had an appointment before the range started. A customer with + * no appointment in the range at all counts as neither. + */ +export async function getCustomerReport( + prisma: PrismaClient, + params: { businessId: string; range: ReportRange }, +): Promise { + const appointmentsInRange = await prisma.appointment.findMany({ + where: { + businessId: params.businessId, + status: { notIn: ["CANCELLED"] }, + startAt: { gte: params.range.from, lte: params.range.to }, + }, + include: { services: true, customer: { select: { firstName: true, lastName: true } } }, + }); + + const customerIds = [...new Set(appointmentsInRange.map((a) => a.customerId))]; + if (customerIds.length === 0) { + return { newCustomers: 0, returningCustomers: 0, topCustomers: [] }; + } + + const priorAppointments = await prisma.appointment.groupBy({ + by: ["customerId"], + where: { + businessId: params.businessId, + customerId: { in: customerIds }, + status: { notIn: ["CANCELLED"] }, + startAt: { lt: params.range.from }, + }, + _count: true, + }); + const hadPriorVisit = new Set(priorAppointments.map((p) => p.customerId)); + + let newCustomers = 0; + let returningCustomers = 0; + for (const customerId of customerIds) { + if (hadPriorVisit.has(customerId)) returningCustomers += 1; + else newCustomers += 1; + } + + const byCustomer = new Map(); + for (const a of appointmentsInRange) { + const row = byCustomer.get(a.customerId) ?? { + customerId: a.customerId, + name: `${a.customer.firstName} ${a.customer.lastName}`, + visits: 0, + spend: "0.00", + }; + row.visits += 1; + if (a.status === "COMPLETED") { + const subtotal = a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0); + const discount = a.discountAmount ? Number(a.discountAmount) : 0; + row.spend = (Number(row.spend) + (subtotal - discount)).toFixed(2); + } + byCustomer.set(a.customerId, row); + } + const topCustomers = [...byCustomer.values()].sort((a, b) => Number(b.spend) - Number(a.spend)).slice(0, 10); + + return { newCustomers, returningCustomers, topCustomers }; +} + +export interface LoyaltyReport { + stampsIssued: number; + stampsRemoved: number; + rewardsEarned: number; + rewardsRedeemed: number; + rewardsExpired: number; +} + +export async function getLoyaltyReport( + prisma: PrismaClient, + params: { businessId: string; range: ReportRange }, +): Promise { + const accountScope = { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } } }; + const inRange = { createdAt: { gte: params.range.from, lte: params.range.to } }; + + const [stampTransactions, rewardsEarned, rewardsRedeemed, rewardsExpired] = await Promise.all([ + prisma.loyaltyTransaction.findMany({ + where: { ...accountScope, ...inRange, type: { in: ["STAMP_ADDED", "STAMP_REMOVED"] } }, + select: { amount: true }, + }), + prisma.loyaltyReward.count({ + where: { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } }, ...inRange }, + }), + prisma.loyaltyReward.count({ + where: { + loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } }, + redeemedAt: { gte: params.range.from, lte: params.range.to }, + }, + }), + // No expiredAt column exists — the periodic sweep only flips status, so + // this approximates "expired in range" by expiresAt falling in range + // for a reward that is now EXPIRED. Good enough for a trend, not exact + // to the second the sweep ran. + prisma.loyaltyReward.count({ + where: { + loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } }, + status: "EXPIRED", + expiresAt: { gte: params.range.from, lte: params.range.to }, + }, + }), + ]); + + return { + stampsIssued: stampTransactions.filter((t) => t.amount > 0).reduce((sum, t) => sum + t.amount, 0), + stampsRemoved: Math.abs(stampTransactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + t.amount, 0)), + rewardsEarned, + rewardsRedeemed, + rewardsExpired, + }; +} + +export interface NotificationReportRow { + type: string; + sent: number; + failed: number; +} + +export interface NotificationReport { + totalSent: number; + totalFailed: number; + byType: NotificationReportRow[]; + byChannel: { channel: string; sent: number; failed: number }[]; +} + +export async function getNotificationReport( + prisma: PrismaClient, + params: { businessId: string; range: ReportRange }, +): Promise { + const deliveries = await prisma.notificationDelivery.findMany({ + where: { + notification: { businessId: params.businessId }, + createdAt: { gte: params.range.from, lte: params.range.to }, + }, + select: { status: true, channel: true, notification: { select: { type: true } } }, + }); + + const byType = new Map(); + const byChannel = new Map(); + let totalSent = 0; + let totalFailed = 0; + + for (const d of deliveries) { + const isSent = d.status === "SENT"; + const isFailed = d.status === "FAILED"; + if (isSent) totalSent += 1; + if (isFailed) totalFailed += 1; + + const typeRow = byType.get(d.notification.type) ?? { type: d.notification.type, sent: 0, failed: 0 }; + if (isSent) typeRow.sent += 1; + if (isFailed) typeRow.failed += 1; + byType.set(d.notification.type, typeRow); + + const channelRow = byChannel.get(d.channel) ?? { channel: d.channel, sent: 0, failed: 0 }; + if (isSent) channelRow.sent += 1; + if (isFailed) channelRow.failed += 1; + byChannel.set(d.channel, channelRow); + } + + return { + totalSent, + totalFailed, + byType: [...byType.values()].sort((a, b) => b.sent + b.failed - (a.sent + a.failed)), + byChannel: [...byChannel.values()].sort((a, b) => b.sent + b.failed - (a.sent + a.failed)), + }; +} diff --git a/src/services/scheduling/__tests__/booking-engine.integration.test.ts b/src/services/scheduling/__tests__/booking-engine.integration.test.ts index da2e801..9645dd9 100644 --- a/src/services/scheduling/__tests__/booking-engine.integration.test.ts +++ b/src/services/scheduling/__tests__/booking-engine.integration.test.ts @@ -360,6 +360,50 @@ describe("booking engine (integration)", () => { }); expect(laterValidation.valid).toBe(true); }); + + // Regression test for a real bug found via a MySQL E2E run (see + // docs/phase-7-summary.md): with an asymmetric buffer + // (bufferBeforeMinutes 0, bufferAfterMinutes > 0 — the seeded + // production default), assertNoConflict in scheduling.service.ts was + // applying the wrong side's buffer to the candidate appointment, + // wrongly rejecting a slot placed immediately BEFORE an existing + // appointment using that appointment's *after* buffer instead of its + // (zero) *before* buffer. The test above only exercises + // validateBookingSlot (AvailabilityService) for a candidate placed + // AFTER an existing appointment; createBooking's actual DB-level + // conflict check (assertNoConflict) was never exercised for the + // "before" direction until now. + it("does not apply a later appointment's after-buffer to a candidate slot placed immediately before it", async () => { + const { staffId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + + await prisma.bookingRule.update({ + where: { businessId: business.businessId }, + data: { bufferBeforeMinutes: 0, bufferAfterMinutes: 5 }, + }); + + // Book the later appointment first: 10:30–11:00. + const later = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], // 30 min + startAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone), + }); + expect(later.success).toBe(true); + + // A candidate ending exactly when the later appointment starts (10:00–10:30) needs no gap — + // only the LATER appointment's own trailing (after) buffer applies once IT finishes, not + // before it starts, since bufferBeforeMinutes is 0. + const earlier = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + expect(earlier.success).toBe(true); + }); }); describe("booking validation", () => { diff --git a/src/services/scheduling/__tests__/booking-notifications.integration.test.ts b/src/services/scheduling/__tests__/booking-notifications.integration.test.ts new file mode 100644 index 0000000..f72d0f2 --- /dev/null +++ b/src/services/scheduling/__tests__/booking-notifications.integration.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Weekday } from "@/generated/prisma/enums"; +import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time"; +import type { CalendarDate } from "@/lib/time/business-time"; +import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service"; +import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestGuestCustomer, createTestStaff, prisma } from "./fixtures"; +import type { TestBusiness } from "./fixtures"; + +function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate { + let date = addDays(todayIn(timezone), minDaysAhead); + for (let i = 0; i < 8; i++) { + if (weekdayOf(date) === target) return date; + date = addDays(date, 1); + } + throw new Error("unreachable"); +} + +/** + * Verifies booking.service.ts actually queues the right Notification rows + * for the right recipients on create/reschedule/cancel — see + * booking-notifications.ts. The full channel-resolution/preference logic + * is covered by notification.service.integration.test.ts; this file is + * specifically "does the booking workflow call notify() correctly". + */ +describe("booking notifications (integration)", () => { + let business: TestBusiness; + let monday: CalendarDate; + let guestProfileIds: string[]; + + beforeEach(async () => { + business = await createTestBusiness(); + monday = nextWeekday(business.timezone, Weekday.MONDAY); + guestProfileIds = []; + }); + + afterEach(async () => { + await cleanupTestBusiness(business); + if (guestProfileIds.length > 0) { + await prisma.customerProfile.deleteMany({ where: { id: { in: guestProfileIds } } }).catch(() => {}); + } + }); + + async function grantOwnerPermission(userId: string) { + const role = await prisma.role.create({ + data: { + businessId: business.businessId, + name: "Test Owner", + permissions: { create: [{ permission: "VIEW_REPORTING" }] }, + }, + }); + await prisma.staffProfile.update({ where: { userId }, data: { roleId: role.id } }); + } + + it("creating a booking notifies the customer, the staff member, and every owner", async () => { + const { staffId, userId: staffUserId } = await createTestStaff(business); + const { customerId, userId: customerUserId } = await createTestCustomer(business); + await grantOwnerPermission(staffUserId); // this staff member is also the "owner" for this test + + const result = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + expect(result.success).toBe(true); + if (!result.success) return; + + const notifications = await prisma.notification.findMany({ where: { appointmentId: result.appointment.id } }); + const types = notifications.map((n) => n.type).sort(); + // A booking far enough out (monday is >= 7 days ahead — see nextWeekday) also schedules both + // 24h/2h reminders for the customer and the staff member — see reminder-scheduling.ts. + expect(types).toEqual( + [ + "BOOKING_CONFIRMED", + "BOOKING_REMINDER", + "BOOKING_REMINDER", + "OWNER_NEW_BOOKING", + "STAFF_APPOINTMENT_REMINDER", + "STAFF_APPOINTMENT_REMINDER", + "STAFF_NEW_APPOINTMENT", + ].sort(), + ); + + const customerNotification = notifications.find((n) => n.type === "BOOKING_CONFIRMED"); + expect(customerNotification?.recipientUserId).toBe(customerUserId); + expect(customerNotification?.recipientCustomerId).toBe(customerId); + + const staffNotification = notifications.find((n) => n.type === "STAFF_NEW_APPOINTMENT"); + expect(staffNotification?.recipientUserId).toBe(staffUserId); + + // Same staff member holds both roles in this test, so OWNER_NEW_BOOKING also targets them. + const ownerNotification = notifications.find((n) => n.type === "OWNER_NEW_BOOKING"); + expect(ownerNotification?.recipientUserId).toBe(staffUserId); + }); + + it("a guest booking's customer notification has no recipientUserId but does have a deep link with the guest token", async () => { + const { staffId } = await createTestStaff(business); + const guest = await createTestGuestCustomer(); + guestProfileIds.push(guest.customerId); + + const result = await createBooking(prisma, { + businessId: business.businessId, + customerId: guest.customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.guestAccessToken).toBeTruthy(); + + const notification = await prisma.notification.findFirst({ + where: { appointmentId: result.appointment.id, type: "BOOKING_CONFIRMED" }, + }); + expect(notification?.recipientUserId).toBeNull(); + expect(notification?.recipientCustomerId).toBe(guest.customerId); + expect(notification?.deepLinkPath).toContain(result.guestAccessToken); + }); + + it("requireApproval creates a BOOKING_PENDING_APPROVAL notification instead of BOOKING_CONFIRMED", async () => { + await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } }); + const { staffId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + + const result = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + expect(result.success).toBe(true); + if (!result.success) return; + + const notification = await prisma.notification.findFirst({ + where: { appointmentId: result.appointment.id, recipientCustomerId: customerId }, + }); + expect(notification?.type).toBe("BOOKING_PENDING_APPROVAL"); + }); + + it("rescheduling notifies the customer and staff, and a second reschedule creates a fresh notification rather than deduping", async () => { + const { staffId, userId: staffUserId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + + const created = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + if (!created.success) throw new Error("setup failed"); + + const first = await rescheduleBooking(prisma, { + appointmentId: created.appointment.id, + startAt: wallClockToInstant(monday, 11 * 60, business.timezone), + }); + expect(first.success).toBe(true); + + const second = await rescheduleBooking(prisma, { + appointmentId: created.appointment.id, + startAt: wallClockToInstant(monday, 13 * 60, business.timezone), + }); + expect(second.success).toBe(true); + + const rescheduledNotifications = await prisma.notification.findMany({ + where: { appointmentId: created.appointment.id, type: "BOOKING_RESCHEDULED" }, + }); + expect(rescheduledNotifications).toHaveLength(2); + + const staffNotifications = await prisma.notification.findMany({ + where: { appointmentId: created.appointment.id, type: "STAFF_APPOINTMENT_RESCHEDULED", recipientUserId: staffUserId }, + }); + expect(staffNotifications).toHaveLength(2); + }); + + it("cancelling notifies the customer, staff, and every owner, and only once even if called again on an already-cancelled appointment", async () => { + const { staffId, userId: staffUserId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + await grantOwnerPermission(staffUserId); + + const created = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + if (!created.success) throw new Error("setup failed"); + + const cancelled = await cancelBooking(prisma, { appointmentId: created.appointment.id }); + expect(cancelled.success).toBe(true); + + const cancelNotifications = await prisma.notification.findMany({ + where: { appointmentId: created.appointment.id, type: { in: ["BOOKING_CANCELLED", "STAFF_APPOINTMENT_CANCELLED", "OWNER_BOOKING_CANCELLED"] } }, + }); + expect(cancelNotifications).toHaveLength(3); + + // Cancelling again is rejected by cancelBooking itself (already CANCELLED) — never reaches notify() a second time. + const secondAttempt = await cancelBooking(prisma, { appointmentId: created.appointment.id }); + expect(secondAttempt.success).toBe(false); + const stillOnlyOnce = await prisma.notification.count({ + where: { appointmentId: created.appointment.id, type: "BOOKING_CANCELLED" }, + }); + expect(stillOnlyOnce).toBe(1); + }); +}); diff --git a/src/services/scheduling/__tests__/fixtures.ts b/src/services/scheduling/__tests__/fixtures.ts index 8e124fa..574d1f1 100644 --- a/src/services/scheduling/__tests__/fixtures.ts +++ b/src/services/scheduling/__tests__/fixtures.ts @@ -1,15 +1,17 @@ import "dotenv/config"; import { PrismaClient } from "@/generated/prisma/client"; -import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import { buildMysqlPoolConfig } from "@/lib/db/mysql-pool-config"; import { Weekday } from "@/generated/prisma/enums"; /** - * Integration-test fixtures — real Postgres, real transactions. Not used + * Integration-test fixtures — real MySQL, real transactions. Not used * by the unit test suite (`npm test`); only by `npm run test:integration`. - * See docs/phase-3-summary.md for how to point this at a database. + * See docs/phase-7-summary.md for how to point this at a database + * (migrated from PostgreSQL to MySQL in Phase 7). */ -const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL)); export const prisma = new PrismaClient({ adapter }); let counter = 0; @@ -176,6 +178,16 @@ export async function cleanupTestBusiness(business: TestBusiness): Promise // appointments first lets that cascade complete instead of the business // delete silently failing and leaking a "Test Barbershop" row per run. await prisma.appointment.deleteMany({ where: { businessId: business.businessId } }).catch(() => {}); + // StaffProfile.roleId is ALSO onDelete: Restrict, and Business cascades + // to both Role and StaffProfile. On Postgres, the database's own native + // FK cascade resolver orders this correctly; empirically (Phase 7, see + // docs/phase-7-summary.md), Prisma 7's client-engine-runtime interprets + // cascading deletes itself against MySQL and does NOT reliably delete + // StaffProfile before Role, leaving a P2003 "Foreign key constraint + // violated on (roleId)" that this function's own .catch(() => {}) was + // silently swallowing — leaking a "Test Barbershop" business per run. + // Deleting StaffProfile explicitly first removes the ordering ambiguity. + await prisma.staffProfile.deleteMany({ where: { businessId: business.businessId } }).catch(() => {}); await prisma.business.delete({ where: { id: business.businessId } }).catch(() => {}); if (business.createdUserIds.length > 0) { await prisma.user.deleteMany({ where: { id: { in: business.createdUserIds } } }).catch(() => {}); diff --git a/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts b/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts new file mode 100644 index 0000000..d0f4da4 --- /dev/null +++ b/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Weekday } from "@/generated/prisma/enums"; +import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time"; +import type { CalendarDate } from "@/lib/time/business-time"; +import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service"; +import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures"; +import type { TestBusiness } from "./fixtures"; + +function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate { + let date = addDays(todayIn(timezone), minDaysAhead); + for (let i = 0; i < 8; i++) { + if (weekdayOf(date) === target) return date; + date = addDays(date, 1); + } + throw new Error("unreachable"); +} + +async function reminderDeliveries(appointmentId: string) { + return prisma.notificationDelivery.findMany({ + where: { + notification: { appointmentId, type: { in: ["BOOKING_REMINDER", "STAFF_APPOINTMENT_REMINDER"] } }, + channel: { in: ["EMAIL", "PUSH"] }, + }, + include: { notification: true }, + }); +} + +describe("reminder scheduling (integration)", () => { + let business: TestBusiness; + let monday: CalendarDate; + + beforeEach(async () => { + business = await createTestBusiness(); + monday = nextWeekday(business.timezone, Weekday.MONDAY); + }); + + afterEach(async () => { + await cleanupTestBusiness(business); + }); + + it("schedules 24h and 2h EMAIL reminders for both customer and staff, as SCHEDULED (not PENDING)", async () => { + const { staffId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + + const result = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + if (!result.success) throw new Error("setup failed"); + + const deliveries = await reminderDeliveries(result.appointment.id); + expect(deliveries).toHaveLength(4); // 2 offsets × (customer + staff) + for (const d of deliveries) { + expect(d.status).toBe("SCHEDULED"); + expect(d.scheduledFor).not.toBeNull(); + expect(d.scheduledFor!.getTime()).toBeLessThan(result.appointment.startAt.getTime()); + } + }); + + it("does not schedule reminders for a PENDING (requireApproval) booking", async () => { + await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } }); + const { staffId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + + const result = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + if (!result.success) throw new Error("setup failed"); + + const deliveries = await reminderDeliveries(result.appointment.id); + expect(deliveries).toHaveLength(0); + }); + + it("does not schedule a reminder offset that would already be in the past", async () => { + const { staffId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + + // Book for 1 hour from now — the 24h and 2h-before offsets are both already past. + const result = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: new Date(Date.now() + 3 * 60 * 60_000), // 3h out, staff working hours permitting — see note below + }).catch(() => null); + + // This may fail validation depending on min-notice/working-hours fixtures; only assert the + // reminder-skipping behaviour when the booking itself actually succeeded. + if (!result || !result.success) return; + const deliveries = await reminderDeliveries(result.appointment.id); + // 3h out: the 2h-before offset is still in the future, the 24h-before offset is in the past. + expect(deliveries.length).toBeLessThanOrEqual(2); + expect(deliveries.every((d) => d.notification.idempotencyKey.includes("reminder-2h"))).toBe(true); + }); + + it("rescheduling cancels the old reminders and schedules fresh ones for the new time", async () => { + const { staffId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + + const created = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + if (!created.success) throw new Error("setup failed"); + + const originalDeliveries = await reminderDeliveries(created.appointment.id); + expect(originalDeliveries).toHaveLength(4); + + const rescheduled = await rescheduleBooking(prisma, { + appointmentId: created.appointment.id, + startAt: wallClockToInstant(monday, 14 * 60, business.timezone), + }); + expect(rescheduled.success).toBe(true); + if (!rescheduled.success) return; + + const allDeliveries = await reminderDeliveries(created.appointment.id); + const cancelledCount = allDeliveries.filter((d) => d.status === "CANCELLED").length; + const scheduledCount = allDeliveries.filter((d) => d.status === "SCHEDULED").length; + expect(cancelledCount).toBe(4); // the original 4, now superseded + expect(scheduledCount).toBe(4); // 4 fresh ones for the new time + + const freshOnes = allDeliveries.filter((d) => d.status === "SCHEDULED"); + for (const d of freshOnes) { + expect(d.scheduledFor!.getTime()).toBeLessThan(rescheduled.appointment.startAt.getTime()); + } + }); + + it("cancelling the appointment cancels its scheduled reminders", async () => { + const { staffId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + + const created = await createBooking(prisma, { + businessId: business.businessId, + customerId, + staffId, + serviceIds: [business.haircutServiceId], + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + if (!created.success) throw new Error("setup failed"); + + const cancelled = await cancelBooking(prisma, { appointmentId: created.appointment.id }); + expect(cancelled.success).toBe(true); + + const deliveries = await reminderDeliveries(created.appointment.id); + expect(deliveries.every((d) => d.status === "CANCELLED")).toBe(true); + expect(deliveries).toHaveLength(4); + }); +}); diff --git a/src/services/scheduling/booking-notifications.ts b/src/services/scheduling/booking-notifications.ts new file mode 100644 index 0000000..27001ef --- /dev/null +++ b/src/services/scheduling/booking-notifications.ts @@ -0,0 +1,235 @@ +import type { PrismaClient } from "@/generated/prisma/client"; +import { NotificationType } from "@/generated/prisma/enums"; +import { notifySafely } from "@/services/notifications/notification.service"; +import { getOwnerUserIds } from "@/services/notifications/recipients"; +import type { Appointment } from "./booking.service"; +import { scheduleAppointmentReminders } from "./reminder-scheduling"; + +/** + * Builds and queues the Notification rows a booking-domain event + * produces — called by booking.service.ts strictly AFTER the appointment + * transaction has already committed, using notifySafely so a notification + * failure can never roll back or fail an otherwise-successful booking. + * Owns only "who gets told what, in what words" — the actual email/push + * send happens later, out of process (see reminder-worker.ts). + */ + +function formatDateLabel(date: Date, timezone: string): string { + return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone }); +} + +function formatTimeLabel(date: Date, timezone: string): string { + return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone }); +} + +async function getBusinessTimezone(prisma: PrismaClient, businessId: string): Promise { + const business = await prisma.business.findUnique({ where: { id: businessId }, select: { timezone: true } }); + return business?.timezone ?? "Europe/London"; +} + +function serviceNames(appointment: Appointment): string { + return appointment.services.map((s) => s.service.name).join(", "); +} + +export async function notifyBookingCreated( + prisma: PrismaClient, + appointment: Appointment, + options: { guestAccessToken?: string } = {}, +): Promise { + const timezone = await getBusinessTimezone(prisma, appointment.businessId); + const dateLabel = formatDateLabel(appointment.startAt, timezone); + const timeLabel = formatTimeLabel(appointment.startAt, timezone); + const services = serviceNames(appointment); + const isPending = appointment.status === "PENDING"; + const customerDeepLink = `/appointments/${appointment.id}${options.guestAccessToken ? `?token=${options.guestAccessToken}` : ""}`; + + await notifySafely(prisma, { + businessId: appointment.businessId, + type: isPending ? NotificationType.BOOKING_PENDING_APPROVAL : NotificationType.BOOKING_CONFIRMED, + recipientUserId: appointment.customer.userId ?? undefined, + recipientCustomerId: appointment.customer.id, + appointmentId: appointment.id, + subject: isPending ? "Booking request received" : "Booking confirmed", + bodyText: isPending + ? `Your request for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} is awaiting approval.` + : `Your appointment for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} is confirmed.`, + deepLinkPath: customerDeepLink, + idempotencyKey: `notify:booking-created:${appointment.id}`, + }); + + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.STAFF_NEW_APPOINTMENT, + recipientUserId: appointment.staff.userId, + appointmentId: appointment.id, + subject: "New appointment", + bodyText: `New appointment: ${services} with ${appointment.customer.firstName} ${appointment.customer.lastName} on ${dateLabel} at ${timeLabel}.`, + deepLinkPath: `/staff/appointments/${appointment.id}`, + idempotencyKey: `notify:staff-new-appointment:${appointment.id}`, + }); + + const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId); + for (const ownerUserId of ownerUserIds) { + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.OWNER_NEW_BOOKING, + recipientUserId: ownerUserId, + appointmentId: appointment.id, + subject: "New booking", + bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName} booked ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel}.`, + deepLinkPath: `/owner/appointments/${appointment.id}`, + idempotencyKey: `notify:owner-new-booking:${appointment.id}:${ownerUserId}`, + }); + } +} + +export async function notifyBookingRescheduled(prisma: PrismaClient, appointment: Appointment): Promise { + const timezone = await getBusinessTimezone(prisma, appointment.businessId); + const dateLabel = formatDateLabel(appointment.startAt, timezone); + const timeLabel = formatTimeLabel(appointment.startAt, timezone); + const services = serviceNames(appointment); + // Included in the key so a second reschedule creates a fresh notification + // rather than being deduped against the first one's key. + const versionSuffix = appointment.startAt.getTime(); + + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.BOOKING_RESCHEDULED, + recipientUserId: appointment.customer.userId ?? undefined, + recipientCustomerId: appointment.customer.id, + appointmentId: appointment.id, + subject: "Booking rescheduled", + bodyText: `Your appointment for ${services} with ${appointment.staff.user.name} has been moved to ${dateLabel} at ${timeLabel}.`, + deepLinkPath: `/appointments/${appointment.id}`, + idempotencyKey: `notify:booking-rescheduled:${appointment.id}:${versionSuffix}`, + }); + + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED, + recipientUserId: appointment.staff.userId, + appointmentId: appointment.id, + subject: "Appointment rescheduled", + bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) has moved to ${dateLabel} at ${timeLabel}.`, + deepLinkPath: `/staff/appointments/${appointment.id}`, + idempotencyKey: `notify:staff-appointment-rescheduled:${appointment.id}:${versionSuffix}`, + }); +} + +export async function notifyBookingCancelled( + prisma: PrismaClient, + appointment: Appointment, + options: { wasLateCancellation?: boolean; reason?: string } = {}, +): Promise { + const timezone = await getBusinessTimezone(prisma, appointment.businessId); + const dateLabel = formatDateLabel(appointment.startAt, timezone); + const timeLabel = formatTimeLabel(appointment.startAt, timezone); + const services = serviceNames(appointment); + const lateNote = options.wasLateCancellation ? " This was a late cancellation." : ""; + + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.BOOKING_CANCELLED, + recipientUserId: appointment.customer.userId ?? undefined, + recipientCustomerId: appointment.customer.id, + appointmentId: appointment.id, + subject: "Booking cancelled", + bodyText: `Your appointment for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} has been cancelled.${lateNote}`, + deepLinkPath: `/appointments/${appointment.id}`, + idempotencyKey: `notify:booking-cancelled:${appointment.id}`, + }); + + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.STAFF_APPOINTMENT_CANCELLED, + recipientUserId: appointment.staff.userId, + appointmentId: appointment.id, + subject: "Appointment cancelled", + bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) on ${dateLabel} at ${timeLabel} was cancelled.`, + deepLinkPath: `/staff/appointments/${appointment.id}`, + idempotencyKey: `notify:staff-appointment-cancelled:${appointment.id}`, + }); + + const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId); + for (const ownerUserId of ownerUserIds) { + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.OWNER_BOOKING_CANCELLED, + recipientUserId: ownerUserId, + appointmentId: appointment.id, + subject: "Booking cancelled", + bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} was cancelled.${lateNote}`, + deepLinkPath: `/owner/appointments/${appointment.id}`, + idempotencyKey: `notify:owner-booking-cancelled:${appointment.id}:${ownerUserId}`, + }); + } +} + +/** A PENDING booking (requireApproval) is approved — see AppointmentService.confirm, only ever reachable from PENDING. */ +export async function notifyBookingApproved(prisma: PrismaClient, appointment: Appointment): Promise { + const timezone = await getBusinessTimezone(prisma, appointment.businessId); + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.BOOKING_APPROVED, + recipientUserId: appointment.customer.userId ?? undefined, + recipientCustomerId: appointment.customer.id, + appointmentId: appointment.id, + subject: "Booking approved", + bodyText: `Your appointment for ${serviceNames(appointment)} with ${appointment.staff.user.name} on ${formatDateLabel(appointment.startAt, timezone)} at ${formatTimeLabel(appointment.startAt, timezone)} has been approved.`, + deepLinkPath: `/appointments/${appointment.id}`, + idempotencyKey: `notify:booking-approved:${appointment.id}`, + }); + // Approval is the first point a PENDING (requireApproval) booking becomes + // CONFIRMED — reminders were never scheduled at creation time for it (see + // scheduleAppointmentReminders' own CONFIRMED-only guard), so this is + // where that has to happen instead. + await scheduleAppointmentReminders(prisma, appointment); +} + +/** The owner reassigns an appointment to a different staff member — distinct from a plain time reschedule (see notifyBookingRescheduled, which still also fires for a reassignment since the underlying rescheduleBooking() call can't distinguish "why", see ownerRescheduleAppointmentAction). This tells the customer specifically who they're now booked with. */ +export async function notifyBookingBarberChanged(prisma: PrismaClient, appointment: Appointment): Promise { + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.BOOKING_BARBER_CHANGED, + recipientUserId: appointment.customer.userId ?? undefined, + recipientCustomerId: appointment.customer.id, + appointmentId: appointment.id, + subject: "Your barber has changed", + bodyText: `Your appointment for ${serviceNames(appointment)} is now with ${appointment.staff.user.name}.`, + deepLinkPath: `/appointments/${appointment.id}`, + idempotencyKey: `notify:booking-barber-changed:${appointment.id}:${appointment.staffId}`, + }); +} + +/** The appointment reaches COMPLETED — a distinct, non-loyalty "thanks for visiting" notice (see LoyaltyService for the separate stamp/reward notifications, which fire independently). */ +export async function notifyBookingCompleted(prisma: PrismaClient, appointment: Appointment): Promise { + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.BOOKING_COMPLETED, + recipientUserId: appointment.customer.userId ?? undefined, + recipientCustomerId: appointment.customer.id, + appointmentId: appointment.id, + subject: "Thanks for visiting", + bodyText: `Thanks for visiting ${appointment.staff.user.name ? `and seeing ${appointment.staff.user.name}` : ""} — hope you loved your ${serviceNames(appointment)}!`, + deepLinkPath: `/appointments/${appointment.id}`, + idempotencyKey: `notify:booking-completed:${appointment.id}`, + }); +} + +/** Customer marked NO_SHOW — owner-facing only (the barber already knows, they're the one who marked it). */ +export async function notifyAppointmentNoShow(prisma: PrismaClient, appointment: Appointment): Promise { + const timezone = await getBusinessTimezone(prisma, appointment.businessId); + const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId); + for (const ownerUserId of ownerUserIds) { + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.OWNER_NO_SHOW, + recipientUserId: ownerUserId, + appointmentId: appointment.id, + subject: "No-show recorded", + bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName} didn't show up for their appointment with ${appointment.staff.user.name} on ${formatDateLabel(appointment.startAt, timezone)} at ${formatTimeLabel(appointment.startAt, timezone)}.`, + deepLinkPath: `/owner/appointments/${appointment.id}`, + idempotencyKey: `notify:owner-no-show:${appointment.id}:${ownerUserId}`, + }); + } +} diff --git a/src/services/scheduling/booking.service.ts b/src/services/scheduling/booking.service.ts index 2dd5292..6a41562 100644 --- a/src/services/scheduling/booking.service.ts +++ b/src/services/scheduling/booking.service.ts @@ -7,7 +7,10 @@ import { resolveBookingContext, validateBookingSlot, } from "./availability.service"; +import { notifyBookingCancelled, notifyBookingCreated, notifyBookingRescheduled } from "./booking-notifications"; import { isLateCancellation, isLateReschedule } from "./business-rules.service"; +import { issueGuestAccessToken } from "./guest-access.service"; +import { cancelScheduledReminders, scheduleAppointmentReminders } from "./reminder-scheduling"; import { DoubleBookingError, createAppointment, rescheduleAppointment } from "./scheduling.service"; import { countStaffAppointmentsOnDate } from "./staff-availability.service"; import { fail, ok } from "./types"; @@ -35,7 +38,13 @@ export interface CreateBookingInput { export type Appointment = NonNullable>>; export type BookingResult = - { success: true; appointment: Appointment } | { success: false; validation: ValidationResult }; + | { + success: true; + appointment: Appointment; + /** Raw guest access token — set only when the appointment's customer has no linked User. See guest-access.service.ts; never logged, never persisted beyond its hash. */ + guestAccessToken?: string; + } + | { success: false; validation: ValidationResult }; /** Among staff free at this slot, prefer whoever has the fewest appointments that day — spreads "any barber" bookings out instead of always picking the same one. */ async function pickLeastBusyStaff( @@ -144,7 +153,12 @@ export async function createBooking( // Re-fetch through the shared include shape so callers get the same // appointment shape regardless of which workflow produced it. const appointment = await getAppointmentById(prisma, created.id); - return { success: true, appointment: appointment! }; + const guestAccessToken = created.customer.userId + ? undefined + : await issueGuestAccessToken(prisma, { appointmentId: created.id, appointmentEndAt: endAt }); + await notifyBookingCreated(prisma, appointment!, { guestAccessToken }); + await scheduleAppointmentReminders(prisma, appointment!); + return { success: true, appointment: appointment!, guestAccessToken }; } catch (error) { if (error instanceof DoubleBookingError) { return { @@ -213,6 +227,9 @@ export async function rescheduleBooking( effectiveBuffer: context.effectiveBuffer, }); const appointment = await getAppointmentById(prisma, input.appointmentId); + await notifyBookingRescheduled(prisma, appointment!); + await cancelScheduledReminders(prisma, input.appointmentId); + await scheduleAppointmentReminders(prisma, appointment!); return { success: true, appointment: appointment! }; } catch (error) { if (error instanceof DoubleBookingError) { @@ -302,6 +319,11 @@ export async function cancelBooking( }, }); + await notifyBookingCancelled(prisma, appointment, { + wasLateCancellation, + reason: input.reason, + }); + await cancelScheduledReminders(prisma, input.appointmentId); return { success: true, appointment, wasLateCancellation, validation: ok() }; } diff --git a/src/services/scheduling/guest-access.service.ts b/src/services/scheduling/guest-access.service.ts new file mode 100644 index 0000000..ecb6f85 --- /dev/null +++ b/src/services/scheduling/guest-access.service.ts @@ -0,0 +1,89 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { PrismaClient } from "@/generated/prisma/client"; + +/** + * Single-purpose access tokens for guest (no linked User) appointments — + * closes the Phase 4 gap where a guest's only credential was the + * appointment's own id (see docs/phase-4-summary.md, "Security", and + * GuestAppointmentAccessToken in schema.prisma). A registered customer's + * access is still session-based and untouched by this file — see + * src/features/booking/authorization.ts. + * + * Only the SHA-256 hash of the token is ever persisted. The raw token + * exists solely in this module's return values, for a caller to embed in + * a link (the booking confirmation page today; a future notification + * email) and never log or store elsewhere. + */ + +const TOKEN_BYTES = 32; +const VALID_FOR_DAYS_AFTER_APPOINTMENT = 90; + +function hashToken(rawToken: string): string { + return createHash("sha256").update(rawToken).digest("hex"); +} + +/** + * Issues this appointment's guest access token, generating one if none + * exists or overwriting the previous hash if one does — deliberately a + * single row per appointment (see the model's `appointmentId @unique`), + * so re-issuing invalidates any earlier copy of the raw token rather than + * leaving multiple valid links outstanding. Reschedule/cancel do not + * currently re-issue — the same link a guest already has keeps working + * across those, which is the more useful default until Phase 7's + * notification emails (not yet built) give a concrete reason to rotate on + * a specific event. + */ +export async function issueGuestAccessToken( + prisma: PrismaClient, + params: { appointmentId: string; appointmentEndAt: Date }, +): Promise { + const rawToken = randomBytes(TOKEN_BYTES).toString("base64url"); + const expiresAt = new Date( + params.appointmentEndAt.getTime() + VALID_FOR_DAYS_AFTER_APPOINTMENT * 24 * 60 * 60 * 1000, + ); + + await prisma.guestAppointmentAccessToken.upsert({ + where: { appointmentId: params.appointmentId }, + create: { appointmentId: params.appointmentId, tokenHash: hashToken(rawToken), expiresAt }, + update: { tokenHash: hashToken(rawToken), expiresAt, revokedAt: null, lastUsedAt: null }, + }); + + return rawToken; +} + +/** + * True only if `rawToken` is the CURRENT token for exactly this + * appointment, not expired, and not revoked. Compares hashes with + * `timingSafeEqual` (matching the convention in + * src/lib/booking-draft/sign.ts) since, unlike a lookup keyed by the hash + * itself, this comparison happens in application code after fetching the + * row by appointmentId. + */ +export async function validateGuestAccessToken( + prisma: PrismaClient, + params: { appointmentId: string; rawToken: string }, +): Promise { + const record = await prisma.guestAppointmentAccessToken.findUnique({ + where: { appointmentId: params.appointmentId }, + }); + if (!record || record.revokedAt || record.expiresAt < new Date()) return false; + + const provided = Buffer.from(hashToken(params.rawToken), "hex"); + const stored = Buffer.from(record.tokenHash, "hex"); + if (provided.length !== stored.length || !timingSafeEqual(provided, stored)) return false; + + // Best-effort — a stale lastUsedAt from a lost race isn't a security concern. + await prisma.guestAppointmentAccessToken + .update({ where: { appointmentId: params.appointmentId }, data: { lastUsedAt: new Date() } }) + .catch(() => {}); + + return true; +} + +/** Immediately invalidates a guest appointment's current access link — e.g. if it was sent to the wrong address. */ +export async function revokeGuestAccessToken(prisma: PrismaClient, appointmentId: string): Promise { + await prisma.guestAppointmentAccessToken.updateMany({ + where: { appointmentId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); +} diff --git a/src/services/scheduling/reminder-scheduling.ts b/src/services/scheduling/reminder-scheduling.ts new file mode 100644 index 0000000..0f6ec4a --- /dev/null +++ b/src/services/scheduling/reminder-scheduling.ts @@ -0,0 +1,98 @@ +import type { PrismaClient } from "@/generated/prisma/client"; +import { NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums"; +import { notifySafely } from "@/services/notifications/notification.service"; +import type { Appointment } from "./booking.service"; + +/** + * Reminder timing — hardcoded, not yet a BookingRule column: there's no + * owner-facing UI to change it in Phase 7, so storing an unreachable + * setting felt like the wrong kind of "configurable" (see + * docs/phase-7-summary.md, "Known limitations"). A natural home for this + * would be two new BookingRule fields feeding the existing Booking Rules + * editor — not built this phase. + */ +const REMINDER_HOURS_BEFORE = [24, 2] as const; + +function formatDateLabel(date: Date, timezone: string): string { + return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone }); +} + +function formatTimeLabel(date: Date, timezone: string): string { + return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone }); +} + +async function getBusinessTimezone(prisma: PrismaClient, businessId: string): Promise { + const business = await prisma.business.findUnique({ where: { id: businessId }, select: { timezone: true } }); + return business?.timezone ?? "Europe/London"; +} + +/** + * Cancels every not-yet-sent reminder delivery tied to this appointment — + * called before rescheduling to a new time and before cancelling, so a + * customer/barber never gets a reminder for a time that's no longer + * correct. Only touches PENDING/SCHEDULED rows; a reminder that already + * sent stays as a true historical record. + */ +export async function cancelScheduledReminders(prisma: PrismaClient, appointmentId: string): Promise { + await prisma.notificationDelivery.updateMany({ + where: { + status: { in: [NotificationDeliveryStatus.PENDING, NotificationDeliveryStatus.SCHEDULED] }, + notification: { + appointmentId, + type: { in: [NotificationType.BOOKING_REMINDER, NotificationType.STAFF_APPOINTMENT_REMINDER] }, + }, + }, + data: { status: NotificationDeliveryStatus.CANCELLED }, + }); +} + +/** + * Schedules the customer's and barber's reminders for a CONFIRMED + * appointment at each offset in REMINDER_HOURS_BEFORE that's still in the + * future — an offset that's already passed (e.g. booked 1 hour out) is + * silently skipped, not scheduled in the past. Each reminder's + * idempotencyKey includes the exact startAt, so a reschedule (which calls + * cancelScheduledReminders then this again) always produces fresh rows + * rather than colliding with a cancelled one — see + * booking-notifications.ts for the same "version-suffixed key" pattern. + */ +export async function scheduleAppointmentReminders(prisma: PrismaClient, appointment: Appointment): Promise { + if (appointment.status !== "CONFIRMED") return; + + const timezone = await getBusinessTimezone(prisma, appointment.businessId); + const dateLabel = formatDateLabel(appointment.startAt, timezone); + const timeLabel = formatTimeLabel(appointment.startAt, timezone); + const services = appointment.services.map((s) => s.service.name).join(", "); + const now = new Date(); + const versionSuffix = appointment.startAt.getTime(); + + for (const hoursBefore of REMINDER_HOURS_BEFORE) { + const scheduledFor = new Date(appointment.startAt.getTime() - hoursBefore * 60 * 60 * 1000); + if (scheduledFor <= now) continue; + + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.BOOKING_REMINDER, + recipientUserId: appointment.customer.userId ?? undefined, + recipientCustomerId: appointment.customer.id, + appointmentId: appointment.id, + subject: "Upcoming appointment reminder", + bodyText: `Reminder: your appointment for ${services} with ${appointment.staff.user.name} is on ${dateLabel} at ${timeLabel}.`, + deepLinkPath: `/appointments/${appointment.id}`, + idempotencyKey: `notify:booking-reminder-${hoursBefore}h:${appointment.id}:${versionSuffix}`, + scheduledFor, + }); + + await notifySafely(prisma, { + businessId: appointment.businessId, + type: NotificationType.STAFF_APPOINTMENT_REMINDER, + recipientUserId: appointment.staff.userId, + appointmentId: appointment.id, + subject: "Upcoming appointment reminder", + bodyText: `Reminder: ${appointment.customer.firstName} ${appointment.customer.lastName} (${services}) is booked for ${dateLabel} at ${timeLabel}.`, + deepLinkPath: `/staff/appointments/${appointment.id}`, + idempotencyKey: `notify:staff-appointment-reminder-${hoursBefore}h:${appointment.id}:${versionSuffix}`, + scheduledFor, + }); + } +} diff --git a/src/services/scheduling/scheduling.service.ts b/src/services/scheduling/scheduling.service.ts index 6abeb80..0042078 100644 --- a/src/services/scheduling/scheduling.service.ts +++ b/src/services/scheduling/scheduling.service.ts @@ -21,6 +21,21 @@ import type { AppointmentStatus } from "@/generated/prisma/enums"; * translate into the same DoubleBookingError. Serializable isolation * is standard SQL, supported by both Postgres and MySQL — this stays * portable, unlike a Postgres-only exclusion constraint would be. + * + * Re-verified against MySQL 8.0 in Phase 7 (see docs/phase-7-summary.md): + * a 25-way concurrent-write stress test against the same slot produced 1 + * commit, 15 rejections caught by the explicit `assertNoConflict` check, + * and 9 raw database write-conflict errors — all surfaced as Prisma's + * `P2034` (`PrismaClientKnownRequestError`), the SAME code the classic + * query-engine used for Postgres. This was not a given: Prisma 7's + * TypeScript-native client-engine-runtime (used with driver adapters, see + * src/lib/db/prisma.ts) propagates a raw `DriverAdapterError` for a + * Postgres serialization failure instead of wrapping it as P2034 (see the + * check below) — empirically, MySQL's write conflicts take the OTHER path + * and already arrive as P2034, so `isSerializationFailure`'s first check + * already covers MySQL correctly with no new branch needed. Found by + * running the concurrency test repeatedly and a dedicated high-concurrency + * probe, not assumed from the Postgres behaviour. */ export class DoubleBookingError extends Error { @@ -70,11 +85,28 @@ async function assertNoConflict( excludeAppointmentId?: string; }, ): Promise { + // AvailabilityService pads each EXISTING appointment by + // [start - beforeMinutes, end + afterMinutes] and checks the candidate's + // raw window against that (slot-calculator.ts expandWithBuffer). Doing + // the equivalent check here — with a single query, without fetching and + // padding every row in application code — means padding the CANDIDATE + // instead, but that flips which side each buffer applies to: a conflict + // exists when `existing.startAt < candidateEnd + beforeMinutes` (an + // existing appointment starting soon after the candidate ends still + // needs its own leading buffer) `&& existing.endAt > candidateStart - + // afterMinutes` (an existing appointment that just finished still needs + // its trailing buffer before the candidate can start). Using + // beforeMinutes/afterMinutes the other way round (as an earlier version + // of this function did) silently swaps which buffer guards which side — + // found via a real MySQL E2E failure where a candidate slot placed + // immediately BEFORE an existing appointment was wrongly rejected using + // that appointment's bufferAfterMinutes instead of its + // bufferBeforeMinutes (see docs/phase-7-summary.md). const bufferedStart = new Date( - params.startAt.getTime() - params.effectiveBuffer.beforeMinutes * 60_000, + params.startAt.getTime() - params.effectiveBuffer.afterMinutes * 60_000, ); const bufferedEnd = new Date( - params.endAt.getTime() + params.effectiveBuffer.afterMinutes * 60_000, + params.endAt.getTime() + params.effectiveBuffer.beforeMinutes * 60_000, ); const conflict = await tx.appointment.findFirst({ @@ -82,10 +114,6 @@ async function assertNoConflict( staffId: params.staffId, status: { notIn: NON_BLOCKING_STATUSES }, ...(params.excludeAppointmentId ? { id: { not: params.excludeAppointmentId } } : {}), - // Overlap test: the candidate's buffered span against existing appointments' raw spans — mirrors - // exactly how AvailabilityService treats existing appointments as buffered "busy" time (see - // slot-calculator.ts expandWithBuffer), so a slot the availability search offered can't be - // rejected here for a different reason than why it wouldn't have been offered in the first place. startAt: { lt: bufferedEnd }, endAt: { gt: bufferedStart }, }, @@ -97,15 +125,20 @@ async function assertNoConflict( function isSerializationFailure(error: unknown): boolean { // P2034: "Transaction failed due to a write conflict or a deadlock. Please retry your transaction." - // This is the error shape Prisma's classic query-engine binary produces. + // The error shape Prisma's classic query-engine binary produces — and, found + // empirically in Phase 7, also what MySQL 8.0's write-conflict errors arrive as + // through the TS-native client-engine-runtime (see the file header for the + // concurrency-stress-test results). This one check covers both databases. if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2034") return true; // Prisma 7's TypeScript-native client-engine-runtime (used with driver - // adapters, see src/lib/db/prisma.ts) instead propagates the adapter's - // own DriverAdapterError for a Postgres serialization failure (SQLSTATE - // 40001) rather than wrapping it as P2034 — found empirically by - // actually running the concurrency test below, not from documentation. - // Duck-typed rather than importing @prisma/driver-adapter-utils + // adapters, see src/lib/db/prisma.ts) can instead propagate the adapter's + // own DriverAdapterError for a serialization failure rather than wrapping + // it as P2034 — found empirically for Postgres (SQLSTATE 40001) in Phase + // 3. MySQL's write conflicts did NOT take this path in Phase 7's testing + // (they all arrived as P2034 above); this fallback is kept as defence in + // depth for whichever database is in use, not because MySQL is known to + // need it. Duck-typed rather than importing @prisma/driver-adapter-utils // directly, since that's a transitive dependency, not one of ours. if ( error instanceof Error && diff --git a/src/services/staff/__tests__/staff.service.integration.test.ts b/src/services/staff/__tests__/staff.service.integration.test.ts index f99882c..370b62d 100644 --- a/src/services/staff/__tests__/staff.service.integration.test.ts +++ b/src/services/staff/__tests__/staff.service.integration.test.ts @@ -53,6 +53,7 @@ describe("StaffService (integration)", () => { const { staffId } = await createTestStaff(business); const result = await setWorkingHours(prisma, { staffId, + businessTimezone: business.timezone, days: [ { dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 }, { dayOfWeek: Weekday.SUNDAY, isOff: true }, @@ -67,6 +68,78 @@ describe("StaffService (integration)", () => { const sunday = hours.find((h) => h.dayOfWeek === Weekday.SUNDAY); expect(sunday?.isOff).toBe(true); }); + + it("blocks marking a day off that has a future appointment, and Save Anyway bypasses it without cancelling the appointment", async () => { + const { staffId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + const monday = nextWeekday(business.timezone, Weekday.MONDAY); + + const appointment = await prisma.appointment.create({ + data: { + businessId: business.businessId, + staffId, + customerId, + status: "CONFIRMED", + startAt: wallClockToInstant(monday, 12 * 60, business.timezone), + endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone), + services: { + create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }], + }, + }, + }); + + const days = [ + { dayOfWeek: Weekday.MONDAY, isOff: true }, + { dayOfWeek: Weekday.TUESDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 }, + ]; + + const blocked = await setWorkingHours(prisma, { staffId, businessTimezone: business.timezone, days }); + expect(blocked.success).toBe(false); + if (blocked.success) throw new Error("unreachable"); + expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id); + + const stillConfirmed = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } }); + expect(stillConfirmed.status).toBe("CONFIRMED"); + + const acknowledged = await setWorkingHours(prisma, { + staffId, + businessTimezone: business.timezone, + days, + acknowledgeConflicts: true, + }); + expect(acknowledged.success).toBe(true); + + const afterSave = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } }); + expect(afterSave.status).toBe("CONFIRMED"); + expect(afterSave.staffId).toBe(staffId); + }); + + it("does not flag a conflict for an appointment that falls inside the new working window", async () => { + const { staffId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + const monday = nextWeekday(business.timezone, Weekday.MONDAY); + + await prisma.appointment.create({ + data: { + businessId: business.businessId, + staffId, + customerId, + status: "CONFIRMED", + startAt: wallClockToInstant(monday, 12 * 60, business.timezone), + endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone), + services: { + create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }], + }, + }, + }); + + const result = await setWorkingHours(prisma, { + staffId, + businessTimezone: business.timezone, + days: [{ dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 }], + }); + expect(result.success).toBe(true); + }); }); describe("breaks", () => { diff --git a/src/services/staff/staff-invitation.service.ts b/src/services/staff/staff-invitation.service.ts index c960bdb..18360d0 100644 --- a/src/services/staff/staff-invitation.service.ts +++ b/src/services/staff/staff-invitation.service.ts @@ -1,6 +1,9 @@ import { randomBytes } from "node:crypto"; import bcrypt from "bcryptjs"; import type { PrismaClient } from "@/generated/prisma/client"; +import { NotificationType } from "@/generated/prisma/enums"; +import { notifySafely } from "@/services/notifications/notification.service"; +import { getOwnerUserIds } from "@/services/notifications/recipients"; /** * The "invite a new staff member" flow, deliberately split out of @@ -10,12 +13,13 @@ import type { PrismaClient } from "@/generated/prisma/client"; * immediately; this file only tracks the separate "has this person set * their own password yet" signal via StaffInvitation.acceptedAt. * - * No real email delivery this phase — `token` is a plain opaque value - * (crypto-random, not HMAC-signed like the booking-draft cookie, because - * it's validated against a database row rather than needing to be - * self-contained) surfaced to the owner as a copy-link - * (`/invite/accept?token=...`) to send however they like. This is a - * documented development-safe shortcut, not a production email system. + * `token` is a plain opaque value (crypto-random, not HMAC-signed like the + * booking-draft cookie, because it's validated against a database row + * rather than needing to be self-contained) — it's still also surfaced to + * the owner as a copy-link (`/invite/accept?token=...`) as a fallback in + * case email delivery is unavailable/unconfigured, but as of Phase 7 a + * real STAFF_INVITATION_RECEIVED email is queued too (see + * NotificationService/templates/staff-emails.ts). */ const INVITATION_TTL_DAYS = 7; @@ -30,7 +34,7 @@ export async function createInvitation( prisma: PrismaClient, params: { businessId: string; staffId: string; email: string; invitedByUserId?: string }, ) { - return prisma.staffInvitation.create({ + const invitation = await prisma.staffInvitation.create({ data: { businessId: params.businessId, staffId: params.staffId, @@ -40,6 +44,28 @@ export async function createInvitation( expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000), }, }); + + const staff = await prisma.staffProfile.findUnique({ + where: { id: params.staffId }, + include: { user: { select: { id: true, name: true } }, role: { select: { name: true } }, business: { select: { name: true } } }, + }); + const invitedBy = params.invitedByUserId + ? await prisma.user.findUnique({ where: { id: params.invitedByUserId }, select: { name: true } }) + : null; + + if (staff) { + await notifySafely(prisma, { + businessId: params.businessId, + type: NotificationType.STAFF_INVITATION_RECEIVED, + recipientUserId: staff.user.id, + subject: `You've been invited to join ${staff.business.name}`, + bodyText: `${invitedBy?.name ?? "The owner"} has added you as ${staff.role.name} at ${staff.business.name}. Set your password to get started.`, + deepLinkPath: `/invite/accept?token=${invitation.token}`, + idempotencyKey: `notify:staff-invitation-received:${invitation.id}`, + }); + } + + return invitation; } /** An expired or already-viewed invitation gets a fresh token/expiry rather than a second row — one StaffInvitation per StaffProfile (see the schema's `staffId @unique`). */ @@ -99,5 +125,19 @@ export async function acceptInvitation( data: { acceptedAt: new Date() }, }), ]); + + const ownerUserIds = await getOwnerUserIds(prisma, check.invitation.businessId); + for (const ownerUserId of ownerUserIds) { + await notifySafely(prisma, { + businessId: check.invitation.businessId, + type: NotificationType.OWNER_STAFF_INVITATION_ACCEPTED, + recipientUserId: ownerUserId, + subject: "Invitation accepted", + bodyText: `${check.invitation.staff.user.name ?? "A new team member"} has set their password and joined ${check.invitation.staff.business.name}.`, + deepLinkPath: `/owner/team/${check.invitation.staff.id}`, + idempotencyKey: `notify:owner-staff-invitation-accepted:${check.invitation.id}:${ownerUserId}`, + }); + } + return { success: true }; } diff --git a/src/services/staff/staff-notifications.ts b/src/services/staff/staff-notifications.ts new file mode 100644 index 0000000..e75dc00 --- /dev/null +++ b/src/services/staff/staff-notifications.ts @@ -0,0 +1,32 @@ +import { randomUUID } from "node:crypto"; +import type { PrismaClient } from "@/generated/prisma/client"; +import { NotificationType } from "@/generated/prisma/enums"; +import { notifySafely } from "@/services/notifications/notification.service"; + +/** + * STAFF_SCHEDULE_CHANGED — fired only when an OWNER changes a staff + * member's working hours/breaks/time off on their behalf (see + * src/features/owner/actions/breaks.ts, time-off.ts, staff.ts). Never + * called from the staff self-service editors — notifying someone about + * their own action would just be noise, and StaffService itself has no + * "who initiated this" context to gate on, since owner-on-behalf and + * self-service both call the exact same StaffService functions by design + * (see docs/phase-6-summary.md, "explicitly NOT creating separate + * owner-only availability logic"). A fresh idempotencyKey every call — + * unlike a booking, there's no natural "one instance" to dedupe against; + * every distinct owner edit is its own notification-worthy event. + */ +export async function notifyStaffScheduleChanged( + prisma: PrismaClient, + params: { businessId: string; staffUserId: string; changedByName: string; whatChanged: string; viewSchedulePath: string }, +): Promise { + await notifySafely(prisma, { + businessId: params.businessId, + type: NotificationType.STAFF_SCHEDULE_CHANGED, + recipientUserId: params.staffUserId, + subject: "Your schedule was updated", + bodyText: `${params.changedByName} has updated ${params.whatChanged}.`, + deepLinkPath: params.viewSchedulePath, + idempotencyKey: `notify:staff-schedule-changed:${randomUUID()}`, + }); +} diff --git a/src/services/staff/staff.service.ts b/src/services/staff/staff.service.ts index 7325912..1eabbfd 100644 --- a/src/services/staff/staff.service.ts +++ b/src/services/staff/staff.service.ts @@ -1,5 +1,5 @@ import type { PrismaClient } from "@/generated/prisma/client"; -import { NotificationCategory, NotificationChannel, Weekday } from "@/generated/prisma/enums"; +import { Weekday } from "@/generated/prisma/enums"; import { calendarDateOf, calendarDateToUtcMidnight, @@ -152,12 +152,72 @@ export async function getWorkingHours(prisma: PrismaClient, staffId: string) { return prisma.workingHours.findMany({ where: { staffId }, orderBy: { dayOfWeek: "asc" } }); } +/** + * Future, non-cancelled appointments that would fall outside the proposed + * working hours — either on a day newly marked off, or before/after the + * new start/end window on a day that stays working. Same pattern as + * findBreakConflicts/findTimeOffConflicts/BusinessService's + * findOpeningHoursConflicts (see docs/phase-7-summary.md, "Working-hours + * conflict review"): this never cancels anything itself, only reports what + * the caller should show before asking for acknowledgement. + */ +export async function findWorkingHoursConflicts( + prisma: PrismaClient, + params: { staffId: string; businessTimezone: string; days: WorkingHoursDayInput[] }, +) { + const appointments = await prisma.appointment.findMany({ + where: { + staffId: params.staffId, + status: { notIn: ["CANCELLED", "NO_SHOW"] }, + startAt: { gte: new Date() }, + }, + include: APPOINTMENT_CONFLICT_INCLUDE, + orderBy: { startAt: "asc" }, + }); + + const byDay = new Map(params.days.map((d) => [d.dayOfWeek, d])); + + return appointments.filter((a) => { + const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone)); + const proposed = byDay.get(weekday); + if (!proposed) return false; + if (proposed.isOff) return true; + + const aStart = minutesFromMidnight(a.startAt, params.businessTimezone); + const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone); + return aStart < (proposed.startMinutes ?? 0) || aEnd > (proposed.endMinutes ?? 24 * 60); + }); +} + export async function setWorkingHours( prisma: PrismaClient, - params: { staffId: string; days: WorkingHoursDayInput[] }, -): Promise<{ success: boolean; validation: ValidationResult }> { + params: { + staffId: string; + businessTimezone: string; + days: WorkingHoursDayInput[]; + acknowledgeConflicts?: boolean; + }, +) { const validation = validateWorkingHoursInput(params.days); - if (!validation.valid) return { success: false, validation }; + if (!validation.valid) return { success: false as const, validation, conflicts: [] }; + + if (!params.acknowledgeConflicts) { + const conflicts = await findWorkingHoursConflicts(prisma, { + staffId: params.staffId, + businessTimezone: params.businessTimezone, + days: params.days, + }); + if (conflicts.length > 0) { + return { + success: false as const, + conflicts, + validation: fail({ + code: "APPOINTMENT_CONFLICTS", + message: "These hours would leave existing appointments outside your working hours.", + }), + }; + } + } await prisma.$transaction( params.days.map((day) => @@ -179,7 +239,7 @@ export async function setWorkingHours( ), ); - return { success: true, validation: ok() }; + return { success: true as const, conflicts: [], validation: ok() }; } // ── Breaks ────────────────────────────────────────────────────────────── @@ -582,53 +642,6 @@ export async function deleteTimeOff( return { success: true }; } -// ── Notification preferences ─────────────────────────────────────────── -// UI only this phase — no real delivery. Every (category, channel) pair -// defaults to enabled (matches the schema's `isEnabled @default(true)`), -// so a user with no rows yet still sees a fully-populated, all-on grid. - -const NOTIFICATION_CATEGORIES = Object.values(NotificationCategory); -const NOTIFICATION_CHANNELS = Object.values(NotificationChannel); - -export interface NotificationPreferenceCell { - category: NotificationCategory; - channel: NotificationChannel; - isEnabled: boolean; -} - -export async function getNotificationPreferences( - prisma: PrismaClient, - userId: string, -): Promise { - const rows = await prisma.notificationPreference.findMany({ where: { userId } }); - const byKey = new Map(rows.map((r) => [`${r.category}:${r.channel}`, r.isEnabled])); - - const cells: NotificationPreferenceCell[] = []; - for (const category of NOTIFICATION_CATEGORIES) { - for (const channel of NOTIFICATION_CHANNELS) { - cells.push({ category, channel, isEnabled: byKey.get(`${category}:${channel}`) ?? true }); - } - } - return cells; -} - -export async function setNotificationPreferences( - prisma: PrismaClient, - userId: string, - updates: NotificationPreferenceCell[], -): Promise<{ success: true }> { - await prisma.$transaction( - updates.map((u) => - prisma.notificationPreference.upsert({ - where: { userId_category_channel: { userId, category: u.category, channel: u.channel } }, - create: { userId, category: u.category, channel: u.channel, isEnabled: u.isEnabled }, - update: { isEnabled: u.isEnabled }, - }), - ), - ); - return { success: true }; -} - // ── Owner: team management ───────────────────────────────────────────── // Everything below acts on staff *other than* the caller — the owner's own // business-membership must already be verified by the caller (see @@ -643,13 +656,13 @@ export async function listStaffForBusiness( where: { businessId: params.businessId, ...(params.includeInactive ? {} : { status: { not: "INACTIVE" } }), + // No `mode: "insensitive"` — that's a Postgres-only Prisma option (removed in the + // Phase 7 MySQL migration). MySQL's default collation (utf8mb4_0900_ai_ci — "ai" = + // accent-insensitive, "ci" = case-insensitive) already makes `contains` case-insensitive. ...(params.q ? { user: { - OR: [ - { name: { contains: params.q, mode: "insensitive" } }, - { email: { contains: params.q, mode: "insensitive" } }, - ], + OR: [{ name: { contains: params.q } }, { email: { contains: params.q } }], }, } : {}),