diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc74dcd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +node_modules +.next +generated +.git +.gitea +.env +.env.* +!.env.example +!.env.local.example +test-results +playwright-report +blob-report +playwright/.cache +coverage +e2e +docs +*.md +.vscode +.idea +.DS_Store +npm-debug.log* diff --git a/.env.example b/.env.example index 92ea499..f2157a6 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,11 @@ EMAIL_SMTP_PORT="587" EMAIL_SMTP_USERNAME="" EMAIL_SMTP_PASSWORD="" EMAIL_FROM_ADDRESS="" -EMAIL_FROM_NAME="NextChair" +# EMAIL_FROM_NAME is optional and is only used when no business name can +# be resolved. Normal business emails use the installed business name +# automatically (see docs/phase-8-email-sender-summary.md) — you do not +# need to set or keep this in sync with Business Settings. +EMAIL_FROM_NAME="" # 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 — @@ -42,3 +46,19 @@ VAPID_SUBJECT="mailto:owner@example.com" # scheduler presents as `Authorization: Bearer ` to # /api/cron/process-notifications. Generate with: openssl rand -hex 32 CRON_SECRET="" + +# First-run installation wizard (Phase 8) — required before /install will +# create the first business/administrator. Generate with: +# openssl rand -hex 32 +INSTALLATION_TOKEN="" + +# Staff Google Calendar sync (Phase 8) — separate OAuth client from +# AUTH_GOOGLE_ID/SECRET above (different scopes; see +# src/services/calendar/google-calendar.provider.ts). Leave unset to keep +# calendar sync disabled — Settings shows a "not configured" state rather +# than crashing. +GOOGLE_CALENDAR_CLIENT_ID="" +GOOGLE_CALENDAR_CLIENT_SECRET="" +GOOGLE_CALENDAR_REDIRECT_URI="http://localhost:3000/api/staff/calendar/google/callback" +# Encrypts stored refresh tokens at rest. Generate with: openssl rand -hex 32 +CALENDAR_TOKEN_ENCRYPTION_KEY="" diff --git a/.gitignore b/.gitignore index 633f19f..c0b9118 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ yarn-error.log* .idea/ /generated/prisma +.env.example +.env.local.example +.env.example +.env.example diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..26e0b99 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# Multi-stage build. NextChair is the internal project/image name only — +# see docs/phase-8-summary.md's white-label section for why that's fine +# here (Docker/package names are explicitly "internal and acceptable") +# even though the running application never shows that name to a user. +# +# Prisma 7 uses driver adapters (@prisma/adapter-mariadb), not a native +# Rust query-engine binary — the generated client is pure JS, so this +# image needs no special native-binary handling the way older Prisma +# versions on Alpine sometimes did. +# +# `output: "standalone"` in next.config.ts was deliberately NOT enabled: +# 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 — a pruned +# standalone bundle would need those re-added anyway, so a single +# `npm ci` (full dependencies, including the small set of dev tools this +# image actually runs) is simpler and no less correct. See +# docs/phase-8-summary.md, "Docker image quality" for this trade-off. + +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# DATABASE_URL isn't needed to generate the client (no DB connection at +# generate time) — only real values needed at runtime, never baked in here. +RUN npx prisma generate +# `next build`'s page-data collection step imports every route module +# (including /api/health) to inspect it — that pulls in +# src/lib/db/prisma.ts, which requires DATABASE_URL just to construct its +# connection-pool config object, even though nothing actually connects at +# build time. This placeholder only exists to satisfy that check; the +# real value is supplied at container runtime via env_file (see +# docker-compose.local.yaml) and this one is never used to connect to +# anything or baked into a layer that matters. +ENV DATABASE_URL="mysql://build:build@localhost:3306/build" +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production + +# Non-root — see the brief's "Docker image quality: non-root user." +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextchair + +COPY --from=builder --chown=nextchair:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=nextchair:nodejs /app/.next ./.next +COPY --from=builder --chown=nextchair:nodejs /app/public ./public +COPY --from=builder --chown=nextchair:nodejs /app/generated ./generated +COPY --from=builder --chown=nextchair:nodejs /app/prisma ./prisma +COPY --from=builder --chown=nextchair:nodejs /app/src ./src +COPY --from=builder --chown=nextchair:nodejs /app/package.json ./package.json +COPY --from=builder --chown=nextchair:nodejs /app/prisma.config.ts ./prisma.config.ts +COPY --from=builder --chown=nextchair:nodejs /app/next.config.ts ./next.config.ts +COPY --from=builder --chown=nextchair:nodejs /app/tsconfig.json ./tsconfig.json +COPY --chown=nextchair:nodejs docker/entrypoint.sh ./docker/entrypoint.sh +RUN chmod +x ./docker/entrypoint.sh + +USER nextchair +EXPOSE 3000 +ENV PORT=3000 + +ENTRYPOINT ["./docker/entrypoint.sh"] diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml new file mode 100644 index 0000000..8339a0e --- /dev/null +++ b/docker-compose.local.yaml @@ -0,0 +1,91 @@ +name: next-chair + +# Local Docker Desktop deployment — production-style by default (real +# migrations, no auto-seed), not a dev-mode source-mount setup. See the +# README section this file is documented under for exact commands. +# +# Config source of truth: copy .env.local.example to .env.local and run +# every command below with `--env-file .env.local` (see README) — that +# one file drives both container environment variables (via each +# service's `env_file:`) and the few values this compose file itself +# needs (the host-port mappings below). + +services: + next-chair-mysql: + image: mysql:8.0 + container_name: next-chair-mysql + restart: unless-stopped + env_file: + - .env.local + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci + ports: + - "${MYSQL_HOST_PORT:-3306}:3306" + volumes: + - next-chair-mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-change-me-too}"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + networks: + - next-chair + + next-chair: + build: + context: . + dockerfile: Dockerfile + container_name: next-chair + restart: unless-stopped + depends_on: + next-chair-mysql: + condition: service_healthy + env_file: + - .env.local + ports: + - "${APP_HOST_PORT:-3000}:3000" + healthcheck: + # No curl/wget in the node:22-alpine base image — a plain Node + # request avoids adding a package just for this. See + # src/app/api/health/route.ts: healthy as soon as the database is + # reachable, even before installation completes. + test: + [ + "CMD", + "node", + "-e", + "require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + networks: + - next-chair + + # Explicit, opt-in only — never runs as part of a normal `up` (see the + # `profiles` key). Development/demo fixtures, not a production seed — + # see prisma/seed.ts's own header and docs/phase-8-summary.md, + # "Production installation versus development seed." + next-chair-seed: + build: + context: . + dockerfile: Dockerfile + container_name: next-chair-seed + profiles: ["seed"] + depends_on: + next-chair-mysql: + condition: service_healthy + env_file: + - .env.local + entrypoint: ["npx", "tsx", "prisma/seed.ts"] + networks: + - next-chair + +volumes: + next-chair-mysql-data: + name: next-chair-mysql-data + +networks: + next-chair: + name: next-chair diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..ad99724 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Entrypoint for the `next-chair` app container — see docker-compose.local.yaml. +# `migrate deploy` (never `migrate reset`) is safe to run on every +# container start: it only applies migrations not yet recorded in +# `_prisma_migrations`, so a restart with an already-up-to-date schema is +# a fast no-op, never destructive. It intentionally does NOT seed — +# development fixtures are a separate, explicit opt-in service (see +# `next-chair-seed` in docker-compose.local.yaml and the brief's own +# "production installation versus development seed" distinction); a +# fresh container is left empty and shows the installation wizard. +set -e + +echo "[entrypoint] Applying database migrations..." +npx prisma migrate deploy + +echo "[entrypoint] Starting application..." +# `exec` + calling the `next` binary directly (not `npm run start`) so +# the Next.js server process itself receives SIGTERM directly from +# Docker on `docker stop` / `docker compose down` — routing through npm +# as an intermediary process can swallow the signal and force a slower +# SIGKILL after Compose's stop-grace-period instead of a clean shutdown. +exec node_modules/.bin/next start -p "${PORT:-3000}" diff --git a/docs/phase-8-email-sender-summary.md b/docs/phase-8-email-sender-summary.md new file mode 100644 index 0000000..e15afea --- /dev/null +++ b/docs/phase-8-email-sender-summary.md @@ -0,0 +1,99 @@ +# Phase 8 Follow-Up — Business Name as Email Sender: Summary + +A focused fix on top of Phase 8: every outgoing email's visible sender **name** now comes from the installed business's own name, not `EMAIL_FROM_NAME` or the internal product name. The sender **email address** stays exactly as it was — environment-configured, since it has to match whatever address/domain is actually verified with the SMTP relay. + +--- + +## Where the business name is resolved + +In `src/services/notifications/render-email.ts`'s `renderEmailForNotification()` — the one function that turns a stored `Notification` row into an actual email, already responsible for loading the business (for the existing name/logo/address/policy fields every template uses). It now also does: + +```ts +const business = await prisma.business.findUnique({ where: { id: notification.businessId }, include: {...} }); +const sender: EmailSender = { name: resolveSenderName(business.name) }; +``` + +`notification.businessId` is a required, non-nullable column (`prisma/schema.prisma`) — every `Notification` in this schema is already business-scoped, so this is a `findUnique` on the notification's own explicit relationship, never a global `findFirst()`. Verified directly with an integration test that creates two businesses and confirms a notification scoped to business A never resolves business B's name, even though B exists in the same database (`sender-identity.integration.test.ts`, "never lets one business's notification resolve another business's name"). + +This lookup happens **fresh, every time** — `renderEmailForNotification` is called by the background processor right before a `PENDING` delivery is actually sent (`reminder-worker.ts`), never at notification-creation time. That was already true before this change (see that file's own header, written in Phase 7); this change just rides the same guarantee for the sender name. No caching beyond the implicit request-scoped Prisma round trip — nothing is cached across requests or worker runs, so a business rename takes effect on the very next email sent, with no restart and no environment-variable change. Verified directly: `sender-identity.integration.test.ts`'s "uses the updated business name for the very next email after a rename" test sends one email, renames the business via a plain `prisma.business.update()` (the same call `BusinessService.updateBusinessProfile` makes from Owner Settings), sends a second email in the same process, and asserts the second one uses the new name. + +## How it reaches the Brevo provider + +`renderEmailForNotification()` now returns `RenderedEmailWithSender` (`{ subject, html, text, sender }`) instead of the old `RenderedEmail`. `reminder-worker.ts`'s `processEmailDelivery()` — the **only** call site of `EmailProvider.sendEmail()` in the entire codebase (confirmed by grep before making this change) — passes `sender: email.sender` straight through: + +```ts +const email = await renderEmailForNotification(prisma, delivery.notification); +const sendResult = await emailProvider.sendEmail({ + to: { email: recipientEmail }, + sender: email.sender, + subject: email.subject, + html: email.html, + text: email.text, + idempotencyKey: delivery.id, +}); +``` + +`SendEmailParams` (`src/services/notifications/providers/email-provider.ts`) gained a required `sender: EmailSender` field (`{ name: string; email?: string }`). `BrevoEmailProvider.sendEmail()` uses `params.sender.name` for the SMTP `from` display name, and **always** its own `this.fromAddress` (from `BrevoEmailProviderConfig`, env-configured) for the address — `params.sender.email` is accepted in the shape for forward-compatibility with a future provider that might need it, but Brevo's own implementation ignores it, documented inline and covered by a test (`brevo-email-provider.test.ts`, "sends with the per-message sender name and the provider's own configured address, ignoring any sender.email"). `BrevoEmailProviderConfig` itself dropped `fromName` entirely — it now only carries `host`/`port`/`username`/`password`/`fromAddress`, i.e. only static, non-business-derived provider settings, per the architecture this was explicitly asked to follow. `loadBrevoEmailProviderConfigFromEnv()` no longer reads `EMAIL_FROM_NAME` at all — that env var is read exactly once, inside `resolveSenderName()` (see below), not at provider-construction time. + +`ConsoleEmailProvider` (the dev default) also prints the resolved sender name (`From: {name}`) so local development shows exactly what would be sent — this is what the manual verification below used. + +## The sender-name fallback order + +`src/services/notifications/sender-identity.ts` — a small, pure, DB-free function: + +```ts +export function resolveSenderName(businessName: string | null | undefined): string { + const trimmedBusinessName = businessName?.trim(); + if (trimmedBusinessName) return trimmedBusinessName; + + const envFallback = process.env.EMAIL_FROM_NAME?.trim(); + if (envFallback) return envFallback; + + return "Booking Notifications"; +} +``` + +1. **The installed business's own name** — the normal, always-taken path for a real installed application (every `Notification` is business-scoped, and `Business.name` is a required, non-empty column populated by the installer/seed). +2. **`EMAIL_FROM_NAME`**, only reached if tier 1 somehow resolves to nothing (a blank business name, or, in principle, a future notification type with no business association at all — no such type exists in this codebase today). Never required to be kept in sync with the business name. +3. **`"Booking Notifications"`** — a neutral, non-product name, reached only if neither of the above is set. `NextChair` never appears anywhere in this function. + +Unit-tested directly (`sender-identity.test.ts`): business name wins even when `EMAIL_FROM_NAME` is also set; `EMAIL_FROM_NAME` is used only when the business name is null/undefined/empty/whitespace; the neutral fallback is used when neither is available; and a regression guard asserting the function never returns `"NextChair"` at any tier. + +## Why the sender email stays environment-configured + +Brevo (and SMTP relays generally) require the sending address to belong to a domain/address actually verified with the account — an arbitrary per-message address would either be silently rewritten by the relay or rejected outright. The business name has no such constraint (any string is a valid display name), so it was safe to make per-message and business-derived; the address was not, and stays exactly where it was before this change — a single environment variable (`EMAIL_FROM_ADDRESS`) loaded once into `BrevoEmailProviderConfig`. `.env.example` and `.env.local.example` were updated to make this explicit for `EMAIL_FROM_NAME` specifically (now documented as "optional... only used when no business name can be resolved... you do not need to set or keep this in sync with Business Settings"); `EMAIL_FROM_ADDRESS`'s own existing documentation was untouched, since its behaviour didn't change. + +## Delivery-record snapshotting + +**Not snapshotted.** `NotificationDelivery` gained no new column for this. This follows the existing, explicit design philosophy stated in `render-email.ts`'s own header (written in Phase 7, unchanged by this follow-up): never store rendered content on the row, always re-derive it from current data at send time. Adding a `senderNameUsed` column would cut against that stated design for a "where useful" (the brief's own wording — optional) requirement, and — because `Business` itself has no history/versioning table — a snapshot column would only be reliable going forward, not retroactively reconstructable for anything sent before it existed. `providerMessageId`/`providerName` already exist on `NotificationDelivery` for send-forensics; reconstructing "what name did we actually show" for a historical delivery would require resolving what the business's name was at `sentAt`, which this app cannot do today (documented here as a known limitation, not fixed, since it wasn't asked for and would need a real audit/versioning mechanism to do properly). + +## Background notification processing + +No change was needed to `processDueNotificationDeliveries`'s own structure — it already calls `renderEmailForNotification()` fresh for every delivery it processes, so the "resolve current business identity when rendering/sending, don't assume creation-time branding is still current" requirement was already satisfied by the existing Phase 7 architecture. This follow-up only added the sender-name resolution to that same, already-correct call. Verified directly: `sender-identity.integration.test.ts`'s "passes the resolved business name all the way through the background processor to the email provider" test goes through the real `processDueNotificationDeliveries()` entry point (not just `renderEmailForNotification()` directly) and asserts the `MockEmailProvider` captured the correct sender name. + +## Email types covered + +Every email type is covered by construction, not type-by-type special-casing: `renderEmailForNotification()` is the **only** path to an outgoing email (confirmed: `EmailProvider.sendEmail()` has exactly one call site, `reminder-worker.ts`), and it resolves and attaches the sender identically regardless of which `NotificationType` it's rendering or which template (a specific booking/loyalty template, or the generic fallback template) ends up producing the subject/html/text. This includes `STAFF_INVITATION_RECEIVED` (currently rendered via the generic fallback template — the specialized `renderStaffInvitationEmail`/`renderStaffScheduleChangedEmail` functions in `templates/staff-emails.ts` are not wired into `renderEmailForNotification`'s dispatch switch, a pre-existing gap from Phase 7 left untouched here since it's out of this follow-up's scope), which is why a dedicated integration test exists for it specifically (`sender-identity.integration.test.ts`, "resolves the business name for a staff-invitation notification"). + +## Tests added + +**Unit** (`src/services/notifications/__tests__/sender-identity.test.ts`, 6 tests): business name used when present; business name wins over `EMAIL_FROM_NAME`; `EMAIL_FROM_NAME` used only when no business name; neutral fallback used when neither is set (including whitespace-only business names); `NextChair` never returned at any tier. + +**Unit, updated** (`brevo-email-provider.test.ts`): existing tests updated for the new `sender` field; one new test asserts the provider uses the per-message sender name and its own configured address, explicitly ignoring `sender.email`. + +**Integration, real MySQL** (`src/services/notifications/__tests__/sender-identity.integration.test.ts`, 5 tests): a booking/loyalty-style notification resolves the installed business's name; a staff-invitation notification resolves it too (via the generic template); the full background-processor path (`processDueNotificationDeliveries`) passes the resolved name through to the email provider; renaming the business mid-process changes the very next email's sender with no restart; one business's notification never resolves another business's name even when both exist in the same database. + +Full regression run after these changes: 236 unit tests (was 230), 163 integration tests (was 158) — all passing, zero new failures. + +## Manual verification + +Ran directly against the real seeded local MySQL database (not a scripted mock), using the actual `ConsoleEmailProvider` the app selects when `EMAIL_PROVIDER=console` (this environment's local `.env` setting) — deliberately with `.env`'s `EMAIL_FROM_NAME` left at its old, stale, misconfigured value (`"NextChair"`) specifically to prove that value is no longer used as the primary source: + +- Every real, seeded notification (loyalty stamp, staff new-appointment, owner new-booking) rendered and "sent" through the actual `processDueNotificationDeliveries` → `renderEmailForNotification` → `ConsoleEmailProvider` pipeline showed `From: Blake Heritage Barbershop` — never `From: NextChair`, despite the misconfigured env var. +- Renamed the business directly via `prisma.business.update()` (the same underlying call Owner Settings' "Business Details" form makes) to `"Verify Rename Barbershop"`, in the same running process, no restart. +- The very next notification sent immediately afterward showed `From: Verify Rename Barbershop` — confirming no caching, no restart required, no environment-variable change required. +- Reverted the business name back to its original value afterward, leaving the seeded database as found. + +Not independently re-verified this specific change through a full Docker Compose install cycle: `docker-compose.local.yaml`/`Dockerfile` were not touched by this follow-up, the email-sending code path is identical in a container (same source, same `ConsoleEmailProvider`/`BrevoEmailProvider` classes), and Phase 8's own summary already recorded a full, real Docker Compose lifecycle verification (build → empty volume → install → seed → restart-persists → teardown). Re-running that entire cycle here would not exercise anything Docker-specific about this particular change; the sender-name behaviour itself was verified against the real database and real (non-mocked, for the "would send" log) code path instead, which is the part that's actually new. + +Not verified (unchanged from Phase 8's own "Unverified external-provider behaviour" section, still true here): a real Brevo SMTP send with the new sender-name behaviour — no live Brevo credentials were available in this environment. `BrevoEmailProvider`'s exact `nodemailer.sendMail({ from: { address, name } })` call is unit-tested (mocked transporter, asserting the correct `from` object is constructed), which is the same level of provider-boundary confidence this whole project has used for Brevo throughout every prior phase. diff --git a/docs/phase-8-summary.md b/docs/phase-8-summary.md new file mode 100644 index 0000000..518b589 --- /dev/null +++ b/docs/phase-8-summary.md @@ -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`. diff --git a/prisma/migrations/20260804070345_phase8_installation_branding_calendar/migration.sql b/prisma/migrations/20260804070345_phase8_installation_branding_calendar/migration.sql new file mode 100644 index 0000000..40fd698 --- /dev/null +++ b/prisma/migrations/20260804070345_phase8_installation_branding_calendar/migration.sql @@ -0,0 +1,77 @@ +-- AlterTable +ALTER TABLE `BookingRule` MODIFY `allowGuestBooking` BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE `Business` ADD COLUMN `appIconUrl` VARCHAR(191) NULL, + ADD COLUMN `backgroundColor` VARCHAR(191) NULL, + ADD COLUMN `coverImageUrl` VARCHAR(191) NULL, + ADD COLUMN `faviconUrl` VARCHAR(191) NULL, + ADD COLUMN `shortName` VARCHAR(191) NULL, + ADD COLUMN `themeColor` VARCHAR(191) NULL; + +-- CreateTable +CREATE TABLE `ApplicationInstallation` ( + `id` VARCHAR(191) NOT NULL, + `status` ENUM('NOT_INSTALLED', 'INSTALLING', 'INSTALLED', 'FAILED') NOT NULL DEFAULT 'NOT_INSTALLED', + `installedAt` DATETIME(3) NULL, + `installedVersion` VARCHAR(191) NULL, + `initialAdminUserId` VARCHAR(191) NULL, + `primaryBusinessId` VARCHAR(191) NULL, + `metadata` JSON NULL, + `failureReason` TEXT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +-- CreateTable +CREATE TABLE `StaffCalendarConnection` ( + `id` VARCHAR(191) NOT NULL, + `staffId` VARCHAR(191) NOT NULL, + `provider` ENUM('GOOGLE') NOT NULL DEFAULT 'GOOGLE', + `externalAccountId` VARCHAR(191) NOT NULL, + `accountEmail` VARCHAR(191) NOT NULL, + `encryptedAccessToken` TEXT NULL, + `encryptedRefreshToken` TEXT NOT NULL, + `tokenExpiresAt` DATETIME(3) NULL, + `selectedCalendarId` VARCHAR(191) NOT NULL, + `selectedCalendarName` VARCHAR(191) NOT NULL, + `connectedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `lastSyncAt` DATETIME(3) NULL, + `lastSyncError` TEXT NULL, + `revokedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `StaffCalendarConnection_staffId_key`(`staffId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +-- CreateTable +CREATE TABLE `AppointmentCalendarEvent` ( + `id` VARCHAR(191) NOT NULL, + `appointmentId` VARCHAR(191) NOT NULL, + `connectionId` VARCHAR(191) NOT NULL, + `providerEventId` VARCHAR(191) NOT NULL, + `providerCalendarId` VARCHAR(191) NOT NULL, + `status` ENUM('SYNCED', 'PENDING', 'FAILED', 'CANCELLED') NOT NULL DEFAULT 'SYNCED', + `lastSyncedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `lastSyncError` TEXT NULL, + `sourceVersion` INTEGER NOT NULL DEFAULT 1, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `AppointmentCalendarEvent_connectionId_idx`(`connectionId`), + UNIQUE INDEX `AppointmentCalendarEvent_appointmentId_connectionId_key`(`appointmentId`, `connectionId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +-- AddForeignKey +ALTER TABLE `StaffCalendarConnection` ADD CONSTRAINT `StaffCalendarConnection_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `AppointmentCalendarEvent` ADD CONSTRAINT `AppointmentCalendarEvent_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `AppointmentCalendarEvent` ADD CONSTRAINT `AppointmentCalendarEvent_connectionId_fkey` FOREIGN KEY (`connectionId`) REFERENCES `StaffCalendarConnection`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d1d6784..c7389b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -191,6 +191,31 @@ enum NotificationDeliveryStatus { SKIPPED } +/// See src/lib/installation/installation-state.ts — the one place this is +/// read. INSTALLING exists purely as an in-transaction lock state (see +/// ApplicationInstallation's own header); no caller ever observes it +/// committed, since a failed install rolls the whole transaction back to +/// no-row-at-all rather than leaving INSTALLING or FAILED sitting around. +enum InstallationStatus { + NOT_INSTALLED + INSTALLING + INSTALLED + FAILED +} + +enum CalendarProvider { + GOOGLE +} + +/// Per-appointment sync outcome, not the connection's overall health (see +/// StaffCalendarConnection.lastSyncError for that). +enum CalendarSyncStatus { + SYNCED + PENDING + FAILED + CANCELLED +} + // ───────────────────────────────────────────────────────────────────────── // Business // ───────────────────────────────────────────────────────────────────────── @@ -199,11 +224,30 @@ enum NotificationDeliveryStatus { /// every other model hangs off `businessId` rather than assuming a /// singleton so multi-business support later is additive, not a rewrite. model Business { - id String @id @default(cuid()) - slug String @unique - name String - description String? @db.Text - logoUrl String? + id String @id @default(cuid()) + slug String @unique + name String + /// Short form for tight spaces (PWA home-screen label, browser tab on + /// narrow viewports) — falls back to a truncated `name` when unset, see + /// src/lib/business/branding.ts. + shortName String? + description String? @db.Text + logoUrl String? + /// Wide banner image for the Welcome page / booking pages — distinct + /// from `logoUrl` (square mark) and optional; falls back to a plain + /// color background when unset. + coverImageUrl String? + /// Falls back to the app's neutral favicon when unset — see + /// src/app/icon.png and the branding fallback rules in + /// src/lib/business/branding.ts. + faviconUrl String? + /// Used for the PWA manifest icon set — falls back to the same neutral + /// app icon as the favicon when unset. + appIconUrl String? + /// Hex color, e.g. "#111b15" — PWA `theme_color` / manifest and + /// browser chrome. Falls back to the app's own theme color when unset. + themeColor String? + backgroundColor String? phone String? email String? @@ -317,7 +361,13 @@ model BookingRule { maxAppointmentsPerStaffPerDay Int? customerCanChooseStaff Boolean @default(true) - allowGuestBooking Boolean @default(true) + /// Defaults to false as of Phase 8 (see docs/phase-8-summary.md) — a + /// freshly installed business requires sign-in/account creation before + /// a booking confirms until the owner explicitly opts in via + /// /owner/settings/booking-rules. Existing guest appointments and their + /// access-token links are unaffected either way (see + /// GuestAppointmentAccessToken) — this only gates *new* bookings. + allowGuestBooking Boolean @default(false) requireApproval Boolean @default(false) /// Modelled now, not enforced anywhere yet — deposits are a future /// payments phase (see docs/implementation-plan.md §6, "later phase"). @@ -326,6 +376,46 @@ model BookingRule { updatedAt DateTime @updatedAt } +// ───────────────────────────────────────────────────────────────────────── +// Installation +// ───────────────────────────────────────────────────────────────────────── + +/// Single source of truth for "has this deployment been set up yet" — see +/// src/lib/installation/installation-state.ts, the only place this is +/// read. Deliberately not inferred from "does Business have any rows" — +/// an explicit status column survives a partially-failed install cleanly. +/// +/// v1 expects exactly one row ever, enforced by a fixed, well-known `id` +/// rather than a separate lock table: the installer's transaction starts +/// with `create({ id: "singleton", status: INSTALLING })` as its very +/// first statement, so a concurrent second install attempt blocks on +/// that same primary-key insert and then fails with a unique-constraint +/// violation once the first transaction commits — no read-then-write +/// race window. If the winning transaction fails partway through, the +/// whole transaction (including this row) rolls back, so a retry sees +/// "not installed" again rather than a stuck INSTALLING/FAILED row — see +/// src/features/installation/actions/install.ts. +model ApplicationInstallation { + id String @id @default(cuid()) + + status InstallationStatus @default(NOT_INSTALLED) + + installedAt DateTime? + installedVersion String? + + initialAdminUserId String? + primaryBusinessId String? + + /// Small, non-secret metadata only (e.g. which starter-service option + /// was chosen) — never the installation token, never credentials. + metadata Json? + + failureReason String? @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // ───────────────────────────────────────────────────────────────────────── // Auth (Auth.js / NextAuth v5 + Prisma adapter standard shape) // ───────────────────────────────────────────────────────────────────────── @@ -438,7 +528,7 @@ model CustomerProfile { appointments Appointment[] loyaltyAccounts LoyaltyAccount[] notes CustomerNote[] - receivedNotifications Notification[] @relation("NotificationRecipientCustomer") + receivedNotifications Notification[] @relation("NotificationRecipientCustomer") pushSubscriptions PushSubscription[] @@index([email]) @@ -470,13 +560,16 @@ model StaffProfile { breaks Break[] timeOff TimeOff[] appointments Appointment[] - preferredByCustomers CustomerProfile[] @relation("PreferredBarber") - authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor") - authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor") - redeemedLoyaltyRewards LoyaltyReward[] @relation("LoyaltyRewardRedeemedBy") + preferredByCustomers CustomerProfile[] @relation("PreferredBarber") + authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor") + authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor") + redeemedLoyaltyRewards LoyaltyReward[] @relation("LoyaltyRewardRedeemedBy") /// Set when this profile was created via the owner "Add Staff" flow /// rather than seeded directly — see StaffInvitationService. invitation StaffInvitation? + /// At most one connected Google account per staff member — see + /// StaffCalendarConnection and src/services/calendar. + calendarConnection StaffCalendarConnection? @@index([businessId]) } @@ -544,6 +637,78 @@ model StaffInvitation { @@index([businessId]) } +/// A staff member's own connected Google account for one-way appointment +/// sync (business → Google Calendar) — entirely separate from Google +/// sign-in (Account/next-auth): a barber may authenticate with email and +/// still connect a *different* Google account here, or vice versa. See +/// src/services/calendar/calendar-sync.service.ts. +model StaffCalendarConnection { + id String @id @default(cuid()) + + staffId String @unique + staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade) + + provider CalendarProvider @default(GOOGLE) + externalAccountId String + accountEmail String + + /// AES-256-GCM ciphertext (base64: iv + authTag + ciphertext) — see + /// src/lib/security/token-encryption.ts. CALENDAR_TOKEN_ENCRYPTION_KEY + /// is the only key that can decrypt these; never logged, never + /// returned to the client, never readable by the owner (see + /// StaffCalendarConnection's owner-facing projection in + /// calendar-sync.service.ts, which only ever selects the non-token + /// columns). + encryptedAccessToken String? @db.Text + encryptedRefreshToken String @db.Text + tokenExpiresAt DateTime? + + selectedCalendarId String + selectedCalendarName String + + connectedAt DateTime @default(now()) + lastSyncAt DateTime? + lastSyncError String? @db.Text + revokedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + events AppointmentCalendarEvent[] +} + +/// One row per (appointment, connection) pair the appointment has ever +/// been synced to — the unique constraint below is what makes sync +/// idempotent (an existing row means "update the same event", not +/// "create a new one"). +model AppointmentCalendarEvent { + id String @id @default(cuid()) + + appointmentId String + appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade) + + connectionId String + connection StaffCalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + + providerEventId String + providerCalendarId String + + status CalendarSyncStatus @default(SYNCED) + lastSyncedAt DateTime @default(now()) + lastSyncError String? @db.Text + /// Bumped on every appointment mutation that should trigger a re-push + /// (reschedule, service change) — lets the sync worker distinguish + /// "already reflects the latest appointment state" from "needs an + /// update" without re-diffing full event content each time. + sourceVersion Int @default(1) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([appointmentId, connectionId]) + @@index([connectionId]) +} + // ───────────────────────────────────────────────────────────────────────── // Catalog // ───────────────────────────────────────────────────────────────────────── @@ -752,6 +917,7 @@ model Appointment { /// as the array Prisma requires for a one-to-many back-relation. appliedLoyaltyRewards LoyaltyReward[] guestAccessToken GuestAppointmentAccessToken? + calendarEvents AppointmentCalendarEvent[] @@index([staffId, startAt]) @@index([businessId, startAt]) @@ -984,12 +1150,12 @@ model Notification { loyaltyRewardId String? loyaltyReward LoyaltyReward? @relation(fields: [loyaltyRewardId], references: [id], onDelete: SetNull) - subject String? + subject String? /// Canonical rendered plain-text content — the in-app notification body /// and the email plain-text fallback both read from here; the HTML email /// template (see src/services/notifications/templates) renders its own /// markup from the same underlying data, not from this string. - bodyText String @db.Text + bodyText String @db.Text /// Relative path (e.g. "/appointments/xyz?token=..." for a guest) — /// never an absolute URL, so it works whether the notification is opened /// from an email (base URL prepended at send time), the installed PWA, @@ -1036,7 +1202,7 @@ model NotificationDelivery { providerMessageId String? /// A short, safe-to-display summary only — never the provider's raw /// error payload, which can include request details worth not logging. - failureReason String? + failureReason String? sentAt DateTime? @@ -1061,7 +1227,7 @@ model NotificationPreference { type NotificationType channel NotificationChannel - isEnabled Boolean @default(true) + isEnabled Boolean @default(true) @@unique([userId, type, channel]) } @@ -1087,7 +1253,7 @@ model PushSubscription { /// The push service's own subscription URL — long (some real-world push /// services exceed 191 chars), hence the explicit length instead of /// Prisma's default VARCHAR(191) for a unique-constrained String. - endpoint String @unique @db.VarChar(500) + endpoint String @unique @db.VarChar(500) /// The subscription's own public key/auth secret (from /// PushSubscription.toJSON().keys in the browser) — needed to encrypt /// each push payload; not a credential of ours to protect the way diff --git a/prisma/seed.ts b/prisma/seed.ts index 8ff773a..8f6bac1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -4,10 +4,12 @@ * every relation in the schema once, not a full demo dataset. */ import "dotenv/config"; -import { PrismaClient, Permission, Weekday, RewardType, NotificationType } from "../generated/prisma/client"; +import { PrismaClient, Weekday, RewardType, NotificationType, InstallationStatus } from "../generated/prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config"; import { notifySafely } from "../src/services/notifications/notification.service"; +import { OWNER_PERMISSIONS, BARBER_PERMISSIONS, syncRolePermissions } from "../src/lib/roles/default-permissions"; +import { INSTALLATION_SINGLETON_ID } from "../src/lib/installation/installation-state"; import bcrypt from "bcryptjs"; /** @@ -29,52 +31,6 @@ function minutesFromNow(minutes: number): Date { const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL)); const prisma = new PrismaClient({ adapter }); -/** - * `role.upsert`'s `update: {}` deliberately does nothing on an existing - * role (see the two call sites below) — re-running the seed must still be - * safe to call repeatedly without duplicating anything, but a plain - * nested `permissions: { create: [...] }` only fires on the `create` - * branch, so a permission added to OWNER_PERMISSIONS/BARBER_PERMISSIONS - * after a business was first seeded would silently never reach an - * already-seeded database. This syncs RolePermission rows to exactly - * match the target list every run — found and fixed while adding Phase 6 - * permissions to a database seeded back in Phase 2. - */ -async function syncRolePermissions(roleId: string, permissions: Permission[]): Promise { - await prisma.rolePermission.deleteMany({ where: { roleId, permission: { notIn: permissions } } }); - await prisma.rolePermission.createMany({ - data: permissions.map((permission) => ({ roleId, permission })), - skipDuplicates: true, - }); -} - -const OWNER_PERMISSIONS: Permission[] = [ - Permission.MANAGE_BUSINESS_PROFILE, - Permission.MANAGE_BOOKING_RULES, - Permission.MANAGE_SERVICES, - Permission.MANAGE_STAFF, - Permission.MANAGE_LOYALTY_PROGRAMME, - Permission.MANAGE_NOTIFICATION_SETTINGS, - Permission.VIEW_ALL_STAFF_APPOINTMENTS, - Permission.MANAGE_ALL_APPOINTMENTS, - Permission.MANAGE_OWN_APPOINTMENTS, - Permission.CHECK_IN_CUSTOMERS, - Permission.MANAGE_LOYALTY_STAMPS, - Permission.MANAGE_OWN_AVAILABILITY, - Permission.VIEW_CUSTOMER_DIRECTORY, - Permission.BOOK_ON_BEHALF_OF_CUSTOMER, - Permission.VIEW_REPORTING, -]; - -const BARBER_PERMISSIONS: Permission[] = [ - Permission.MANAGE_OWN_APPOINTMENTS, - Permission.CHECK_IN_CUSTOMERS, - Permission.MANAGE_LOYALTY_STAMPS, - Permission.MANAGE_OWN_AVAILABILITY, - Permission.VIEW_CUSTOMER_DIRECTORY, - Permission.BOOK_ON_BEHALF_OF_CUSTOMER, -]; - const WEEKDAYS: Weekday[] = [ Weekday.MONDAY, Weekday.TUESDAY, @@ -112,7 +68,13 @@ async function main() { lateArrivalPolicy: "Arrivals more than 10 minutes late may need to be rescheduled.", }, }, - bookingRule: { create: {} }, // all defaults from the schema + // Guest booking defaults to false as of Phase 8 (see BookingRule in + // prisma/schema.prisma) — the dev/demo fixture explicitly opts back + // in so the guest-booking E2E journey and manual testing keep + // working; a real installed business starts with it off (see + // src/features/installation/installer.service.ts) until the owner + // enables it themselves. + bookingRule: { create: { allowGuestBooking: true } }, hours: { create: [ ...WEEKDAYS.map((dayOfWeek) => ({ @@ -146,7 +108,7 @@ async function main() { permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) }, }, }); - await syncRolePermissions(ownerRole.id, OWNER_PERMISSIONS); + await syncRolePermissions(prisma, ownerRole.id, OWNER_PERMISSIONS); const barberRole = await prisma.role.upsert({ where: { businessId_name: { businessId: business.id, name: "Barber" } }, @@ -158,7 +120,7 @@ async function main() { permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) }, }, }); - await syncRolePermissions(barberRole.id, BARBER_PERMISSIONS); + await syncRolePermissions(prisma, barberRole.id, BARBER_PERMISSIONS); const category = await prisma.serviceCategory.upsert({ where: { id: `${business.id}-senior-barber` }, @@ -1013,6 +975,29 @@ async function main() { }); } + // Dev/demo fixtures bypass the installation wizard entirely (see + // src/features/installation/installer.service.ts for the real, + // production first-run path) — this upsert only exists so a freshly + // seeded dev/CI/E2E database is treated as "installed" by the routing + // gate in src/proxy.ts, exactly as if a real install had completed. + // Never run alongside a real installation: the two are mutually + // exclusive by construction (the installer's own transaction refuses + // to start once this row exists — see ApplicationInstallation's model + // header). + await prisma.applicationInstallation.upsert({ + where: { id: INSTALLATION_SINGLETON_ID }, + update: {}, + create: { + id: INSTALLATION_SINGLETON_ID, + status: InstallationStatus.INSTALLED, + installedAt: new Date(), + installedVersion: "dev-seed", + initialAdminUserId: ownerUser.id, + primaryBusinessId: business.id, + metadata: { source: "prisma/seed.ts" }, + }, + }); + console.log("Seed complete:", { business: business.name, roles: [ownerRole.name, barberRole.name], diff --git a/public/sw.js b/public/sw.js index dda14b0..4991434 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -// NextChair service worker. +// Service worker — internal filename/comment only, never shown to a user. // // Scope, deliberately: this app is almost entirely dynamic, signed-in, // per-request server-rendered content (appointments, calendars, loyalty @@ -94,7 +94,11 @@ self.addEventListener("push", (event) => { return; } - const title = payload.title || "NextChair"; + // The server always sets a real, business-branded title (see + // src/services/notifications/reminder-worker.ts) — this is only a + // defensive fallback for a malformed payload, so it stays neutral + // rather than naming the internal product. + const title = payload.title || "New notification"; const options = { body: payload.body || "", data: { url: payload.url || "/" }, diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 2642140..3724163 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,11 +1,36 @@ import { Scissors } from "lucide-react"; +import { getBranding } from "@/lib/business/branding"; -export default function AuthLayout({ children }: { children: React.ReactNode }) { +/** + * Every auth page (sign-in, register, forgot-password, verify) shares + * this shell, so the business logo is shown once here rather than on + * each page — see the brief's "Authentication Branding" requirement. + * Unreachable pre-install: src/proxy.ts redirects auth routes to + * /install until a business exists, so `getBranding()` never falls back + * to neutral wording here in practice. + * + * Forced dynamic: without this, a production build can statically + * prerender /sign-in and /register with whatever branding (or the + * neutral fallback) happened to be in the database at *build* time, + * baked into static HTML served forever after — a real business's name + * would never actually reach these pages in that deployment. See + * docs/phase-8-summary.md, "Known limitations" for how this was found + * (a Docker build with no live database at build time). + */ +export const dynamic = "force-dynamic"; + +export default async function AuthLayout({ children }: { children: React.ReactNode }) { + const branding = await getBranding(); return (
-
{children}
diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 648700e..fa8021b 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,16 +1,22 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { RegisterForm } from "@/features/auth/components/register-form"; +import { getBranding } from "@/lib/business/branding"; export const metadata: Metadata = { title: "Create Account", }; -export default function RegisterPage() { +export default async function RegisterPage() { + const branding = await getBranding(); return (
-

Create Account

-

Join to track your appointments and loyalty.

- +

Join {branding.name}

+

Create an account to track your appointments and loyalty.

+ {/* useSearchParams in RegisterForm requires a Suspense boundary. */} + + +
); } diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index 40b886b..6a37132 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -1,15 +1,17 @@ import type { Metadata } from "next"; import { Suspense } from "react"; import { SignInForm } from "@/features/auth/components/sign-in-form"; +import { getBranding } from "@/lib/business/branding"; export const metadata: Metadata = { title: "Sign In", }; -export default function SignInPage() { +export default async function SignInPage() { + const branding = await getBranding(); return (
-

Welcome back

+

Welcome back to {branding.name}

Please enter your credentials to access your appointments.

diff --git a/src/app/(customer)/settings/page.tsx b/src/app/(customer)/settings/page.tsx index f43f7d6..49d8531 100644 --- a/src/app/(customer)/settings/page.tsx +++ b/src/app/(customer)/settings/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/feedback/empty-state"; import { ProfileCard } from "@/components/ui/profile-card"; import { prisma } from "@/lib/db/prisma"; +import { getBranding } from "@/lib/business/branding"; import { SignOutButton } from "@/features/auth/components/sign-out-button"; import { InstallAppButton } from "@/features/pwa/components/install-app-button"; @@ -33,9 +34,10 @@ export default async function CustomerSettingsPage() { ); } - const customerProfile = await prisma.customerProfile.findUnique({ - where: { userId: session.user.id }, - }); + const [customerProfile, branding] = await Promise.all([ + prisma.customerProfile.findUnique({ where: { userId: session.user.id } }), + getBranding(), + ]); return (
@@ -65,7 +67,7 @@ export default async function CustomerSettingsPage() {

Get the app

- +
diff --git a/src/app/(owner)/owner/settings/page.tsx b/src/app/(owner)/owner/settings/page.tsx index 9b2ca1f..11b16a4 100644 --- a/src/app/(owner)/owner/settings/page.tsx +++ b/src/app/(owner)/owner/settings/page.tsx @@ -7,6 +7,7 @@ import { ProfileCard } from "@/components/ui/profile-card"; import { SignOutButton } from "@/features/auth/components/sign-out-button"; import { hasPermission } from "@/lib/auth/permissions"; import { Permission } from "@/generated/prisma/enums"; +import { getBranding } from "@/lib/business/branding"; import { InstallAppButton } from "@/features/pwa/components/install-app-button"; export const metadata: Metadata = { title: "Owner Settings" }; @@ -26,6 +27,7 @@ export default async function OwnerSettingsPage() { const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF); if (!canManage) redirect("/staff"); + const branding = await getBranding(); return (
@@ -53,7 +55,7 @@ export default async function OwnerSettingsPage() {

Get the app

- +
diff --git a/src/app/(owner)/owner/team/[id]/page.tsx b/src/app/(owner)/owner/team/[id]/page.tsx index 4ffe6d3..e8d4d2b 100644 --- a/src/app/(owner)/owner/team/[id]/page.tsx +++ b/src/app/(owner)/owner/team/[id]/page.tsx @@ -26,10 +26,15 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise if (!owner) redirect("/staff"); const { id } = await params; - const [target, roles, allServices] = await Promise.all([ + const [target, roles, allServices, calendarConnection] = await Promise.all([ getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }), listRolesForBusiness(prisma, owner.businessId), prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }), + // Status only — never tokens (see the brief's "Owners must not see + // OAuth tokens, refresh tokens, or unrelated private calendar + // events"). Owners can see whether a barber has connected, not + // connect on their behalf. + prisma.staffCalendarConnection.findUnique({ where: { staffId: id }, select: { revokedAt: true, lastSyncError: true } }), ]); if (!target) notFound(); @@ -72,6 +77,20 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise )}
+ + +

Google Calendar

+

+ {!calendarConnection || calendarConnection.revokedAt + ? "Not connected" + : calendarConnection.lastSyncError + ? "Sync error" + : "Connected"} +

+ {calendarConnection?.lastSyncError && !calendarConnection.revokedAt && ( +

{calendarConnection.lastSyncError}

+ )} +
+ +

+ One-way sync: {branding.name} stays the source of truth, and your confirmed appointments are pushed to + Google Calendar. Nothing is ever imported back from Google. +

+ + + ); +} diff --git a/src/app/(staff)/staff/settings/page.tsx b/src/app/(staff)/staff/settings/page.tsx index 16a33e2..bf9f940 100644 --- a/src/app/(staff)/staff/settings/page.tsx +++ b/src/app/(staff)/staff/settings/page.tsx @@ -1,11 +1,12 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { Bell, CalendarClock, CalendarOff, Clock, ShieldCheck, UserCircle } from "lucide-react"; +import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, ShieldCheck, UserCircle } from "lucide-react"; import { auth } from "@/auth"; import { ProfileCard } from "@/components/ui/profile-card"; import { SignOutButton } from "@/features/auth/components/sign-out-button"; import { hasPermission } from "@/lib/auth/permissions"; import { Permission } from "@/generated/prisma/enums"; +import { getBranding } from "@/lib/business/branding"; import { InstallAppButton } from "@/features/pwa/components/install-app-button"; const SETTINGS_LINKS = [ @@ -14,6 +15,7 @@ const SETTINGS_LINKS = [ { href: "/staff/settings/time-off", label: "Time Off", icon: CalendarOff }, { href: "/staff/settings/profile", label: "Profile", icon: UserCircle }, { href: "/staff/settings/notifications", label: "Notifications", icon: Bell }, + { href: "/staff/settings/calendar", label: "Google Calendar", icon: CalendarSync }, ] as const; export const metadata: Metadata = { title: "Staff Settings" }; @@ -25,7 +27,10 @@ export default async function StaffSettingsPage() { // Real permission check, not the templates' unconditional mock "Switch to // Admin View" button (see docs/implementation-plan.md §3) — only staff // whose Role actually grants MANAGE_STAFF see a way into the owner area. - const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF); + const [canManageStaff, branding] = await Promise.all([ + hasPermission(session.user.id, Permission.MANAGE_STAFF), + getBranding(), + ]); return (
@@ -55,7 +60,7 @@ export default async function StaffSettingsPage() {

Get the app

- +
diff --git a/src/app/api/appointments/[id]/calendar.ics/route.ts b/src/app/api/appointments/[id]/calendar.ics/route.ts index 09f61b2..e55c586 100644 --- a/src/app/api/appointments/[id]/calendar.ics/route.ts +++ b/src/app/api/appointments/[id]/calendar.ics/route.ts @@ -39,7 +39,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const ics = generateAppointmentIcs({ appointmentId: appointment.id, - businessName: business?.name ?? "NextChair", + businessName: business?.name ?? "the business", businessAddress: business?.locations[0] ? `${business.locations[0].addressLine1}, ${business.locations[0].city} ${business.locations[0].postalCode}` : undefined, diff --git a/src/app/api/cron/process-notifications/route.ts b/src/app/api/cron/process-notifications/route.ts index 143c263..651b3fc 100644 --- a/src/app/api/cron/process-notifications/route.ts +++ b/src/app/api/cron/process-notifications/route.ts @@ -1,18 +1,11 @@ -import { timingSafeEqual } from "node:crypto"; import { NextResponse } from "next/server"; import { prisma } from "@/lib/db/prisma"; +import { isInstalledForRequest } from "@/lib/installation/installation-state"; +import { secretsMatch } from "@/lib/security/secrets-match"; import { expireOverdueRewards } from "@/services/loyalty/loyalty.service"; import { getEmailProvider, getPushProvider } from "@/services/notifications/providers"; import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker"; -/** Same constant-time convention as src/lib/booking-draft/sign.ts and guest-access.service.ts — a plain `!==` on a secret leaks its length/prefix via timing. */ -function secretsMatch(provided: string, expected: string): boolean { - const providedBuffer = Buffer.from(provided); - const expectedBuffer = Buffer.from(expected); - if (providedBuffer.length !== expectedBuffer.length) return false; - return timingSafeEqual(providedBuffer, expectedBuffer); -} - /** * The external trigger point for the background notification processor — * see reminder-worker.ts for why this exists instead of an in-process @@ -47,6 +40,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); } + // No business data exists to process before the installer has run — see + // src/lib/installation/installation-state.ts. A 200 with a no-op result + // (not an error) so an external scheduler hitting this on a fixed + // interval before day-one setup doesn't page anyone. + if (!(await isInstalledForRequest())) { + return NextResponse.json({ skipped: "not_installed" }); + } + const result = await processDueNotificationDeliveries(prisma, { emailProvider: getEmailProvider(), pushProvider: getPushProvider(), diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..665f9da --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db/prisma"; +import { getInstallationState } from "@/lib/installation/installation-state"; + +/** + * Docker/orchestrator health check — deliberately never requires + * installation to have completed: a fresh deployment with a reachable, + * migrated database but no business yet is healthy (it should correctly + * show the installer), not unhealthy. Only a database connectivity + * failure returns a non-200 status. Never returns secrets or a stack + * trace — just enough for a health probe to act on. + */ +export async function GET() { + try { + await prisma.$queryRaw`SELECT 1`; + } catch { + return NextResponse.json({ status: "unhealthy", database: "unreachable" }, { status: 503 }); + } + + const { installed } = await getInstallationState().catch(() => ({ installed: false })); + + return NextResponse.json({ status: "healthy", database: "reachable", installed }); +} diff --git a/src/app/api/staff/calendar/google/authorize/route.ts b/src/app/api/staff/calendar/google/authorize/route.ts new file mode 100644 index 0000000..6972a90 --- /dev/null +++ b/src/app/api/staff/calendar/google/authorize/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db/prisma"; +import { createOAuthState } from "@/lib/security/oauth-state"; +import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider"; + +function getRedirectUri(): string { + const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI; + if (explicit) return explicit; + const appUrl = process.env.NEXT_PUBLIC_APP_URL; + return `${appUrl}/api/staff/calendar/google/callback`; +} + +/** + * Starts the Google Calendar connect flow for the signed-in staff + * member's own account — deliberately separate from Google sign-in (see + * docs/phase-8-summary.md): a barber may connect a different Google + * account here than the one they authenticate with. `state` is a signed, + * short-lived, staff-scoped token (see oauth-state.ts) set as an httpOnly + * cookie and re-verified in the callback — CSRF/replay protection without + * server-side session storage. + */ +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL)); + } + const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } }); + if (!staffProfile) { + return NextResponse.json({ error: "Only staff members can connect a calendar." }, { status: 403 }); + } + + // Fails closed with a friendly redirect, not a 500 — see the brief's + // "show disabled states rather than crashing" for every optional + // integration. The Settings page itself also hides the Connect button + // when unconfigured (see calendar-connection-panel.tsx), so reaching + // this route unconfigured means a stale link or a direct hit. + if (!process.env.GOOGLE_CALENDAR_CLIENT_ID || !process.env.GOOGLE_CALENDAR_CLIENT_SECRET) { + const url = new URL("/staff/settings/calendar", process.env.NEXT_PUBLIC_APP_URL); + url.searchParams.set("error", "1"); + url.searchParams.set("detail", "not_configured"); + return NextResponse.redirect(url); + } + + const state = createOAuthState(staffProfile.id); + const provider = new GoogleCalendarProvider(); + const authorizationUrl = provider.buildAuthorizationUrl({ redirectUri: getRedirectUri(), state }); + + const response = NextResponse.redirect(authorizationUrl); + response.cookies.set("gcal_oauth_state", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 600, + }); + return response; +} diff --git a/src/app/api/staff/calendar/google/callback/route.ts b/src/app/api/staff/calendar/google/callback/route.ts new file mode 100644 index 0000000..bc0eba7 --- /dev/null +++ b/src/app/api/staff/calendar/google/callback/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db/prisma"; +import { encryptToken } from "@/lib/security/token-encryption"; +import { verifyOAuthState } from "@/lib/security/oauth-state"; +import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider"; +import { CalendarProviderError } from "@/services/calendar/types"; +import { runInitialSync } from "@/services/calendar/calendar-sync.service"; + +function getRedirectUri(): string { + const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI; + if (explicit) return explicit; + const appUrl = process.env.NEXT_PUBLIC_APP_URL; + return `${appUrl}/api/staff/calendar/google/callback`; +} + +function settingsRedirect(status: "connected" | "error", detail?: string): NextResponse { + const url = new URL("/staff/settings/calendar", process.env.NEXT_PUBLIC_APP_URL); + url.searchParams.set(status, "1"); + if (detail) url.searchParams.set("detail", detail); + const response = NextResponse.redirect(url); + response.cookies.delete("gcal_oauth_state"); + return response; +} + +/** + * Consent denial, an expired/tampered state, or a state issued for a + * different staff member than the one currently signed in are all + * treated the same way: bounce back to Settings with a generic error — + * never revealing which check failed (same principle as the installation + * token check, see installer.service.ts). + */ +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const returnedState = url.searchParams.get("state"); + const consentDenied = url.searchParams.get("error"); + + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL)); + } + const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } }); + if (!staffProfile) { + return settingsRedirect("error", "not_staff"); + } + + if (consentDenied) { + return settingsRedirect("error", "consent_denied"); + } + + const cookieState = request.headers.get("cookie")?.match(/gcal_oauth_state=([^;]+)/)?.[1]; + if (!code || !returnedState || !cookieState || returnedState !== cookieState || !verifyOAuthState(cookieState, staffProfile.id)) { + return settingsRedirect("error", "invalid_state"); + } + + try { + const provider = new GoogleCalendarProvider(); + const tokens = await provider.exchangeCodeForTokens({ code, redirectUri: getRedirectUri() }); + const calendars = await provider.listWritableCalendars(tokens.accessToken); + const primaryCalendar = calendars.find((c) => c.primary) ?? calendars[0]; + if (!primaryCalendar) { + return settingsRedirect("error", "no_writable_calendar"); + } + + const connection = await prisma.staffCalendarConnection.upsert({ + where: { staffId: staffProfile.id }, + create: { + staffId: staffProfile.id, + externalAccountId: tokens.externalAccountId, + accountEmail: tokens.accountEmail, + encryptedAccessToken: encryptToken(tokens.accessToken), + encryptedRefreshToken: encryptToken(tokens.refreshToken), + tokenExpiresAt: tokens.expiresAt, + selectedCalendarId: primaryCalendar.id, + selectedCalendarName: primaryCalendar.name, + }, + update: { + externalAccountId: tokens.externalAccountId, + accountEmail: tokens.accountEmail, + encryptedAccessToken: encryptToken(tokens.accessToken), + encryptedRefreshToken: encryptToken(tokens.refreshToken), + tokenExpiresAt: tokens.expiresAt, + selectedCalendarId: primaryCalendar.id, + selectedCalendarName: primaryCalendar.name, + revokedAt: null, + lastSyncError: null, + }, + }); + + await runInitialSync(prisma, connection.staffId); + + return settingsRedirect("connected"); + } catch (error) { + const detail = error instanceof CalendarProviderError ? error.code.toLowerCase() : "unknown"; + return settingsRedirect("error", detail); + } +} diff --git a/src/app/book/details/page.tsx b/src/app/book/details/page.tsx index 3556fb7..39f9786 100644 --- a/src/app/book/details/page.tsx +++ b/src/app/book/details/page.tsx @@ -1,6 +1,10 @@ import type { Metadata } from "next"; +import { LogIn } from "lucide-react"; +import Link from "next/link"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/feedback/empty-state"; import { TopNav } from "@/components/navigation/top-nav"; import { readDraft } from "@/lib/booking-draft/cookie"; import { getPrimaryBusiness } from "@/lib/business/get-primary-business"; @@ -10,6 +14,8 @@ import { DetailsForm } from "@/features/booking/components/details-form"; export const metadata: Metadata = { title: "Your Details" }; export const dynamic = "force-dynamic"; +const CALLBACK_URL = "/book/details"; + export default async function BookDetailsPage() { const business = await getPrimaryBusiness(); const draft = await readDraft(business.id); @@ -18,6 +24,36 @@ export default async function BookDetailsPage() { if (!draft.startAt) redirect("/book/barber-time"); const session = await auth(); + + // Booking progress up to this point (services, barber, time) is stored + // in the signed draft cookie, which survives navigation to /sign-in or + // /register and back — see src/lib/booking-draft/cookie.ts. Only the + // *details* step itself (and everything after it) requires an account + // when guest booking is disabled; browsing and slot selection never do. + const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false; + if (!session?.user && !guestBookingEnabled) { + return ( +
+ + + + +
+ } + /> +
+ ); + } + let prefill: { firstName: string; lastName: string; email: string; phone: string } | undefined; if (session?.user?.id) { diff --git a/src/app/install/layout.tsx b/src/app/install/layout.tsx new file mode 100644 index 0000000..4cd1944 --- /dev/null +++ b/src/app/install/layout.tsx @@ -0,0 +1,18 @@ +/** + * Deliberately not the (auth)/(customer)/(staff)/(owner) shells — this + * route renders before any business exists, so there is nothing to brand + * with yet (see the brief's "Offline and Error Branding" section: use + * neutral installation wording before install, business branding after). + * A plain neutral mark, not the app's own logo/name, keeps that boundary + * honest rather than showing product branding on the one screen where it + * explicitly shouldn't appear. + */ +export default function InstallLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/app/install/page.tsx b/src/app/install/page.tsx new file mode 100644 index 0000000..f04b478 --- /dev/null +++ b/src/app/install/page.tsx @@ -0,0 +1,41 @@ +import { redirect } from "next/navigation"; +import { getInstallationState } from "@/lib/installation/installation-state"; +import { InstallWizard } from "@/features/installation/components/install-wizard"; + +export const metadata = { + title: "Set Up Your Business", +}; +// Must never be statically prerendered — the installation-state check on +// every request is the entire point of this page (see the brief's "do +// not rely on the form being hidden"). Without this, Next could bake a +// single build-time snapshot of `installed` into static HTML and serve +// that forever, regardless of the database's real state afterward. +export const dynamic = "force-dynamic"; + +export default async function InstallPage() { + const { installed } = await getInstallationState(); + // Direct navigation after a completed install — src/proxy.ts already + // redirects here for most cases, but this page enforces the same rule + // itself rather than trusting the proxy alone (see the brief's "do not + // rely on the form being hidden" requirement). + if (installed) { + redirect("/"); + } + + const tokenConfigured = !!process.env.INSTALLATION_TOKEN; + + if (!tokenConfigured) { + return ( +
+

Installation is not configured

+

+ This deployment has no INSTALLATION_TOKEN{" "} + environment variable set, so setup can't proceed. An administrator needs to set one and restart the + application before installation can begin. +

+
+ ); + } + + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 76d3274..062048c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; import { Providers } from "@/components/providers"; +import { getBranding } from "@/lib/business/branding"; import "./globals.css"; const inter = Inter({ @@ -9,28 +10,37 @@ const inter = Inter({ display: "swap", }); -export const metadata: Metadata = { - title: { - default: "NextChair", - template: "%s — NextChair", - }, - description: - "Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.", - appleWebApp: { - capable: true, - statusBarStyle: "default", - title: "NextChair", - }, -}; +/** + * Dynamic, not a static `export const metadata` — every page's title, + * description, and app-name metadata reflect the installed business, not + * the internal product name (see src/lib/business/branding.ts). Before + * installation completes, `getBranding()` returns neutral wording rather + * than "NextChair" (see that file's own fallback-order comment). + */ +export async function generateMetadata(): Promise { + const branding = await getBranding(); + return { + title: { default: branding.name, template: `%s | ${branding.name}` }, + description: branding.description || "Book your next appointment in seconds.", + appleWebApp: { capable: true, statusBarStyle: "default", title: branding.shortName }, + icons: + branding.faviconUrl || branding.appIconUrl + ? { icon: branding.faviconUrl || undefined, apple: branding.appIconUrl || undefined } + : undefined, + }; +} -export const viewport: Viewport = { - width: "device-width", - initialScale: 1, - maximumScale: 1, - userScalable: false, - viewportFit: "cover", - themeColor: "#111b15", -}; +export async function generateViewport(): Promise { + const branding = await getBranding(); + return { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + viewportFit: "cover", + themeColor: branding.themeColor, + }; +} export default function RootLayout({ children, diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 3bd0807..c1fa7a4 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -1,22 +1,32 @@ import type { MetadataRoute } from "next"; +import { getBranding } from "@/lib/business/branding"; /** * Next.js's manifest file convention — auto-served at /manifest.webmanifest - * with the tag injected automatically, so nothing in - * layout.tsx needs to reference this directly. Single business per - * deployment (see docs/implementation-plan.md), so the name/colors are - * static rather than read from the Business row. + * with the tag injected automatically. Dynamic as of + * Phase 8: name/description/colors reflect the installed business (see + * src/lib/business/branding.ts) rather than the internal product name. + * + * `icons` intentionally stays pointed at the app's own pre-generated, + * correctly-sized/maskable PWA icon set rather than a business's single + * `appIconUrl` — there's no image-processing pipeline in this app to + * derive the required 192/512/maskable variants from one arbitrary + * uploaded URL (see docs/phase-8-summary.md, "Known limitations"), so + * this is fallback rule #2 ("business name + a neutral generated icon"), + * applied deliberately rather than risking a broken/non-square install + * icon. */ -export default function manifest(): MetadataRoute.Manifest { +export default async function manifest(): Promise { + const branding = await getBranding(); return { - name: "NextChair — Barber Booking", - short_name: "NextChair", - description: "Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.", + name: branding.name, + short_name: branding.shortName, + description: branding.description || "Book your next appointment in seconds.", start_url: "/home", scope: "/", display: "standalone", - background_color: "#f4f6f4", - theme_color: "#111b15", + background_color: branding.backgroundColor, + theme_color: branding.themeColor, orientation: "portrait-primary", icons: [ { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, diff --git a/src/app/offline/page.tsx b/src/app/offline/page.tsx index 4d46cae..3c9d8ab 100644 --- a/src/app/offline/page.tsx +++ b/src/app/offline/page.tsx @@ -1,16 +1,29 @@ import type { Metadata } from "next"; import { WifiOff } from "lucide-react"; +import { getBranding } from "@/lib/business/branding"; export const metadata: Metadata = { title: "You're offline" }; +// Forced dynamic so every time the service worker actually (re-)fetches +// this page (at install/activate, while online — see this file's own +// comment below), it gets a fresh render reflecting current branding. +// Without this, a production build could statically bake in whatever +// branding existed at *build* time and serve that forever, even after +// the business's branding changes. +export const dynamic = "force-dynamic"; /** * The service worker's navigation fallback (see public/sw.js) — served - * from cache when a page request fails with no network. Deliberately - * static: no auth, no data fetching, nothing that could itself require a - * network round-trip, since the whole point is that it still renders with - * zero connectivity. Precached at service-worker install time. + * from cache when a page request fails with no network. No auth, no + * per-request data fetching once it's *serving* offline (the whole point + * is that it still renders with zero connectivity) — but it's still a + * normal server-rendered page whenever the service worker actually + * (re-)precaches it, which only ever happens while online (install/ + * activate, see public/sw.js). So it reads business branding like any + * other page; a device that goes offline simply keeps showing whatever + * branding was current the last time it had a connection. */ -export default function OfflinePage() { +export default async function OfflinePage() { + const branding = await getBranding(); return (
@@ -18,7 +31,7 @@ export default function OfflinePage() {

You're offline

- NextChair needs a connection for booking, schedules, and account details. Reconnect and try again. + {branding.name} needs a connection for booking, schedules, and account details. Reconnect and try again.

); diff --git a/src/app/page.tsx b/src/app/page.tsx index 4f2b0a5..3654d04 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,21 +3,49 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { Button } from "@/components/ui/button"; +import { getBranding } from "@/lib/business/branding"; +import { getPrimaryBusiness } from "@/lib/business/get-primary-business"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/db/prisma"; +import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-card"; +/** + * The unauthenticated entry point — always shown at `/` before + * authentication, per the brief's "Welcome routing" requirements. + * Authenticated visitors never see it: they're routed straight to their + * role's home (customer → /home, barber → /staff, owner → /owner) below. + * Unreachable at all before installation completes — src/proxy.ts + * redirects every non-exempt route (including this one) to /install + * until ApplicationInstallation flips to INSTALLED. + */ export default async function WelcomePage() { const session = await auth(); if (session?.user) { + const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); + if (staffProfile) { + const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF); + redirect(canManageStaff ? "/owner" : "/staff"); + } redirect("/home"); } + const [branding, business] = await Promise.all([getBranding(), getPrimaryBusiness()]); + const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false; + return (
-
-

NextChair

+

{branding.name}

- Precision grooming at your fingertips. Book your next visit in seconds. + Book your next visit in seconds — services, staff and loyalty rewards in one place.

@@ -32,15 +60,24 @@ export default async function WelcomePage() { > Sign In + {guestBookingEnabled && ( + + )} +
- {/* - The templates' "Continue as Guest" goes straight to the business - home/booking flow (template 20 → 15). That flow isn't built yet - (Phase 1: Customer Booking), so this button isn't shown rather than - linking somewhere that doesn't work yet — see - docs/implementation-plan.md §16 for the phase it belongs to. - */} +
+ +
); } diff --git a/src/components/navigation/staff-shell.tsx b/src/components/navigation/staff-shell.tsx index 89d0e8b..fb85271 100644 --- a/src/components/navigation/staff-shell.tsx +++ b/src/components/navigation/staff-shell.tsx @@ -1,4 +1,5 @@ import { NotificationBell } from "@/features/notifications/components/notification-bell"; +import { getBranding } from "@/lib/business/branding"; import { BottomNav } from "./bottom-nav"; import { StaffSidebarNav } from "./staff-sidebar-nav"; import type { BottomNavItem } from "./bottom-nav"; @@ -10,8 +11,12 @@ import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items"; * defaults to the staff nav; (owner) passes OWNER_NAV_ITEMS instead (see * staff-nav-items.ts) — same shell, same sidebar/bottom-nav components, * different item set, per the Phase 6 brief's own nav requirement. + * + * Reads business branding itself (async Server Component) rather than + * requiring every (staff)/(owner) layout to fetch and thread it through — + * one call site covers both. */ -export function StaffShell({ +export async function StaffShell({ children, items = STAFF_NAV_ITEMS, footerLink, @@ -24,9 +29,10 @@ export function StaffShell({ notificationsHref?: string; unreadNotificationCount?: number; }) { + const branding = await getBranding(); return (
- +
{children}
diff --git a/src/components/navigation/staff-sidebar-nav.tsx b/src/components/navigation/staff-sidebar-nav.tsx index 00ea6db..d6cac84 100644 --- a/src/components/navigation/staff-sidebar-nav.tsx +++ b/src/components/navigation/staff-sidebar-nav.tsx @@ -18,10 +18,14 @@ import type { BottomNavItem } from "./bottom-nav"; export function StaffSidebarNav({ items, footerLink, + businessName, + logoUrl, }: { items: BottomNavItem[]; /** Owner sidebar's explicit "back to barber tools" — see the brief's "clear access back to barber tools". Omitted on the staff sidebar itself. */ footerLink?: { href: string; label: string }; + businessName: string; + logoUrl: string | null; }) { const pathname = usePathname(); @@ -31,8 +35,13 @@ export function StaffSidebarNav({ className="fixed inset-y-0 left-0 z-50 hidden w-56 flex-col border-r border-white/5 bg-[#0c0c0c] px-4 py-6 lg:flex" >
-
{items.map(({ href, label, icon }) => { diff --git a/src/features/auth/actions/register.ts b/src/features/auth/actions/register.ts index 4a13dfe..057eac6 100644 --- a/src/features/auth/actions/register.ts +++ b/src/features/auth/actions/register.ts @@ -6,6 +6,7 @@ import { prisma } from "@/lib/db/prisma"; import { registerSchema } from "@/features/auth/schemas"; import { checkRateLimit } from "@/lib/security/rate-limit"; import { getRequestIp } from "@/lib/security/request-ip"; +import { safeRedirectPath } from "@/lib/utils/safe-redirect"; export interface RegisterResult { success: boolean; @@ -75,7 +76,8 @@ export async function registerAction(formData: FormData): Promise

- You've been invited to join {businessName} on NextChair. Set - a password to finish setting up your account. + You've been invited to join {businessName}. Set a password + to finish setting up your account.

(null); const { @@ -25,6 +29,7 @@ export function RegisterForm() { setFormError(null); const formData = new FormData(); Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? "")); + formData.set("callbackUrl", callbackUrl); const result = await registerAction(formData); @@ -111,7 +116,7 @@ export function RegisterForm() {

Already have an account?{" "} - + Log in

diff --git a/src/features/auth/components/sign-in-form.tsx b/src/features/auth/components/sign-in-form.tsx index 5388896..7ed9317 100644 --- a/src/features/auth/components/sign-in-form.tsx +++ b/src/features/auth/components/sign-in-form.tsx @@ -10,10 +10,11 @@ import { useForm } from "react-hook-form"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { signInSchema, type SignInInput } from "@/features/auth/schemas"; +import { safeRedirectPath } from "@/lib/utils/safe-redirect"; export function SignInForm() { const searchParams = useSearchParams(); - const callbackUrl = searchParams.get("callbackUrl") ?? "/home"; + const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home"); const [formError, setFormError] = useState(null); const { @@ -91,7 +92,7 @@ export function SignInForm() {

Don't have an account?{" "} - + Create one

diff --git a/src/features/booking/actions/confirm.ts b/src/features/booking/actions/confirm.ts index 051b430..06c1925 100644 --- a/src/features/booking/actions/confirm.ts +++ b/src/features/booking/actions/confirm.ts @@ -8,6 +8,7 @@ import { checkRateLimit } from "@/lib/security/rate-limit"; import { getRequestIp } from "@/lib/security/request-ip"; import { resolveCustomerForBooking } from "@/services/customer/customer.service"; import { createBooking } from "@/services/scheduling/booking.service"; +import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service"; import { getFirstFriendlyMessage } from "../error-messages"; export interface ConfirmBookingResult { @@ -44,6 +45,16 @@ export async function confirmBookingAction(businessId: string): Promise { + const session = await auth(); + if (!session?.user?.id) throw new Error("Not signed in."); + const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } }); + if (!staffProfile) throw new Error("Not a staff member."); + return staffProfile.id; +} + +export interface CalendarActionResult { + success: boolean; + error?: string; +} + +export async function disconnectCalendarAction(): Promise { + const staffId = await requireStaffId(); + const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } }); + if (!connection) return { success: true }; + + // Best-effort revoke, then always clear locally regardless of whether + // Google's revoke call succeeded — see the brief's "Revoke credentials + // where possible" (not "must succeed before disconnecting"). + const provider = new GoogleCalendarProvider(); + await provider.revoke(decryptToken(connection.encryptedRefreshToken)).catch(() => {}); + + // Existing AppointmentCalendarEvent rows and their remote events are + // deliberately left alone — see the brief's "Leave existing events, + // stop managing them." Only the connection itself is removed, so future + // appointment changes have nothing left to sync against. + await prisma.staffCalendarConnection.update({ + where: { staffId }, + data: { revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null }, + }); + return { success: true }; +} + +export async function syncNowAction(): Promise { + const staffId = await requireStaffId(); + const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } }); + if (!connection || connection.revokedAt) { + return { success: false, error: "Connect Google Calendar first." }; + } + await runInitialSync(prisma, staffId); + return { success: true }; +} + +export interface ListCalendarsResult { + success: boolean; + calendars?: RemoteCalendarInfo[]; + error?: string; +} + +export async function listAvailableCalendarsAction(): Promise { + const staffId = await requireStaffId(); + const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } }); + if (!connection || connection.revokedAt) { + return { success: false, error: "Connect Google Calendar first." }; + } + + const provider = new GoogleCalendarProvider(); + try { + let accessToken = connection.encryptedAccessToken ? decryptToken(connection.encryptedAccessToken) : null; + const expired = !connection.tokenExpiresAt || connection.tokenExpiresAt.getTime() - Date.now() < 60_000; + if (!accessToken || expired) { + const refreshed = await provider.refreshAccessToken(decryptToken(connection.encryptedRefreshToken)); + accessToken = refreshed.accessToken; + await prisma.staffCalendarConnection.update({ + where: { staffId }, + data: { encryptedAccessToken: encryptToken(refreshed.accessToken), tokenExpiresAt: refreshed.expiresAt }, + }); + } + const calendars = await provider.listWritableCalendars(accessToken); + return { success: true, calendars }; + } catch (error) { + const message = error instanceof CalendarProviderError ? error.message : "Couldn't load calendars."; + return { success: false, error: message }; + } +} + +export async function selectCalendarAction(calendarId: string, calendarName: string): Promise { + const staffId = await requireStaffId(); + const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } }); + if (!connection || connection.revokedAt) { + return { success: false, error: "Connect Google Calendar first." }; + } + await prisma.staffCalendarConnection.update({ + where: { staffId }, + data: { selectedCalendarId: calendarId, selectedCalendarName: calendarName }, + }); + return { success: true }; +} diff --git a/src/features/calendar/components/calendar-connection-panel.tsx b/src/features/calendar/components/calendar-connection-panel.tsx new file mode 100644 index 0000000..4755f79 --- /dev/null +++ b/src/features/calendar/components/calendar-connection-panel.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { + disconnectCalendarAction, + listAvailableCalendarsAction, + selectCalendarAction, + syncNowAction, +} from "../actions/calendar"; +import type { RemoteCalendarInfo } from "@/services/calendar/types"; + +export interface ConnectionState { + connected: boolean; + accountEmail?: string; + selectedCalendarName?: string; + lastSyncAt?: string; + lastSyncError?: string | null; +} + +export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) { + const [state, setState] = useState(initial); + const [isPending, startTransition] = useTransition(); + const [message, setMessage] = useState(null); + const [calendars, setCalendars] = useState(null); + + const sync = () => { + setMessage(null); + startTransition(async () => { + const result = await syncNowAction(); + setMessage(result.success ? "Sync complete." : (result.error ?? "Sync failed.")); + }); + }; + + const disconnect = () => { + setMessage(null); + startTransition(async () => { + await disconnectCalendarAction(); + setState({ connected: false }); + setMessage("Disconnected. Existing calendar events were left as they are."); + }); + }; + + const loadCalendars = () => { + setMessage(null); + startTransition(async () => { + const result = await listAvailableCalendarsAction(); + if (!result.success) { + setMessage(result.error ?? "Couldn't load calendars."); + return; + } + setCalendars(result.calendars ?? []); + }); + }; + + const chooseCalendar = (calendar: RemoteCalendarInfo) => { + startTransition(async () => { + const result = await selectCalendarAction(calendar.id, calendar.name); + if (result.success) { + setState((prev) => ({ ...prev, selectedCalendarName: calendar.name })); + setCalendars(null); + setMessage(`Now syncing to "${calendar.name}".`); + } + }); + }; + + if (!state.connected) { + return ( +
+

Google Calendar

+

+ Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately + from sign-in — you can use a different one if you like. +

+ {message &&

{message}

} + {configured ? ( + + ) : ( +

Google Calendar sync is not configured on this deployment yet.

+ )} +
+ ); + } + + return ( +
+

Google Calendar

+
+

Connected as {state.accountEmail}

+

Syncing to “{state.selectedCalendarName}”

+ {state.lastSyncAt &&

Last synced {new Date(state.lastSyncAt).toLocaleString()}

} + {state.lastSyncError &&

Last sync error: {state.lastSyncError}

} +
+ + {message &&

{message}

} + + {calendars ? ( +
    + {calendars.map((calendar) => ( +
  • + +
  • + ))} +
+ ) : null} + +
+ + + +
+
+ ); +} diff --git a/src/features/installation/__tests__/installer.service.integration.test.ts b/src/features/installation/__tests__/installer.service.integration.test.ts new file mode 100644 index 0000000..b5a60b9 --- /dev/null +++ b/src/features/installation/__tests__/installer.service.integration.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/services/scheduling/__tests__/fixtures"; +import { InstallationStatus } from "@/generated/prisma/enums"; +import { installApplication } from "../installer.service"; +import type { InstallInput } from "../schema"; + +/** + * Exercises the real transactional installer against a real MySQL + * database — see installer.service.ts's own header for why the + * ApplicationInstallation singleton row is the concurrency lock. These + * tests own that row exclusively: any pre-existing installation state in + * this database is removed in `beforeEach` and never restored, since a + * test database is expected to be disposable (see + * docs/phase-8-summary.md's testing section) — do not point this at a + * real deployment's database. + */ + +const INSTALLATION_TOKEN = "integration-test-install-token"; + +function buildInput(overrides: Partial = {}): InstallInput { + return { + installationToken: INSTALLATION_TOKEN, + administrator: { + firstName: "Test", + lastName: "Owner", + email: `install-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`, + phone: "", + password: "correct-horse-battery", + confirmPassword: "correct-horse-battery", + }, + business: { + name: "Integration Test Barbershop", + shortName: "", + description: "", + email: "", + phone: "", + website: "", + }, + location: { + addressLine1: "1 Test Street", + addressLine2: "", + city: "Testville", + region: "", + postalCode: "T1 1TT", + country: "GB", + timezone: "Europe/London", + currency: "GBP", + locale: "en-GB", + }, + hours: (["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const).map((dayOfWeek) => ({ + dayOfWeek, + isClosed: dayOfWeek === "SUNDAY", + opensAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 540, + closesAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 1080, + })), + bookingRules: { + allowGuestBooking: false, + minNoticeMinutes: 120, + maxAdvanceDays: 60, + cancellationWindowHours: 24, + rescheduleWindowHours: 24, + slotIntervalMinutes: 30, + bufferBeforeMinutes: 0, + bufferAfterMinutes: 5, + maxAppointmentsPerDay: null, + maxAppointmentsPerStaffPerDay: null, + customerCanChooseStaff: true, + requireApproval: false, + }, + branding: { logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" }, + starterServices: [ + { categoryName: "Haircuts", name: "Classic Cut", description: "", price: "20.00", durationMinutes: 30, active: true, allowOnlineBooking: true }, + ], + ...overrides, + }; +} + +async function resetInstallationSingleton(): Promise { + await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } }); +} + +async function cleanupInstalledBusiness(businessId: string | undefined): Promise { + if (!businessId) return; + const staff = await prisma.staffProfile.findMany({ where: { businessId }, select: { id: true, userId: true } }); + const userIds = staff.map((s) => s.userId); + await prisma.staffService.deleteMany({ where: { staffId: { in: staff.map((s) => s.id) } } }); + await prisma.staffProfile.deleteMany({ where: { businessId } }); + await prisma.service.deleteMany({ where: { businessId } }); + await prisma.serviceCategory.deleteMany({ where: { businessId } }); + await prisma.rolePermission.deleteMany({ where: { role: { businessId } } }); + await prisma.role.deleteMany({ where: { businessId } }); + await prisma.auditLog.deleteMany({ where: { entityId: businessId } }); + await prisma.loyaltyProgramme.deleteMany({ where: { businessId } }); + await prisma.bookingRule.deleteMany({ where: { businessId } }); + await prisma.businessSettings.deleteMany({ where: { businessId } }); + await prisma.businessHours.deleteMany({ where: { businessId } }); + await prisma.businessLocation.deleteMany({ where: { businessId } }); + await prisma.business.deleteMany({ where: { id: businessId } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await resetInstallationSingleton(); +} + +describe("installApplication (integration)", () => { + let createdBusinessId: string | undefined; + + beforeEach(async () => { + await resetInstallationSingleton(); + createdBusinessId = undefined; + }); + + afterEach(async () => { + await cleanupInstalledBusiness(createdBusinessId); + }); + + it("rejects an incorrect installation token without creating anything", async () => { + const originalToken = process.env.INSTALLATION_TOKEN; + process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN; + try { + const result = await installApplication(prisma, { + input: buildInput({ installationToken: "wrong-token" }), + installedVersion: "test", + }); + expect(result.success).toBe(false); + if (!result.success) expect(result.code).toBe("INVALID_TOKEN"); + + const business = await prisma.business.findFirst({ where: { name: "Integration Test Barbershop" } }); + expect(business).toBeNull(); + const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } }); + expect(installation).toBeNull(); + } finally { + process.env.INSTALLATION_TOKEN = originalToken; + } + }); + + it("creates the business, administrator, permissions, booking rules, and starter services transactionally", async () => { + const originalToken = process.env.INSTALLATION_TOKEN; + process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN; + try { + const result = await installApplication(prisma, { input: buildInput(), installedVersion: "test" }); + expect(result.success).toBe(true); + if (!result.success) return; + createdBusinessId = result.businessId; + + const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } }); + expect(installation?.status).toBe(InstallationStatus.INSTALLED); + expect(installation?.primaryBusinessId).toBe(result.businessId); + expect(installation?.initialAdminUserId).toBe(result.userId); + + const business = await prisma.business.findUnique({ + where: { id: result.businessId }, + include: { bookingRule: true, locations: true, hours: true, loyaltyProgramme: true }, + }); + expect(business?.name).toBe("Integration Test Barbershop"); + // Guest booking off by default, per the brief — confirmed end to end here. + expect(business?.bookingRule?.allowGuestBooking).toBe(false); + expect(business?.locations).toHaveLength(1); + expect(business?.hours).toHaveLength(7); + expect(business?.loyaltyProgramme?.isEnabled).toBe(false); + + const roles = await prisma.role.findMany({ where: { businessId: result.businessId }, include: { permissions: true } }); + const ownerRole = roles.find((r) => r.name === "Owner"); + const barberRole = roles.find((r) => r.name === "Barber"); + expect(ownerRole).toBeDefined(); + expect(barberRole).toBeDefined(); + expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length); + + const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } }); + expect(staffProfile?.roleId).toBe(ownerRole!.id); + // The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible." + expect(staffProfile?.services).toHaveLength(1); + + const services = await prisma.service.findMany({ where: { businessId: result.businessId } }); + expect(services).toHaveLength(1); + expect(services[0]?.name).toBe("Classic Cut"); + + const auditLog = await prisma.auditLog.findFirst({ where: { entityId: result.businessId, action: "APPLICATION_INSTALLED" } }); + expect(auditLog).toBeTruthy(); + } finally { + process.env.INSTALLATION_TOKEN = originalToken; + } + }); + + it("refuses to install a second time once already installed", async () => { + const originalToken = process.env.INSTALLATION_TOKEN; + process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN; + try { + const first = await installApplication(prisma, { input: buildInput(), installedVersion: "test" }); + expect(first.success).toBe(true); + if (first.success) createdBusinessId = first.businessId; + + const second = await installApplication(prisma, { + input: buildInput({ + administrator: { ...buildInput().administrator, email: `second-${Date.now()}@example.com` }, + }), + installedVersion: "test", + }); + expect(second.success).toBe(false); + if (!second.success) expect(second.code).toBe("ALREADY_INSTALLED"); + } finally { + process.env.INSTALLATION_TOKEN = originalToken; + } + }); + + it("prevents two concurrent installation attempts from both succeeding", async () => { + const originalToken = process.env.INSTALLATION_TOKEN; + process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN; + try { + const inputA = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-a-${Date.now()}@example.com` } }); + const inputB = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-b-${Date.now()}@example.com` } }); + + const [resultA, resultB] = await Promise.all([ + installApplication(prisma, { input: inputA, installedVersion: "test" }), + installApplication(prisma, { input: inputB, installedVersion: "test" }), + ]); + + const successes = [resultA, resultB].filter((r) => r.success); + expect(successes).toHaveLength(1); + const winner = successes[0]; + if (winner?.success) createdBusinessId = winner.businessId; + + const businessCount = await prisma.business.count({ + where: { name: "Integration Test Barbershop" }, + }); + expect(businessCount).toBe(1); + } finally { + process.env.INSTALLATION_TOKEN = originalToken; + } + }); +}); diff --git a/src/features/installation/actions/install.ts b/src/features/installation/actions/install.ts new file mode 100644 index 0000000..05bcf56 --- /dev/null +++ b/src/features/installation/actions/install.ts @@ -0,0 +1,73 @@ +"use server"; + +import { signIn } from "@/auth"; +import { prisma } from "@/lib/db/prisma"; +import { getRequestIp } from "@/lib/security/request-ip"; +import { checkRateLimit } from "@/lib/security/rate-limit"; +import { secretsMatch } from "@/lib/security/secrets-match"; +import { installApplication } from "../installer.service"; +import { installSchema } from "../schema"; +import type { InstallInput } from "../schema"; + +const packageVersion = process.env.npm_package_version ?? "1.0.0"; + +export interface VerifyTokenResult { + valid: boolean; + error?: string; +} + +/** + * A cheap, read-only check the wizard's first step calls for immediate + * feedback — never reveals *why* a token is wrong, and never on its own + * unlocks anything server-side: the real gate is `submitInstallationAction` + * re-checking the same token before it writes anything (see + * installer.service.ts's own header for why it's checked twice). + */ +export async function verifyInstallationTokenAction(token: string): Promise { + const ip = await getRequestIp(); + const { allowed } = checkRateLimit(`install-token:${ip}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 }); + if (!allowed) { + return { valid: false, error: "Too many attempts. Please try again later." }; + } + + const expected = process.env.INSTALLATION_TOKEN; + if (!expected) { + return { valid: false, error: "Installation is not available on this deployment." }; + } + if (!token || !secretsMatch(token, expected)) { + return { valid: false, error: "Incorrect installation token." }; + } + return { valid: true }; +} + +export interface SubmitInstallationResult { + success: boolean; + error?: string; + fieldErrors?: Record; +} + +export async function submitInstallationAction(input: InstallInput): Promise { + const ip = await getRequestIp(); + const { allowed } = checkRateLimit(`install-submit:${ip}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 }); + if (!allowed) { + return { success: false, error: "Too many attempts. Please try again later." }; + } + + const parsed = installSchema.safeParse(input); + if (!parsed.success) { + return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors }; + } + + const result = await installApplication(prisma, { input: parsed.data, installedVersion: packageVersion }); + if (!result.success) { + return { success: false, error: result.message }; + } + + await signIn("credentials", { + email: parsed.data.administrator.email, + password: parsed.data.administrator.password, + redirectTo: "/owner?installed=1", + }); + + return { success: true }; +} diff --git a/src/features/installation/components/install-wizard.tsx b/src/features/installation/components/install-wizard.tsx new file mode 100644 index 0000000..0642cb2 --- /dev/null +++ b/src/features/installation/components/install-wizard.tsx @@ -0,0 +1,598 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { submitInstallationAction, verifyInstallationTokenAction } from "../actions/install"; +import type { InstallInput } from "../schema"; + +/** + * Holds every wizard step's data as plain React state, not a signed + * server-side draft — nothing is validated or written to the database + * until the single final `submitInstallationAction` call on Step 9's + * confirmation (see installer.service.ts's own header). That means a + * page refresh loses progress, a deliberate simplification: this page is + * only ever visited once, by the person physically standing up the + * deployment, so "refresh loses your place" is a fair trade for not + * needing a signed-cookie draft model and its own cleanup story. + */ + +const WEEKDAYS = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const; +type WeekdayCode = (typeof WEEKDAYS)[number]; +const DAY_LABEL: Record = { + MONDAY: "Monday", + TUESDAY: "Tuesday", + WEDNESDAY: "Wednesday", + THURSDAY: "Thursday", + FRIDAY: "Friday", + SATURDAY: "Saturday", + SUNDAY: "Sunday", +}; + +interface HoursDayState { + dayOfWeek: WeekdayCode; + isClosed: boolean; + startTime: string; + endTime: string; +} + +interface StarterServiceState { + key: string; + categoryName: string; + name: string; + description: string; + price: string; + durationMinutes: number; + active: boolean; + allowOnlineBooking: boolean; +} + +function toMinutes(time: string): number { + const [h, m] = time.split(":").map(Number); + return (h ?? 0) * 60 + (m ?? 0); +} + +const STEPS = [ + "Welcome", + "Administrator", + "Business Details", + "Location & Contact", + "Opening Hours", + "Booking Settings", + "Branding", + "Starter Services", + "Review", +] as const; + +let starterServiceKeySeq = 0; +function newStarterServiceKey(): string { + starterServiceKeySeq += 1; + return `starter-${starterServiceKeySeq}`; +} + +const DEFAULT_STARTER_SERVICES: StarterServiceState[] = [ + { + key: newStarterServiceKey(), + categoryName: "Haircuts", + name: "Classic Haircut", + description: "", + price: "25.00", + durationMinutes: 30, + active: true, + allowOnlineBooking: true, + }, + { + key: newStarterServiceKey(), + categoryName: "Beard Services", + name: "Beard Trim", + description: "", + price: "15.00", + durationMinutes: 20, + active: true, + allowOnlineBooking: true, + }, + { + key: newStarterServiceKey(), + categoryName: "Packages", + name: "Haircut & Beard Combo", + description: "", + price: "35.00", + durationMinutes: 45, + active: true, + allowOnlineBooking: true, + }, +]; + +export function InstallWizard() { + const [step, setStep] = useState(1); + const [isPending, startTransition] = useTransition(); + const [submitError, setSubmitError] = useState(null); + + // Step 1 + const [token, setToken] = useState(""); + const [tokenError, setTokenError] = useState(null); + + // Step 2 + const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" }); + + // Step 3 + const [business, setBusiness] = useState({ name: "", shortName: "", description: "", email: "", phone: "", website: "" }); + + // Step 4 + const [location, setLocation] = useState({ + addressLine1: "", + addressLine2: "", + city: "", + region: "", + postalCode: "", + country: "GB", + timezone: "Europe/London", + currency: "GBP", + locale: "en-GB", + }); + + // Step 5 + const [hours, setHours] = useState( + WEEKDAYS.map((dayOfWeek) => ({ + dayOfWeek, + isClosed: dayOfWeek === "SUNDAY", + startTime: "09:00", + endTime: "18:00", + })), + ); + + // Step 6 — guest booking defaults OFF, per the brief. + const [bookingRules, setBookingRules] = useState({ + allowGuestBooking: false, + minNoticeMinutes: 120, + maxAdvanceDays: 60, + cancellationWindowHours: 24, + rescheduleWindowHours: 24, + slotIntervalMinutes: 30, + bufferBeforeMinutes: 0, + bufferAfterMinutes: 5, + maxAppointmentsPerDay: null as number | null, + maxAppointmentsPerStaffPerDay: null as number | null, + customerCanChooseStaff: true, + requireApproval: false, + }); + + // Step 7 + const [branding, setBranding] = useState({ logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" }); + + // Step 8 + const [starterServices, setStarterServices] = useState(DEFAULT_STARTER_SERVICES); + + const verifyToken = () => { + setTokenError(null); + startTransition(async () => { + const result = await verifyInstallationTokenAction(token); + if (!result.valid) { + setTokenError(result.error ?? "Incorrect installation token."); + return; + } + setStep(2); + }); + }; + + const updateDay = (dayOfWeek: WeekdayCode, patch: Partial) => { + setHours((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d))); + }; + const copyMondayToWeekdays = () => { + const monday = hours.find((d) => d.dayOfWeek === "MONDAY"); + if (!monday) return; + setHours((prev) => + prev.map((d) => + d.dayOfWeek === "SATURDAY" || d.dayOfWeek === "SUNDAY" || d.dayOfWeek === "MONDAY" + ? d + : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime }, + ), + ); + }; + const copyMondayToAll = () => { + const monday = hours.find((d) => d.dayOfWeek === "MONDAY"); + if (!monday) return; + setHours((prev) => + prev.map((d) => (d.dayOfWeek === "MONDAY" ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })), + ); + }; + + const updateStarter = (key: string, patch: Partial) => { + setStarterServices((prev) => prev.map((s) => (s.key === key ? { ...s, ...patch } : s))); + }; + const removeStarter = (key: string) => { + setStarterServices((prev) => prev.filter((s) => s.key !== key)); + }; + const addStarter = () => { + setStarterServices((prev) => [ + ...prev, + { + key: newStarterServiceKey(), + categoryName: "Haircuts", + name: "", + description: "", + price: "0.00", + durationMinutes: 30, + active: true, + allowOnlineBooking: true, + }, + ]); + }; + + const submit = () => { + setSubmitError(null); + const input: InstallInput = { + installationToken: token, + administrator: admin, + business, + location, + hours: hours.map((d) => ({ + dayOfWeek: d.dayOfWeek, + isClosed: d.isClosed, + ...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }), + })), + bookingRules, + branding, + starterServices: starterServices + .filter((s) => s.name.trim()) + .map((s) => ({ + categoryName: s.categoryName, + name: s.name, + description: s.description, + price: s.price, + durationMinutes: s.durationMinutes, + active: s.active, + allowOnlineBooking: s.allowOnlineBooking, + })), + }; + + startTransition(async () => { + const result = await submitInstallationAction(input); + if (!result.success) { + setSubmitError(result.error ?? "Installation failed. Please try again."); + } + // On success, submitInstallationAction signs the administrator in + // and redirects server-side — nothing else to do here. + }); + }; + + const canProceedFromStep = (s: number): boolean => { + switch (s) { + case 2: + return ( + !!admin.firstName.trim() && + !!admin.lastName.trim() && + !!admin.email.trim() && + admin.password.length >= 8 && + admin.password === admin.confirmPassword + ); + case 3: + return !!business.name.trim(); + case 4: + return ( + !!location.addressLine1.trim() && + !!location.city.trim() && + !!location.postalCode.trim() && + !!location.timezone.trim() && + location.currency.trim().length === 3 + ); + default: + return true; + } + }; + + return ( +
+
+

+ Step {step} of {STEPS.length} — {STEPS[step - 1]} +

+
+
+
+
+ + {step === 1 && ( +
+

Set up your business

+

+ This wizard creates the first administrator account and configures your business — opening hours, + booking rules, and branding. It only needs to run once. +

+ setToken(e.target.value)} + error={tokenError ?? undefined} + required + autoComplete="off" + /> + +
+ )} + + {step === 2 && ( +
+

Administrator account

+

This becomes the first owner — full permissions, protected from removal.

+
+ setAdmin({ ...admin, firstName: e.target.value })} required /> + setAdmin({ ...admin, lastName: e.target.value })} required /> +
+ setAdmin({ ...admin, email: e.target.value })} required /> + setAdmin({ ...admin, phone: e.target.value })} /> + setAdmin({ ...admin, password: e.target.value })} + hint="At least 8 characters." + required + /> + setAdmin({ ...admin, confirmPassword: e.target.value })} + error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined} + required + /> + setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} /> +
+ )} + + {step === 3 && ( +
+

Business details

+ setBusiness({ ...business, name: e.target.value })} required /> + setBusiness({ ...business, shortName: e.target.value })} + /> +