Files
NextChair/docs/phase-8-summary.md
T
2026-08-04 09:57:58 +01:00

40 KiB

Phase 8 — Installation Wizard, White-Label Branding, Guest-Booking Defaults, Google Calendar Sync and Local Docker Deployment: Summary

A focused post-Phase-7 pass making the application deployable as a real, white-label product for a single business: a secure first-run installation wizard replaces seeded demo data as the production bootstrap path, every customer/staff/owner-facing surface now reflects the installed business's own name and branding instead of the internal "NextChair" product name, guest booking defaults off, staff can sync their own appointments to their personal Google Calendar, and a complete docker-compose.local.yaml stack brings the whole thing up on Docker Desktop with one command. No broad rewrite and no repeat of Phase 7's audits — every change here is additive on top of the existing domain services (BusinessService, BookingRule, StaffService, the notification/PWA infrastructure), which remain the authoritative logic for everything they already owned.


1. Installation-state architecture

A single ApplicationInstallation table (prisma/schema.prisma) is the one source of truth for "has this deployment been set up yet" — never inferred from "does any table have rows." It uses a fixed, well-known id ("singleton") rather than a separate lock table: the installer's transaction opens with tx.applicationInstallation.create({ id: "singleton", status: INSTALLING }) as its very first statement (src/features/installation/installer.service.ts). MySQL's row-level locking on that primary-key insert is the entire concurrency story — a second concurrent install attempt blocks on the same insert and then fails with a unique-constraint violation once the first transaction commits, or proceeds cleanly if the first one instead rolls back. This was verified directly, not assumed: an integration test fires two concurrent installApplication() calls and asserts exactly one business is ever created (src/features/installation/__tests__/installer.service.integration.test.ts).

Two read paths, deliberately different for different callers:

  • getInstallationState() (src/lib/installation/installation-state.ts) — React cache()-deduped, used by Server Components (/install, the auth layout, etc.).
  • isInstalledForRequest() — used only by src/proxy.ts and the notification cron route. Once a process observes INSTALLED, it caches that fact in a module-level variable for the rest of that process's lifetime, so the routing gate that runs on every single request doesn't do a fresh database round-trip forever after a one-time, one-way transition. Before installation (the only window where re-checking actually matters), every request still does the real check.

2. Installation security

  • Token: INSTALLATION_TOKEN env var, required before setup can begin. Checked twice — once by a cheap, rate-limited verifyInstallationTokenAction the wizard's first step calls for immediate feedback, and again, unconditionally, as the very first thing installApplication() does before touching the database — so a client that skips straight to final submission without ever calling the first check is still rejected. Comparison is via secretsMatch() (src/lib/security/secrets-match.ts, extracted this phase and now shared with the cron route's own secret check), using Node's timingSafeEqual. Neither check reveals why a token was wrong. If INSTALLATION_TOKEN isn't set at all, /install shows a plain "not configured" message rather than silently allowing setup with no gate.
  • Rate limiting: both the token-verify action and the final submission action are rate-limited (checkRateLimit, 10 attempts/15 min, keyed by IP) — the same primitive Phase 7 built for sign-in/registration/guest-booking abuse.
  • No token persistence: the raw token is never stored or logged anywhere; it's compared and discarded.

3. Installation wizard steps

Nine screens (src/features/installation/components/install-wizard.tsx): Welcome (token), Administrator, Business Details, Location & Contact (UK defaults — Europe/London / GBP / en-GB — editable), Opening Hours (reuses the same day-by-day UI pattern as the owner's existing hours editor, plus "copy Monday to weekdays/all days"), Booking Settings (guest booking off by default, with the brief's exact recommended copy), Branding (optional logo/cover/theme/background — plain URL fields, matching how Business Details already handles logoUrl everywhere else in the app), Starter Services (a small editable/removable prefilled set — Classic Haircut / Beard Trim / Haircut & Beard Combo across three categories — the installer must explicitly reach Review to submit, so nothing is silently created), and Review (a no-secrets summary with explicit "Confirm & Install" confirmation).

