# 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 `