Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user