State lives entirely in the wizard's own React state, not a signed draft cookie — a deliberate simplification, documented inline in the component: nothing is validated or written to the database until the single final submission, so a page refresh mid-wizard just means starting over, which is an acceptable trade-off for a page one person visits once per deployment. This also means "no partial production records after every step" is true by construction, not by extra bookkeeping.

4. Initial administrator creation

Created inside the installer's own transaction: User (bcrypt-hashed password, same cost factor as registration) + StaffProfile bound to a freshly-created Owner role holding the full OWNER_PERMISSIONS set. No separate "super-admin" authentication system — the brief was explicit that this isn't a platform-wide concept, just the first, fully-permissioned owner in this app's existing Role/Permission model. OWNER_PERMISSIONS/BARBER_PERMISSIONS/syncRolePermissions were extracted this phase from prisma/seed.ts into src/lib/roles/default-permissions.ts so the installer and the dev seed script share one definition — a permission added to one can no longer silently miss the other, closing a real drift risk the two paths would otherwise have had. No isInitialAdministrator flag was added: the existing last-owner protection (any role holding MANAGE_STAFF) already covers "protect the last owner from self-demotion/removal" without a parallel authorization concept.

5. Initial business creation

One transaction creates Business (with all the new branding columns — see §13), BusinessLocation (primary), BusinessSettings, BusinessHours (all 7 days), BookingRule (guest booking off unless the installer explicitly enabled it), and a LoyaltyProgramme row with isEnabled: false — loyalty is never silently turned on. A collision-safe slug is generated from the business name plus a base-36 timestamp suffix.

6. Starter service approach

Option B from the brief: a small, clearly-editable starter set (3 services across 3 categories), not an empty catalogue and not a large invented one. The installer explicitly reviews (and can remove or add to) the set before Review; only entries with a non-empty name at submission time are created. ServiceCategory rows are created once per distinct category name in submission order, then one Service row per entry, then StaffService eligibility rows link the new owner to every created service — matching the brief's "initial owner is service-eligible," verified by an integration test.

7. Installation transaction behaviour

Everything in §4–§6 happens inside one Prisma interactive transaction, alongside the AuditLog entry (APPLICATION_INSTALLED) and the final ApplicationInstallation update to INSTALLED. Any failure anywhere in that transaction rolls back atomically — including the singleton lock row itself, since it was created in the same transaction — so a failed install leaves the database exactly as it was before, not a stuck INSTALLING/FAILED row, and a retry sees "not installed" again cleanly. Verified directly with an integration test that submits a wrong token and asserts no Business row and no ApplicationInstallation row exist afterward.

8. Guest-booking default and toggle

BookingRule.allowGuestBooking's schema default changed from true to false this phase (a genuine, intentional breaking change to the column's default — existing rows are untouched, only future bookingRule: { create: {} } calls with no explicit value are affected). The dev/demo seed (prisma/seed.ts) explicitly opts back in (allowGuestBooking: true) so the existing guest-booking E2E journey and manual testing keep working — documented inline as a deliberate seed-vs-production divergence. The existing owner-facing toggle (src/features/owner/components/booking-rules-form.tsx, already built in Phase 6) needed no new field — only its label/description text was updated to match the brief's exact recommended copy ("Allow customers to book as guests" / the two-sentence explanation).

9. Guest-booking enforcement

Enforced at the one place that actually matters — confirmBookingAction (src/features/booking/actions/confirm.ts), the only Server Action that creates an Appointment row — re-checking BookingRule.allowGuestBooking from the database immediately before resolving a guest customer, regardless of how the request arrived (the UI gate, a stale draft cookie started before the owner disabled guest booking mid-flow, or a direct Server Action call). The same check is duplicated one step earlier in saveDetailsStepAction (src/features/booking/actions/draft.ts) purely for a better error message at the right step, but confirm.ts's check is the authoritative one. The UI side: /book/details (src/app/book/details/page.tsx) shows the brief's exact "Sign in to complete your booking" copy with Sign In / Create Account actions — never "Continue as Guest" — when an unauthenticated visitor reaches that step with guest booking disabled; browsing, service selection, and barber/time selection remain guest-accessible either way, since the gate only applies from the details step onward.

