This commit is contained in:
Stefan Lazic committed 2026-08-04 09:57:58 +01:00
1 parent 546613e677
commit 4785510144
82 files changed
+4903 -287

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
# 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
```bash
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`.