Booking-draft continuity across authentication: callbackUrl now flows end-to-end — Sign In and Create Account links from the guest gate carry ?callbackUrl=/book/details, SignInForm/RegisterForm read and forward it (registerAction previously hardcoded redirectTo: "/home"; it now accepts and validates a caller-supplied path), and the existing signed booking-draft cookie (unrelated to auth) already survives the round trip through /sign-in//register since it's just an httpOnly cookie, not session state. Verified live in a real browser, not just by reading the code: signed out → browse → select a slot → hit the guest gate at /book/details → Sign In → land back on /book/details, now rendering the authenticated details form with the same in-progress booking.

All caller-supplied redirect targets are validated by a new safeRedirectPath() helper (src/lib/utils/safe-redirect.ts — must start with exactly one /, reject //host, reject any ://) before ever reaching a redirect — covered by unit tests for the protocol-relative and absolute-URL open-redirect cases specifically.

10. Existing guest-appointment behaviour

Unaffected. GuestAppointmentAccessToken lookups (/appointments/[id]?token=...), viewing, rescheduling, and cancelling an existing guest appointment never consult allowGuestBooking — that flag only gates the creation of new appointments. No guest CustomerProfile rows were touched, deleted, or converted.

11. Welcome routing

/ (src/app/page.tsx) already served a Welcome page to unauthenticated visitors before this phase; what changed is everything else. It now: renders the business's real logo/name (falling back to a neutral mark, never "NextChair") via getBranding(); shows "Continue as Guest" only when bookingRule.allowGuestBooking is true; always offers "Browse Services" (linking to /home, which has allowed guest browsing since Phase 4); and redirects a signed-in visitor by role rather than always to /home.

12. Role-based root routing

Authenticated visitors to / are routed by looking up their StaffProfile and, if one exists, checking MANAGE_STAFF: owner → /owner, barber → /staff, no staff profile (a customer) → /home — implemented directly in the Welcome page component rather than a separate redirect table, reusing hasPermission() (Phase 3) and the same staffProfile.findUnique pattern the (staff)/(owner) layouts already use. Owners keep the existing "Switch to Barber Tools" link in their own nav (built in Phase 6) as the documented way back to /staff — no new mechanism needed.

13. White-label branding changes

Schema (see §22): Business gained shortName, coverImageUrl, faviconUrl, appIconUrl, themeColor, backgroundColor — logoUrl already existed and needed no change.

Resolution: src/lib/business/branding.ts — getBranding(), a cache()-wrapped, always-non-throwing read (prisma.business.findFirst({ orderBy: { createdAt: "asc" } }), wrapped in try/catch) returning either the real business's identity or a neutral, no-product-name fallback ({ name: "Book an Appointment", shortName: "Booking", ... }). The try/catch matters for a reason beyond tidiness: it's what makes next build's static-page prerendering not crash when no live database is reachable at build time (discovered, not assumed — see §34).

Applied to: the root layout's dynamic generateMetadata/generateViewport (title template "%s | {business name}", replacing the old "%s — NextChair"; theme-color from themeColor); the PWA manifest (src/app/manifest.ts, now async and business-driven); the Welcome page; all four auth pages (logo in the shared (auth) layout, "Welcome back to {business}" / "Join {business}" headings); the staff/owner sidebar nav (StaffShell now fetches branding itself once, passing businessName/logoUrl down to the client-side StaffSidebarNav, so both (staff) and (owner) get it from one call site); the offline fallback page; the ICS calendar file's PRODID (now -//Booking Calendar//EN, no product name) and its business-name fallback; staff-invitation emails (already business-branded from Phase 7 — only the leftover "on NextChair" phrase was removed); the Brevo SMTP fromName env-var fallback ("NextChair" → "Booking Notifications", documented as env-configured rather than DB-derived, since that config loader has no database access — see §35); the push-notification title fallback in both reminder-worker.ts (now resolves via getBranding()) and the service worker's own defensive fallback (public/sw.js, changed to neutral "New notification" — the SW is a static asset with no per-request rendering, so it can't be made dynamically business-branded itself, only its fallback text was fixed); and the owner's staff-invitation acceptance form.

14. Remaining internal product-name occurrences

A full-repo grep for NextChair and TrimTime after this phase's changes turns up only: code comments explaining the white-labeling rules themselves; the X-NextChair-Idempotency-Key HTTP header name in the Brevo provider (a technical/internal header, never rendered to a user — left as-is, matching the brief's own "do not blindly replace... technical identifiers" guidance); src/app/style-guide/page.tsx's demo prop value businessName="TrimTime" (a dev-only design-system reference page, not a real user flow — template/reference-only); and this repository's own name, Docker image/container/network names, package name, and documentation — all explicitly "internal and acceptable" per the brief.

15. PWA branding and manifest behaviour

src/app/manifest.ts is now async, reading name/short_name/description/background_color/theme_color from getBranding(). icons deliberately stays pointed at the app's own pre-generated, correctly-sized/maskable icon set (192/512, standard and maskable) rather than trying to derive those from a business's single appIconUrl — there's no image-processing pipeline in this app to produce four correctly-sized variants from one arbitrary uploaded URL, so this is fallback rule #2 ("business name + a neutral generated icon"), chosen deliberately over risking a broken or non-square install icon (see §35).

16. PWA install-prompt changes

The beforeinstallprompt capture, standalone-mode detection, and iOS detection logic (previously duplicated inline in InstallAppButton) was extracted into a shared hook, src/features/pwa/hooks/use-install-prompt.ts — now the one PWA install implementation, per the brief's explicit "do not create a competing second installer." InstallAppButton (Settings, all three roles) now takes a businessName prop instead of hardcoding "NextChair is installed on this device." A new WelcomeInstallCard (src/features/pwa/components/welcome-install-card.tsx) uses the same hook to render a prominent, non-blocking inline card on the Welcome page — "Add {Business Name} to your Home Screen" — below the primary Sign In/Create Account/Continue as Guest/Browse Services actions, so it's visible immediately without ever gating them or opening as an unsolicited modal.

17. iOS installation behaviour

Since iOS Safari never fires beforeinstallprompt, both InstallAppButton and WelcomeInstallCard detect iOS via user-agent sniffing and show the brief's exact three-step instructional text ("Tap the Share button in Safari… Add to Home Screen… Tap Add") instead of claiming a native prompt exists. Instructions are gated behind a click (not shown automatically) and never appear once the app is already running standalone.

18. Dismissal

WelcomeInstallCard stores a dismissal timestamp in localStorage (nextchair:install-card-dismissed-until) with a 30-day re-prompt interval — chosen (and documented inline) specifically because this card is one of two places to install (Settings always has the button too, with no dismissal at all), so a longer, less naggy interval is appropriate for the more prominent, unsolicited-feeling surface. Detection of window/localStorage happens inside useEffect via the same async-chain pattern Phase 7 established for push-subscribe-toggle.tsx (never a useState lazy initializer reading browser APIs — that would run during SSR and mismatch the client's first render), which also satisfies the react-hooks/set-state-in-effect lint rule.

19. Google Calendar architecture

src/services/calendar/:

  • types.ts — provider-neutral shapes (CalendarEventInput, RemoteCalendarInfo, ExchangedTokens, and a typed CalendarProviderError with a closed code union).
  • calendar-provider.ts — the CalendarProvider interface (buildAuthorizationUrl, exchangeCodeForTokens, refreshAccessToken, listWritableCalendars, createEvent/updateEvent/deleteEvent, revoke).
  • google-calendar.provider.ts — the only implementation today, built directly against Google's OAuth 2.0 and Calendar API v3 REST endpoints via fetch (no googleapis SDK dependency — the same "build against the documented REST contract, don't guess a provider-specific shape" discipline Phase 7 applied to Brevo). Minimum scopes: calendar.events + calendar.readonly + email — never the full calendar scope, which would also grant calendar-settings management this app never needs.
  • mock-calendar.provider.ts — an in-memory CalendarProvider for tests, recording every create/update/delete with no network call at all — see §33 for why, and §26 for the small refactor that made it actually injectable.
  • calendar-sync.service.ts — the policy layer (see §21).

No appointment Server Action calls Google's API directly — every one goes through syncAppointmentSafely()/runInitialSync(), which internally resolve a CalendarProvider and never leak provider-specific detail upward.

20. OAuth and encrypted token storage

Two new routes: GET /api/staff/calendar/google/authorize and GET /api/staff/calendar/google/callback. state is a signed, staff-scoped, 10-minute-lived token (src/lib/security/oauth-state.ts — HMAC-SHA256 over JSON, its own signing context/key derivation, deliberately separate from booking-draft/sign.ts's so the two never share cryptographic material), set as an httpOnly cookie at authorize time and re-verified against both the returned state query param and the currently signed-in staff member's id at callback time — CSRF and replay protection without any server-side session-storage table. A missing/incorrect Google Calendar env configuration fails closed with a friendly redirect (both from the route and from the Settings UI, which hides the Connect button entirely when GOOGLE_CALENDAR_CLIENT_ID/SECRET aren't set), never a 500. Refresh tokens (and access tokens, while valid) are encrypted at rest with AES-256-GCM (src/lib/security/token-encryption.ts — CALENDAR_TOKEN_ENCRYPTION_KEY, SHA-256'd into a 32-byte AES key so any input length still yields a valid key; output is base64(iv + authTag + ciphertext) in one column). Verified with unit tests: round-trip correctness, a fresh IV/ciphertext on every call for the same plaintext, and a hard failure on a tampered ciphertext (GCM's authentication tag catches it).

21. Calendar sync policy

One-way only (business → Google), enforced by construction: nothing in this codebase ever reads a Google Calendar event back into an Appointment. Status mapping, decided once in calendar-sync.service.ts and nowhere else:

  • PENDING — never synced (an unapproved booking shouldn't occupy a barber's personal calendar until approved).
  • CONFIRMED / CHECKED_IN / IN_PROGRESS / COMPLETED — event created or updated.
  • CANCELLED — the remote event is deleted outright and the local AppointmentCalendarEvent row marked CANCELLED.
  • NO_SHOW — retained, with the event description updated to note the outcome, rather than removed — the barber's calendar should still reflect they were scheduled to work that slot.

Event content: customer name, service name(s), business name and primary address, an appointment reference, and a link back to /staff/appointments/{id} — no internal customer notes, no unnecessary contact detail, visibility: "private" set explicitly on every Google event.

22. Retry and duplicate prevention

AppointmentCalendarEvent has a @@unique([appointmentId, connectionId]) constraint — the actual mechanism that makes sync idempotent: an existing row means "update the same remote event," not "create a new one," verified directly by an integration test that calls syncAppointmentSafely() twice on the same appointment and asserts exactly one row and one mock-provider event exist afterward. A reassignment (appointment's staffId changes to a different connected staff member) is handled by scanning for any non-cancelled AppointmentCalendarEvent rows belonging to a different staff member's connection than the appointment's current one, syncing those through the CANCELLED path (deleting their remote event) before syncing the new staff member's connection — verified with a dedicated integration test asserting both the old connection's event is cancelled and the new connection gets a fresh SYNCED one. Every sync call is wrapped so a Google failure (rate limit, outage, expired token, etc.) is caught, mapped to a typed CalendarProviderError, and recorded on the StaffCalendarConnection/AppointmentCalendarEvent rows for the Settings page to surface — never thrown back to the caller, so a Google Calendar outage can never roll back or block the appointment mutation that triggered it. Sync triggers are exactly the brief's list — confirmation/approval, reschedule, reassignment, cancellation, no-show — deliberately not check-in/start/complete (rapid, same-day, low-value-to-sync transitions the brief's own trigger list excludes).

23. Schema changes

New models: ApplicationInstallation, StaffCalendarConnection, AppointmentCalendarEvent. New enums: InstallationStatus, CalendarProvider, CalendarSyncStatus. Business gained six branding columns (§13). BookingRule.allowGuestBooking's default flipped true → false. One migration: 20260804070345_phase8_installation_branding_calendar — its CREATE TABLE statements initially came out with utf8mb4_unicode_ci collation (Prisma's own default when it can't detect the connected database's collation at generation time) instead of this database's actual utf8mb4_0900_ai_ci, which broke the foreign keys to StaffProfile/Appointment (MySQL requires matching collations across an FK relationship) — caught immediately when the migration failed to apply, not silently shipped; fixed by hand-editing the generated SQL to match the rest of the schema before applying it, then reconciling the partially-applied migration with prisma migrate resolve.

24. Routes created or updated

Created: /install, /staff/settings/calendar, /api/health, /api/staff/calendar/google/authorize, /api/staff/calendar/google/callback. Updated (branding/routing behaviour, not new routes): /, /sign-in, /register, /book/details, /offline, every (staff)/(owner) page via the shared StaffShell, /staff/settings, /owner/team/[id] (read-only Google Calendar status card), /owner/settings/booking-rules (copy only).

25. Components created or updated

Created: InstallWizard, WelcomeInstallCard, CalendarConnectionPanel, the shared useInstallPrompt hook. Updated: InstallAppButton (now takes businessName), StaffShell/StaffSidebarNav (business branding), DetailsForm's host page (/book/details, guest gate), SignInForm/RegisterForm (callbackUrl handling), AcceptInvitationForm's copy, BookingRulesForm's guest-booking copy.

26. Dockerfile changes

New, three-stage (deps → builder → runner), non-root (nextchair:nodejs, uid/gid 1001), explicit Node 22, npm ci (not npm install), no .env ever copied in, no secrets baked into any layer. Deliberately not using Next's output: "standalone" — tried first, reverted: this image runs prisma migrate deploy (and, for the optional seed service, tsx prisma/seed.ts) at container start, both of which need the full prisma/tsx CLIs and their dependencies present regardless, so a pruned standalone bundle would need those re-added anyway — a single full npm ci was simpler and no less correct, documented inline in the Dockerfile itself. Prisma 7's driver-adapter architecture (no native Rust query-engine binary — pure JS client) meant none of the classic "native binary on Alpine" packaging problems applied here. One real, non-obvious build issue found and fixed by actually building the image, not just writing it (see §34): next build's static-page collection step imports every route module including /api/health, which pulls in src/lib/db/prisma.ts, which requires DATABASE_URL just to construct its connection-pool config object even though nothing connects at build time — fixed with a placeholder ENV DATABASE_URL=... in the builder stage only, documented inline as never used to connect to anything.

27. docker-compose.local.yaml

Three services under the Compose project name next-chair: next-chair-mysql (image mysql:8.0, matching CI; utf8mb4/utf8mb4_0900_ai_ci; named volume; mysqladmin ping healthcheck), next-chair (built from the Dockerfile; depends_on: condition: service_healthy; a Node-native healthcheck against /api/health — no curl/wget in node:22-alpine, so a plain http.get() one-liner avoids adding a package just for this), and next-chair-seed (same image, entrypoint overridden to npx tsx prisma/seed.ts, gated behind Compose's profiles: ["seed"] so it never runs on a normal up — see §30). All three share one env_file: .env.local rather than a long list of compose-level ${VAR} substitutions, so one file is the single source of truth for both container environment variables and the two host-port overrides the compose file itself needs.

28. Container names

next-chair (application) and next-chair-mysql (database) — matching the brief's exact required names. Kept as two separate containers throughout, never combined.

29. MySQL volume and network

Named volume next-chair-mysql-data, named network next-chair — both services attached to it, matching the brief's exact required resource names.

30. Environment variables

.env.local.example (new) documents every variable the Docker stack uses, split into "Required for basic startup" (MySQL credentials, DATABASE_URL using the next-chair-mysql service hostname — never localhost — AUTH_SECRET, AUTH_TRUST_HOST, NEXT_PUBLIC_APP_URL, INSTALLATION_TOKEN, CRON_SECRET) versus clearly-marked-optional integrations (Google sign-in, Google Calendar, email, push) that the app starts and runs a complete first-install flow without — verified directly (see §34), not assumed. .env.example (the non-Docker local-dev file) was also updated with the same new Phase 8 variables for consistency between the two setup paths.

31. Production installation versus development seed

Kept strictly separate, and this phase adds an explicit, tested boundary between them: prisma/seed.ts now upserts its own ApplicationInstallation row (status: INSTALLED, installedVersion: "dev-seed") purely so a freshly-seeded dev/CI/E2E database is treated as installed by the routing gate — documented inline as never something to run alongside a real installation, and structurally impossible to (the installer's own transaction refuses to start once any ApplicationInstallation row exists). Compose's default up never seeds; seeding is only ever the explicit next-chair-seed profile service (§27), matching the brief's "running the seed must be an intentional action."

32. Health checks

GET /api/health (src/app/api/health/route.ts) — SELECT 1 against the database; 503 only on a genuine connectivity failure, 200 otherwise, including before installation completes (a migrated, reachable database with no business yet is healthy, not unhealthy — it should correctly show the installer). Never returns a stack trace or secret. Used by both the Dockerfile-adjacent Compose healthcheck and, implicitly, by anything else probing the deployment.

33. Automated tests

New unit tests (14, all passing): safeRedirectPath (open-redirect rejection cases), token-encryption (round-trip, fresh-IV-per-call, tamper detection, missing-key failure), oauth-state (valid/wrong-staff/tampered/malformed/expired).

New integration tests, real MySQL (11, all passing): installer.service.integration.test.ts — wrong token creates nothing; a full successful install creates the business/administrator/roles-with-correct-permission-counts/booking-rule-with-guest-off/opening-hours/starter-services/service-eligibility/audit-log all correctly; a second install attempt is rejected once already installed; two concurrent install attempts create exactly one business (the singleton-lock mechanism verified under real concurrent load, not just reasoned about). calendar-sync.service.integration.test.ts (using MockCalendarProvider — real database, zero network calls) — creates an event for a CONFIRMED appointment; never syncs a PENDING one; is idempotent (two syncs of the same appointment produce one remote event, not two); deletes the remote event on cancellation; correctly cancels the previous staff member's event and creates a new one on reassignment; runInitialSync only picks up future non-pending/non-cancelled appointments; does nothing when no connection exists. This required one small refactor: calendar-sync.service.ts's two exported functions now accept an optional injectable CalendarProvider parameter (defaulting to the real Google provider) specifically so tests could pass MockCalendarProvider instead — directly satisfying the brief's "do not call real Google Calendar APIs in automated tests" rather than leaving that untestable.

Existing suites, full regression run: all pre-existing 216 unit tests (→ 230 with the additions above), 147 integration tests (→ 158), and the full 31-journey Playwright E2E suite were re-run after this phase's changes, against a genuinely fresh (prisma migrate reset --force + explicit reseed) database — 27 of 28 executed E2E tests passed, zero new failures; the one failure ("barber changes their working hours") is the same pre-existing, already-tracked, time-of-day-sensitive flake from Phase 7 (unrelated to this phase, reproduces in isolation) — the remaining 3 tests in that spec file didn't run because Playwright's describe.serial mode skips subsequent tests in a file after one failure, not because of anything this phase changed.

Not automated (see §35): the brief's larger E2E checklist for the new flows this phase built (fresh-install-through-wizard, the guest-gate redirect round trip, Docker Compose lifecycle, iOS install-instruction rendering, a mocked Google Calendar OAuth round trip) was verified manually instead — see §34 — rather than by adding a second Playwright harness capable of running against a genuinely empty, not-yet-seeded database (the existing E2E setup's global-setup.ts always migrates and seeds before the suite runs, which is incompatible with testing the pre-install state without a parallel test-project configuration).

34. Manual verification

Performed directly, with real evidence (screenshots, DOM/API inspection, direct database queries), not assumed:

  • Full installation wizard, end to end, in a real browser against an empty database: wrong token rejected with a generic error; correct token proceeds; all 9 steps filled and submitted; the final transaction created every expected row (confirmed via direct mysql queries — business, booking rule with allowGuestBooking=0, 3 categories, 3 services, Owner role with 15 permissions, Barber role with 6); the administrator was signed in and landed on /owner, which rendered real, non-demo data (a real dashboard reflecting the just-installed business, not seeded fixtures).
  • /install re-entry blocked after completion — direct navigation redirects away.
  • The guest-booking gate, full round trip: signed out → browsed → selected a slot → hit the "Sign in to complete your booking" gate at /book/details (guest booking correctly off from the fresh install) → Sign In → landed back on /book/details, now showing the authenticated details form with the same in-progress booking — the exact scenario §9 describes, actually driven through a browser rather than only reasoned about.
  • Branding across the whole app: browser tab titles ("Owner Settings | Riverside Barbershop", not "— NextChair"), the desktop sidebar nav showing the real business name in place of "NEXTCHAIR", the Welcome page's logo/name/copy, all confirmed visually and via the accessibility tree.
  • Full Docker Compose lifecycle: built the image from a cold cache; started the stack against an empty MySQL volume; confirmed prisma migrate deploy applied all 7 migrations automatically on container start; confirmed the app's own healthcheck reported healthy; confirmed GET /api/health returned installed: false and GET / returned a 307 to /install; walked the installer through a real browser against the containerized app (port-mapped to the host); confirmed the seed profile service (next-chair-seed) runs to completion independently and is never triggered by a normal up; confirmed installed: true and a 200 from / after seeding; restarted both containers and confirmed data and installation state persisted (the named volume, not an ephemeral one); tore the whole test stack down (down -v) and confirmed the pre-existing local dev MySQL container was completely unaffected throughout (different Compose project name, different container names, by design).

35. Unverified external-provider behaviour

No live Google Cloud OAuth client or Brevo/VAPID credentials were available in this environment, so the following are implemented against Google's/the relevant provider's documented, versioned REST contracts and exercised only via the MockCalendarProvider integration tests (§33) and manual code review — not against a real account:

  • The actual Google OAuth consent screen, token exchange, and refresh-token issuance/reuse behaviour (including whether prompt=consent reliably reissues a refresh token on reconnect, as documented).
  • listWritableCalendars's real response shape and accessRole filtering against a real Google account with multiple calendars.
  • Real Google Calendar event create/update/delete responses and their exact error codes/bodies under real rate-limiting, an expired/revoked token, or a deleted calendar.
  • The email/push provider paths (Brevo SMTP, web-push) — unchanged this phase, already flagged as unverified in docs/phase-7-summary.md, and still true here.

36. Known limitations

  • PWA manifest icons are not derived from a business's uploaded appIconUrl — see §15; a future phase adding real image processing could close this.
  • Branding on next-chair-seed/precached-offline-page staleness: the offline page (/offline) is only re-rendered with fresh branding whenever the service worker actually (re-)precaches it (install/activate, while online) — a device that's been offline since before a business renamed itself will show the old name until the SW next updates. Documented as an accepted characteristic, not a bug, in the file itself.
  • EMAIL_FROM_NAME is env-configured, not automatically derived from the installed business — the Brevo provider's config loader is a pure, synchronous function with no database access (see §13); an owner who wants the sender name to exactly match their business name must set the env var themselves. A future phase could thread branding through the notification-sending path instead of its config-loading path to close this.
  • No dedicated "development profile" override for docker-compose.local.yaml (source-mounting/hot-reload/file-watching) was built — the brief marked this explicitly optional ("if included"); the required production-style default stack was prioritized instead given the remaining scope.
  • The Google Calendar OAuth/API integration is unverified against a live account — see §35.
  • New-flow E2E coverage is manual, not automated Playwright specs — see §33's "Not automated" note; the underlying logic for all of these is covered by real integration tests (installer, calendar sync) plus the manual verification recorded in §34, but a future phase wanting CI-enforced regression coverage for the install wizard and guest-gate specifically would need a second Playwright project configured against a genuinely empty (unseeded) database, which the current global-setup.ts doesn't support without changes.
  • The residual Turbopack dev-mode cold-compile E2E flake (the very first test of a full local dev-mode run, tracked since Phase 7) and the pre-existing "barber changes their working hours" time-of-day flake (also tracked since Phase 7) remain unresolved — neither is new to this phase, and CI's own pipeline already runs E2E against a production build (next build before the E2E step), which the dev-mode-specific flake doesn't affect.

Exact Docker Desktop startup commands

cp .env.local.example .env.local
# edit .env.local — at minimum set AUTH_SECRET, INSTALLATION_TOKEN, CRON_SECRET,
# and the MySQL user/password/root-password values

# Start (builds the image on first run)
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local up --build -d

# Logs
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local logs -f next-chair

# Stop
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local down

# Remove database data too
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local down -v

# Seed development/demo data (optional, explicit — never runs automatically)
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local run --rm next-chair-seed

Then visit http://localhost:3000 (or APP_HOST_PORT from .env.local) — a freshly started stack with no seed run shows the installation wizard at /install.