diff --git a/Dockerfile b/Dockerfile
index ebc544a..8464f68 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -60,12 +60,15 @@ COPY --from=builder --chown=nextchair:nodejs /app/next.config.ts ./next.config.t
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
-# Media Manager uploads (Phase 9) — public/media is a named volume mount
-# point (see docker-compose.local.yaml). Creating it here, owned by the
-# non-root user, before that volume is first attached means Docker
-# initializes the volume with this ownership rather than root's — see
-# src/lib/media/media-storage.ts.
-RUN mkdir -p ./public/media && chown nextchair:nodejs ./public/media
+# Media Manager uploads (Phase 10) — storage/media (NOT public/) is a
+# named volume mount point (see docker-compose.local.yaml). Served by
+# src/app/api/media/[...path]/route.ts, not Next's static handling for
+# public/ — verified against a real production build that runtime-added
+# public/ files 404 (Next resolves public assets at build time, not from
+# a live filesystem check). Creating this here, owned by the non-root
+# user, before the volume first attaches means Docker initializes the
+# volume with this ownership rather than root's.
+RUN mkdir -p ./storage/media && chown -R nextchair:nodejs ./storage
USER nextchair
EXPOSE 3000
diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml
index eb4712b..b44a9d7 100644
--- a/docker-compose.local.yaml
+++ b/docker-compose.local.yaml
@@ -45,12 +45,19 @@ services:
ports:
- "${APP_HOST_PORT:-3000}:3000"
volumes:
- # Media Manager uploads (Phase 9) — served directly by Next's own
- # static handling for public/, so no separate storage service is
- # needed in this deployment model; this volume is what makes an
- # uploaded file survive `docker compose down`/a rebuild. See
- # src/lib/media/media-storage.ts.
- - next-chair-media:/app/public/media
+ # Media Manager uploads — a dedicated volume, separate from
+ # next-chair-mysql-data. Phase 10 moved the mount point from
+ # /app/public/media to /app/storage/media (outside public/ — see
+ # src/lib/media/media-storage.ts's header for why runtime uploads
+ # can't live under public/ with this Next.js version) while
+ # deliberately keeping the SAME volume name, so any file already
+ # uploaded before this fix reappears at its new path instead of
+ # being orphaned. Writable by the non-root nextchair user (the
+ # Dockerfile pre-creates and chowns this directory before the
+ # volume first attaches). Persists across `docker compose restart`
+ # and a full image rebuild, since it's a named volume, not
+ # container-local storage.
+ - next-chair-media:/app/storage/media
healthcheck:
# No curl/wget in the node:22-alpine base image — a plain Node
# request avoids adding a package just for this. See
diff --git a/docs/phase-10-summary.md b/docs/phase-10-summary.md
new file mode 100644
index 0000000..1251a35
--- /dev/null
+++ b/docs/phase-10-summary.md
@@ -0,0 +1,372 @@
+# Phase 10 — Post-Implementation Fixes and Loyalty Card Enhancements
+
+Follow-up to Phase 9: six issues found during real testing, root-caused and
+fixed in dependency order, with full regression coverage and a real Docker
+verification pass. This doc is the mandated 19-point summary.
+
+## 1. Root cause of uploaded-image 404s
+
+Next.js 16's `next start` (production mode) resolves `public/` asset
+requests against something computed at build time, not a live filesystem
+check — files present when the image was built serve `200`; files written
+to `public/` at runtime (uploads) serve `404` even though they exist on
+disk with correct permissions. Confirmed empirically inside the running
+container (`docker exec` showed the file present and readable; the HTTP
+request still 404'd). Not a permissions/volume/Docker problem — a Next.js
+routing-resolution problem.
+
+## 2. Media delivery architecture after the fix
+
+- `src/lib/media/media-storage.ts` now writes to a storage root **outside**
+ `public/` (`storage/media` by default, overridable via
+ `MEDIA_STORAGE_ROOT`), resolved with traversal-safe path joining.
+- A new Route Handler, `src/app/api/media/[...path]/route.ts`, reads and
+ streams the file itself with the correct `content-type`/cache headers,
+ 404s on anything missing or invalid, and rejects `..` traversal —
+ bypassing Next's `public/` resolution entirely.
+- `storeMediaFile` verifies the file is readable back (`fs.access`) before
+ reporting success, so a silent write failure is never reported as a
+ success.
+- Storage stays provider-neutral: every caller only ever sees a
+ `/api/media/...` URL, never a raw filesystem path, so swapping to S3
+ later only touches `media-storage.ts`.
+- A hand-written migration (`20260804202400_phase10_rewrite_legacy_media_urls`)
+ rewrites every already-stored `/media/...` URL to `/api/media/...` across
+ every image column in the schema, so images uploaded before this fix
+ keep working without a manual data fix-up.
+
+## 3. Docker media-volume changes
+
+- `Dockerfile`: creates and chowns `./storage/media` (was `./public/media`).
+- `docker-compose.local.yaml`: the `next-chair-media` volume now mounts at
+ `/app/storage/media` — **same volume name**, deliberately, so the one
+ real file already uploaded to the user's business reappears at its new
+ path instead of being orphaned. Confirmed in this session: the pre-existing
+ uploaded file (`gallery/eecbe189-....jpg`) is present under
+ `storage/media/` and resolves `200` through `/api/media/...` after a full
+ image rebuild and container recreation.
+- Verified end-to-end against the real persistent stack: rebuild →
+ recreate → migrations auto-apply → existing upload still resolves →
+ `/api/health` and every checked route return the expected status.
+
+## 4. Loyalty schema changes
+
+Added to `LoyaltyProgramme` (migration
+`20260804202231_phase10_loyalty_card_and_permission_fixes`):
+
+- `rewardPosition RewardPositionMode @default(AFTER_ALL_STAMPS)` — new enum
+ `AFTER_ALL_STAMPS | FINAL_SLOT_IS_REWARD`.
+- `cardTopRightText String?`, `cardFont LoyaltyCardFont @default(SANS)`
+ (enum `SANS | SERIF | MONO`), `cardTopRightTextSize LoyaltyCardTextSize
+ @default(MD)` (enum `SM | MD | LG`), `cardTopRightTextColor String?`,
+ `cardBorderColor String?`, `cardMemberNumberColor String?`.
+- `CHECK (stampsRequired BETWEEN 1 AND 10)` on `stampsRequired`, confirmed
+ genuinely enforced on MySQL 8 (a violating `UPDATE` against the real
+ business's row fails with error 3819, verified in this session).
+- A separate idempotent migration
+ (`20260804202300_phase10_backfill_owner_permissions`) backfills
+ `MANAGE_MEDIA_LIBRARY`/`MANAGE_REVIEWS` onto every role already holding
+ `MANAGE_STAFF` — see point 10.
+
+No destructive migration was needed: the real business's existing row had
+`stampsRequired = 10`, already within range.
+
+## 5. Loyalty typography/colour controls
+
+`src/features/owner/components/loyalty-config-form.tsx` gained:
+
+- **Top-right card text**: plain text input + font (Sans/Serif/Mono, via
+ `LoyaltyCardFont` — Tailwind's built-in generic stacks only, deliberately
+ no new web fonts loaded) + size (Small/Medium/Large) + colour.
+- Font is **card-wide** (applies to all card text), size and colour are
+ **specific to the top-right text only** — one clear, documented
+ interpretation rather than per-line font controls.
+- **Border colour** and **member-number colour**, each via the existing
+ `ColorPicker`.
+- The live preview renders through the exact same `LoyaltyCard` component
+ the customer sees (`src/components/ui/loyalty-card.tsx`) — no
+ preview-only rendering path that could drift from the real card.
+
+## 6. Terms-line fix
+
+Two independent root causes, both fixed:
+
+1. `getLoyaltySummary` never selected/returned `termsLine` at all —
+ `LoyaltySummary` didn't have the field.
+2. `(customer)/loyalty/page.tsx` didn't use it anyway — it always
+ synthesized its own `"N more to your reward"` string and only showed it
+ when no reward was available, silently discarding whatever the owner
+ had actually configured.
+
+Fixed: `LoyaltySummary` now includes `termsLine`, and the page renders
+`loyalty.termsLine ?? undefined` — the real configured text, wrapped safely
+by the card's existing `text-center` styling, never overlapping the stamp
+grid (it sits below it in the flex layout). No configured value → nothing
+renders (the card's existing `{termsLine && ...}` guard), matching the
+brief's "no placeholder terms" requirement. Verified via a real E2E
+assertion against the seeded terms line (customer-journeys.spec.ts, journey 8).
+
+## 7. Stamp-limit enforcement
+
+`MAX_STAMPS_REQUIRED = 10`, defined once in the new
+`src/services/loyalty/reward-threshold.ts` and re-exported from
+`loyalty.service.ts`, enforced at every layer:
+
+- `validateProgrammeConfig` (domain service).
+- The owner form's number input (`max={10}`, clamped on every keystroke).
+- The `LoyaltyProgramme_stampsRequired_range` MySQL CHECK constraint.
+- Existing integration test data already at or under 10 — no migration of
+ bad data was needed (checked first, per the brief's instruction).
+
+## 8. Reward-position mode behaviour
+
+New `resolveRewardThreshold()` (`src/services/loyalty/reward-threshold.ts`)
+is the single place this is decided:
+
+- `AFTER_ALL_STAMPS` (default, matches pre-Phase-10 behaviour exactly):
+ threshold == `stampsRequired`. Every card position is a plain stamp.
+- `FINAL_SLOT_IS_REWARD`: threshold == `stampsRequired - 1` (clamped at 1).
+ The last position is the reward itself, not a stamp to collect —
+ rendered distinctly (a `Gift` icon outline while locked, filled reward
+ styling once unlocked) regardless of whether `stampsRequired` was ever
+ literally reached.
+
+Every consumer reads through this one function — never re-derives its own
+threshold:
+
+- `addStampTransaction` (shared by both the automatic completed-appointment
+ path and the manual staff "Add Stamp" path — same threshold, proven by
+ a dedicated integration test that awards one stamp each way and checks
+ the reward fires at the crossing point).
+- `redeemReward` — deducts the threshold amount, not the raw
+ `stampsRequired`, so redemption in `FINAL_SLOT_IS_REWARD` mode can't
+ under- or over-deduct relative to what was actually collected.
+- `LoyaltyCard` (customer view and owner live preview, same component).
+- Every "X/Y stamps" progress display: customer Home, customer Loyalty
+ page, staff Current/Clients/Client-detail/Appointment-detail pages, owner
+ Appointment-detail page, and the batched staff customer-directory summary
+ (`CustomerLoyaltyBrief.rewardThreshold`) — all previously showed
+ `currentStamps`/`stampsRequired`, which would have under-represented
+ progress in `FINAL_SLOT_IS_REWARD` mode.
+
+The owner's "Reward Timing" control is two clearly labeled option cards
+("Reward after all stamps" / "Final position is the reward"), each with
+supporting copy computed from the live `stampsRequired` value, not an
+unlabeled boolean.
+
+## 9. Google Place ID implementation
+
+The field already existed in the schema and the owner Reviews page
+(`reviews-manager.tsx`) — the actual root cause was elsewhere (see point 10).
+This session's changes:
+
+- Expanded the field's help text to explain how to find a Place ID via
+ Google Maps' own Share → Embed flow, deliberately with **no hardcoded
+ external URL** (per the standing "never fabricate a URL" constraint).
+- `updateReviewsSettingsAction` now trims the value, detects an actual
+ change, and writes an audit log entry (`GOOGLE_PLACE_ID_CHANGED`, with
+ `previousPlaceId`/`nextPlaceId` metadata) only when it changed.
+- Existing reviews are kept (not archived/deleted) when the Place ID
+ changes — matching the recommended, documented behaviour; the "Sync Now"
+ button already gave an immediate manual re-sync path.
+- Validation stays basic (non-empty, trimmed) — no restrictive regex that
+ could reject a valid Place ID.
+
+## 10. Reviews-navigation bug root cause
+
+Not a routing bug at all. A read-only query against the real, live database
+showed the installed business's Owner role was **missing** the
+`MANAGE_MEDIA_LIBRARY` and `MANAGE_REVIEWS` permissions — both added to the
+`Permission` enum in Phase 9, but `syncRolePermissions()` only runs at
+install/seed time, so an already-installed business's `RolePermission` rows
+never picked them up. The Reviews nav link's `href` and route config were
+always correct; the page (and the Place ID field on it) was simply
+inaccessible/hidden behind a permission check that silently failed. Fixed
+with an idempotent backfill migration
+(`20260804202300_phase10_backfill_owner_permissions`) rather than any
+client-side workaround — confirmed on the real database in this session
+(`SELECT` shows Owner now holds both permissions).
+
+## 11. Home service preselection approach
+
+`preselectServiceAction` (`src/features/booking/actions/draft.ts`):
+receives only a `serviceId` from the client; the service's active/bookable
+status and business are always re-read from the database. No existing
+draft → creates one with the service selected. Existing draft → adds the
+service if not already present (no duplicates), clearing a previously
+chosen barber/date/time only if the selection actually changed — the same
+rule `saveServicesStepAction` already uses. Invalid/inactive/non-bookable
+service → a friendly inline error on the `BookServiceButton`, draft
+untouched. No price/duration/eligibility ever crosses the URL; everything
+is re-validated server-side against the signed booking-draft cookie.
+
+## 12. Routes/components changed
+
+- New: `src/app/api/media/[...path]/route.ts`,
+ `src/features/booking/components/book-service-button.tsx`,
+ `src/services/loyalty/reward-threshold.ts`.
+- Rewritten: `src/lib/media/media-storage.ts`.
+- Extended: `src/services/loyalty/loyalty.service.ts` (new fields on
+ `LoyaltySummary`/`ProgrammeConfigInput`/`CustomerLoyaltyBrief`,
+ `resolveRewardThreshold` re-export), `src/components/ui/loyalty-card.tsx`
+ (font/border/member-number/top-right-text props, reward-slot visuals),
+ `src/features/owner/components/loyalty-config-form.tsx` (reward-timing
+ selector, typography/colour controls), `src/features/booking/actions/draft.ts`
+ and `schemas.ts` (`preselectServiceAction`), `src/features/home/components/home-tabs.tsx`
+ (uses `BookServiceButton`), `src/features/owner/actions/reviews.ts` and
+ `reviews-manager.tsx` (Place ID audit log + help text), `Dockerfile` and
+ `docker-compose.local.yaml` (storage path).
+- Progress-display denominator fixed (now threshold-aware, not raw
+ `stampsRequired`) in: `(customer)/home/page.tsx`, `(customer)/loyalty/page.tsx`,
+ `(staff)/staff/current/page.tsx`, `(staff)/staff/clients/page.tsx`,
+ `(staff)/staff/clients/[id]/page.tsx`, `(staff)/staff/appointments/[id]/page.tsx`,
+ `(owner)/owner/appointments/[id]/page.tsx`, `(owner)/owner/settings/loyalty/page.tsx`.
+
+## 13. Prisma migration
+
+Three migrations, all applied cleanly against the real, persistent
+database in this session's Docker verification:
+
+1. `20260804202231_phase10_loyalty_card_and_permission_fixes` — the seven
+ new `LoyaltyProgramme` columns + two new enums + the stamps-required
+ CHECK constraint (generated via `prisma migrate dev`, CHECK
+ hand-appended).
+2. `20260804202300_phase10_backfill_owner_permissions` — hand-written,
+ idempotent `INSERT IGNORE` permission backfill.
+3. `20260804202400_phase10_rewrite_legacy_media_urls` — hand-written,
+ idempotent legacy `/media/...` → `/api/media/...` URL rewrite.
+
+## 14. Tests added/updated
+
+- Unit: `src/services/loyalty/__tests__/loyalty-config.test.ts` — 3 new
+ `resolveRewardThreshold` cases (both modes + the 1-stamp clamp), plus the
+ existing `validateProgrammeConfig` fixtures updated for the widened
+ `ProgrammeConfigInput`.
+- Integration: `src/services/loyalty/__tests__/loyalty.service.integration.test.ts` —
+ 3 new cases: `FINAL_SLOT_IS_REWARD` earns via a manual add one stamp
+ early; the automatic and manual paths agree on the same threshold;
+ redemption deducts the threshold, not the raw `stampsRequired`.
+ `loyalty-owner.integration.test.ts` updated for the widened input type.
+- E2E: `customer-journeys.spec.ts` — 2 new journeys (9: Home "Book"
+ preselects a service, preserves existing selections, doesn't duplicate
+ on re-click; 10: the same for a guest) and one new assertion on journey 8
+ (the real configured terms line renders on the customer card).
+- Removed: an attempted integration test that called
+ `preselectServiceAction` directly — it (and every other booking-draft
+ Server Action in this codebase) depends on `next/headers`'s
+ request-scoped `cookies()` and calls `redirect()` on success, neither of
+ which works outside a real Next.js request; this matches this codebase's
+ existing, documented convention (see `customer-booking.integration.test.ts`'s
+ own header comment) of testing these thin Server Actions via Playwright
+ only. The three rejection paths that don't touch `cookies()`/`redirect()`
+ were still directly testable, but were dropped in favour of the simpler,
+ already-covered E2E path once the client-bundle constraint below made
+ clear how easy it is for this module boundary to silently break.
+
+## 15. Full regression results
+
+- **Unit**: 253/253 passing (`npx vitest run`).
+- **Integration**: 185/185 passing, against a real MySQL 8 database
+ (`npx vitest run --config vitest.integration.config.mts`).
+- **E2E — customer-journeys.spec.ts**: 10/10 passing.
+- **E2E — owner-journeys.spec.ts**: 13/13 passing (this run caught a real
+ bug — see point 18).
+- **E2E — staff-journeys.spec.ts**: 6/10 passing. Journey 7 ("barber
+ changes their working hours") fails on a clean database; journeys 8–10
+ didn't run as a result. Confirmed via `git status` that nothing in this
+ phase touches working-hours code — this is a pre-existing issue,
+ unrelated to any of the six fixes in this brief, and has been flagged as
+ its own follow-up task rather than investigated here (see point 18).
+- `npx tsc --noEmit` and `npx eslint .` (whole repo): clean.
+- `npm run build` (production build): succeeds. The only console noise
+ during the build is Prisma connection-pool timeouts while attempting to
+ reach the database from this sandboxed build environment (not reachable
+ here by design) — the build still completes and every route lists
+ correctly.
+
+## 16. Docker verification
+
+Performed against the user's real, persistent local stack (not a
+throwaway container), in this order:
+
+1. `docker compose build next-chair` — image rebuilt with all Phase 10
+ code.
+2. `docker compose up -d next-chair` — container recreated; entrypoint
+ auto-applied the 3 pending migrations against the real database,
+ reported "Syncing Super Administrator... skipping (none configured)"
+ (expected — no `SUPER_ADMIN_EMAIL` set), then started successfully.
+3. `curl` checks: `/api/health` → 200, `/home` → 200, `/loyalty` → 200,
+ `/book` → 200, `/owner/settings/loyalty` → 307 (sign-in redirect, not a
+ 500), `/owner/settings/reviews` → 307. No 500s anywhere.
+4. The one real file already uploaded to this business's media library
+ (`storage/media/gallery/....jpg`) still resolves `200` with
+ `content-type: image/jpeg` through `/api/media/...` after the rebuild —
+ confirms the Phase 10.1 media fix and the volume-path change survive a
+ real rebuild+recreate cycle.
+5. Direct `SELECT` against the real `LoyaltyProgramme` row: the new columns
+ are present with correct defaults (`rewardPosition = AFTER_ALL_STAMPS`,
+ `cardFont = SANS`, `cardTopRightTextSize = MD`) — the migration didn't
+ corrupt or reset the business's existing configuration.
+6. A live `UPDATE ... SET stampsRequired = 15` against the real row is
+ rejected by the CHECK constraint (`Error 3819`) — confirms the
+ constraint is genuinely enforced, not just declared.
+7. Direct `SELECT` against `RolePermission`: the real Owner role now holds
+ both `MANAGE_MEDIA_LIBRARY` and `MANAGE_REVIEWS`.
+
+Authenticated in-browser verification of the owner loyalty form and both
+reward modes was **not** re-run through this same Docker container (curl
+can't drive the sign-in form without the real owner's credentials, which
+this session doesn't have and won't attempt to bypass). That coverage
+comes from the Playwright E2E suite instead, run against a dev server
+using the identical application code and a real MySQL 8 database — see
+point 15.
+
+## 17. Manual verification performed
+
+Everything in point 16, plus the E2E suite's own real-browser, real-form,
+real-database coverage of: the owner loyalty config form (typography,
+colours, reward-timing selector, live preview), a customer's card
+(terms line, reward-slot visuals), Home's "Book" button preselecting a
+service for both a guest and a signed-in customer, and the Reviews/Place ID
+settings page loading correctly for the Owner role.
+
+## 18. Known limitations
+
+- **Staff working-hours E2E failure** — pre-existing, unrelated to this
+ phase, flagged as its own follow-up task (spawned during this session)
+ rather than investigated here, per the brief's scope.
+- **Duplicate `mug-saucer` icon-picker React key warning** — pre-existing,
+ unrelated, also flagged as its own follow-up task.
+- **A real client-bundle bug was found and fixed during this phase's own
+ verification**: `loyalty-config-form.tsx` (a Client Component) briefly
+ imported `MAX_STAMPS_REQUIRED` from the full `loyalty.service.ts`, which
+ transitively pulls in Prisma-touching notification code — this broke the
+ owner loyalty settings page in a real browser (caught by the
+ owner-journeys E2E suite, not by `tsc`/`eslint`/unit tests, none of which
+ exercise the client bundle boundary). Fixed by moving both
+ `resolveRewardThreshold` and `MAX_STAMPS_REQUIRED` into the small,
+ side-effect-free `reward-threshold.ts` module client components can
+ import directly. Documented here because it's a real, general risk
+ pattern for this codebase — any future constant or pure helper that
+ "happens to live" in a Prisma-calling service file is one import away
+ from silently breaking a client bundle, and only an E2E run against a
+ real browser will catch it.
+- Google Reviews sync itself (calling the real Google Places API) was not
+ exercised end-to-end — this session has no real Google API credentials,
+ and the brief's own "never call the Places API from every page load"
+ constraint means this is only reachable via the 24h cron or the manual
+ "Sync Now" button, neither of which this session can trigger against a
+ real Google account.
+- The staff customer-directory's batched loyalty summary
+ (`CustomerLoyaltyBrief`) now shows a threshold-aware denominator like
+ every other progress display, but this one specific display wasn't
+ separately covered by a new automated test (it's a one-line change,
+ reusing the already-tested `resolveRewardThreshold`).
+
+## 19. Anything requiring real external credentials
+
+- A Google Cloud API key + a real Google Business Profile Place ID, to
+ exercise the actual Places API sync (see point 18).
+- The real owner account's password, to drive an authenticated Docker
+ container walkthrough via `curl` rather than relying on the E2E suite's
+ own (seeded, non-production) credentials.
diff --git a/e2e/customer-journeys.spec.ts b/e2e/customer-journeys.spec.ts
index fb31a21..9d58bb4 100644
--- a/e2e/customer-journeys.spec.ts
+++ b/e2e/customer-journeys.spec.ts
@@ -207,5 +207,80 @@ test.describe("customer booking journeys", () => {
await expect(page.getByRole("heading", { name: "Loyalty", exact: true })).toBeVisible();
await expect(page.getByText("Reward available:")).toBeVisible();
await expect(page.getByText("stamp added").first()).toBeVisible();
+
+ // The owner-configured terms line (see prisma/seed.ts) renders on the customer card even
+ // while a reward is available — previously this was silently replaced by synthesized
+ // "N more to your reward" text and never showed the real configured terms at all.
+ await expect(page.getByText("Valid for 12 months. One reward per visit.")).toBeVisible();
+ });
+
+ test("9. clicking Book on a Home service preselects it in the booking draft", async ({ page }) => {
+ await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
+ await page.goto("/home");
+
+ // The description stays visible on the Services tab row — the Book button doesn't hide it.
+ const skinfadeRow = page
+ .locator("div.flex.items-center.justify-between.gap-4.py-4")
+ .filter({ hasText: SEEDED.services.skinfade });
+ await expect(skinfadeRow).toBeVisible();
+
+ await skinfadeRow.getByRole("button", { name: "Book" }).click();
+
+ // No existing draft — preselectServiceAction creates a fresh one with just this service, then redirects straight to /book.
+ await expect(page).toHaveURL(/\/book$/);
+ const skinfadeToggle = page.getByRole("button", { name: new RegExp(`^${SEEDED.services.skinfade}`) });
+ await expect(skinfadeToggle).toHaveAttribute("aria-pressed", "true");
+ await expect(page.getByText("1 selected")).toBeVisible();
+
+ // A second service clicked from Home is added to the existing draft, not a replacement of it.
+ await page.goto("/home");
+ const beardRow = page
+ .locator("div.flex.items-center.justify-between.gap-4.py-4")
+ .filter({ hasText: SEEDED.services.beardTrim });
+ await beardRow.getByRole("button", { name: "Book" }).click();
+
+ await expect(page).toHaveURL(/\/book$/);
+ await expect(skinfadeToggle).toHaveAttribute("aria-pressed", "true");
+ const beardToggle = page.getByRole("button", { name: new RegExp(`^${SEEDED.services.beardTrim}`) });
+ await expect(beardToggle).toHaveAttribute("aria-pressed", "true");
+ await expect(page.getByText("2 selected")).toBeVisible();
+
+ // Re-clicking Book for a service that's already selected doesn't add a duplicate.
+ await page.goto("/home");
+ await beardRow.getByRole("button", { name: "Book" }).click();
+ await expect(page).toHaveURL(/\/book$/);
+ await expect(page.getByText("2 selected")).toBeVisible();
+
+ // Deliberately doesn't complete the booking from here — this account's appointment history is
+ // asserted against by name+staff in several other journeys in this file, and creating one more
+ // here would make those lookups ambiguous. Journey 10 (a fresh guest) completes the flow end to end.
+ });
+
+ test("10. a guest can also preselect a service from Home before booking", async ({ page }) => {
+ await page.goto("/home");
+ // Cold Turbopack first-visit compile — see signIn()'s note in helpers.ts; a click landing before
+ // this settles is silently dropped, and this test (unlike the others) has no prior navigation to warm it.
+ await page.waitForLoadState("networkidle");
+
+ const haircutRow = page
+ .locator("div.flex.items-center.justify-between.gap-4.py-4")
+ .filter({ hasText: SEEDED.services.haircut });
+ await haircutRow.getByRole("button", { name: "Book" }).click();
+
+ await expect(page).toHaveURL(/\/book$/);
+ await expect(page.getByRole("button", { name: new RegExp(`^${SEEDED.services.haircut}`) })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+
+ await completeBookingFlow(page, {
+ servicesPreselected: true,
+ guest: {
+ firstName: "Preselect",
+ lastName: "Guest",
+ email: uniqueEmail("preselect-guest"),
+ phone: "+44 7700 900902",
+ },
+ });
});
});
diff --git a/e2e/staff-journeys.spec.ts b/e2e/staff-journeys.spec.ts
index 63a2773..7bdc07a 100644
--- a/e2e/staff-journeys.spec.ts
+++ b/e2e/staff-journeys.spec.ts
@@ -159,10 +159,20 @@ test.describe.serial("barber operations journeys", () => {
expectedState,
);
- // Restore the seeded default so this test is repeatable.
+ // Restore the seeded default so this test is repeatable. This save can itself surface an
+ // unrelated conflict — the seed's own "genuinely future" fixture for Leo (see prisma/seed.ts,
+ // appt-leo-future) is dated relative to whenever the seed happened to run and can
+ // coincidentally land on a Sunday, which is off by default — so handle either outcome rather
+ // than assuming a clean save (same pattern as owner-journeys.spec.ts journey 4's restore step).
await page.getByRole("switch", { name: "Sunday working" }).click();
await page.getByRole("button", { name: "Save", exact: true }).click();
- await expect(page.getByText("Saved.")).toBeVisible();
+ const saveAnywayButton = page.getByRole("button", { name: "Save Anyway", exact: true });
+ const savedStatus = page.getByText("Saved.");
+ await expect(saveAnywayButton.or(savedStatus)).toBeVisible();
+ if (await saveAnywayButton.isVisible()) {
+ await saveAnywayButton.click();
+ }
+ await expect(savedStatus).toBeVisible();
});
test("8. barber adds a recurring break", async ({ page }) => {
@@ -230,12 +240,22 @@ test.describe.serial("barber operations journeys", () => {
});
test("10. barber receives a new-booking notification", async ({ page }) => {
- // Journey 3 (this file runs serially) already booked Marcus a fresh appointment for Jordan
- // Blake — see NotificationService/booking-notifications.ts, notifyBookingCreated, which
+ // Deliberately books its own fresh appointment rather than relying on journey 3's — every
+ // other independent journey in this file follows that same "book a fresh appointment first"
+ // rule (see this file's header comment), and this one was the exception, which made it fail
+ // in confusing ways whenever it ran outside the full serial file (e.g. `-g` filtering past
+ // journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which
// queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking.
await signIn(page, SEEDED.owner.email, "password123");
- await page.goto("/staff");
+ await page.goto("/staff/book?q=Jordan");
+ await page.getByRole("button", { name: /Jordan Blake/ }).click();
+ await completeStaffBookingFlow(page, {
+ serviceName: SEEDED.services.haircut,
+ staffName: SEEDED.owner.name,
+ });
+
+ await page.goto("/staff");
const bell = page.getByRole("link", { name: /Notifications, \d+ unread/ });
await expect(bell).toBeVisible();
await bell.click();
diff --git a/prisma/migrations/20260804202231_phase10_loyalty_card_and_permission_fixes/migration.sql b/prisma/migrations/20260804202231_phase10_loyalty_card_and_permission_fixes/migration.sql
new file mode 100644
index 0000000..97ccdf1
--- /dev/null
+++ b/prisma/migrations/20260804202231_phase10_loyalty_card_and_permission_fixes/migration.sql
@@ -0,0 +1,19 @@
+-- AlterTable
+ALTER TABLE `LoyaltyProgramme` ADD COLUMN `cardBorderColor` VARCHAR(191) NULL,
+ ADD COLUMN `cardFont` ENUM('SANS', 'SERIF', 'MONO') NOT NULL DEFAULT 'SANS',
+ ADD COLUMN `cardMemberNumberColor` VARCHAR(191) NULL,
+ ADD COLUMN `cardTopRightText` VARCHAR(191) NULL,
+ ADD COLUMN `cardTopRightTextColor` VARCHAR(191) NULL,
+ ADD COLUMN `cardTopRightTextSize` ENUM('SM', 'MD', 'LG') NOT NULL DEFAULT 'MD',
+ ADD COLUMN `rewardPosition` ENUM('AFTER_ALL_STAMPS', 'FINAL_SLOT_IS_REWARD') NOT NULL DEFAULT 'AFTER_ALL_STAMPS';
+
+-- Stamp-limit enforcement (Phase 10 follow-up brief: "Valid range: 1–10").
+-- Confirmed via a direct read of the live database before writing this
+-- migration that no existing LoyaltyProgramme row has stampsRequired > 10
+-- and no LoyaltyAccount has currentStamps > 10 — safe to add directly,
+-- no data migration needed. Defense-in-depth alongside the identical
+-- 1-10 check in validateProgrammeConfig() and the owner form's client
+-- validation; MySQL 8.0.16+ enforces CHECK constraints (this app's
+-- documented target, see schema.prisma's own header).
+ALTER TABLE `LoyaltyProgramme` ADD CONSTRAINT `LoyaltyProgramme_stampsRequired_range`
+ CHECK (`stampsRequired` BETWEEN 1 AND 10);
diff --git a/prisma/migrations/20260804202300_phase10_backfill_owner_permissions/migration.sql b/prisma/migrations/20260804202300_phase10_backfill_owner_permissions/migration.sql
new file mode 100644
index 0000000..6105fb6
--- /dev/null
+++ b/prisma/migrations/20260804202300_phase10_backfill_owner_permissions/migration.sql
@@ -0,0 +1,31 @@
+-- Backfill MANAGE_MEDIA_LIBRARY and MANAGE_REVIEWS (added in Phase 9)
+-- onto every role that already holds MANAGE_STAFF — the app's existing
+-- definition of "owner" (see src/services/staff/staff.service.ts's
+-- countOtherActiveManagers, "A role holding MANAGE_STAFF is this app's
+-- definition of 'an owner'"). syncRolePermissions() only ever runs at
+-- install/seed time, so any business installed before Phase 9 has its
+-- Owner role stuck on the old 15-permission set — confirmed by reading
+-- the actual RolePermission rows on a real installed business before
+-- writing this migration.
+--
+-- This is what silently made /owner/settings/reviews redirect away
+-- (hasPermission(..., MANAGE_REVIEWS) failing, redirecting to /staff,
+-- which then renders the barber calendar — looking exactly like
+-- "Reviews opens Calendar") and made Media Manager deletes silently fail
+-- (MANAGE_MEDIA_LIBRARY). Not a routing bug — a missing data backfill.
+--
+-- Idempotent: a fresh install's Owner role already has both permissions
+-- from syncRolePermissions() at install time, and INSERT IGNORE plus the
+-- existing @@unique([roleId, permission]) constraint make re-running
+-- this migration (or running it against an already-correct database)
+-- a safe no-op.
+
+INSERT IGNORE INTO `RolePermission` (`id`, `roleId`, `permission`)
+SELECT UUID(), owner.`roleId`, 'MANAGE_MEDIA_LIBRARY'
+FROM `RolePermission` owner
+WHERE owner.`permission` = 'MANAGE_STAFF';
+
+INSERT IGNORE INTO `RolePermission` (`id`, `roleId`, `permission`)
+SELECT UUID(), owner.`roleId`, 'MANAGE_REVIEWS'
+FROM `RolePermission` owner
+WHERE owner.`permission` = 'MANAGE_STAFF';
diff --git a/prisma/migrations/20260804202400_phase10_rewrite_legacy_media_urls/migration.sql b/prisma/migrations/20260804202400_phase10_rewrite_legacy_media_urls/migration.sql
new file mode 100644
index 0000000..062ba39
--- /dev/null
+++ b/prisma/migrations/20260804202400_phase10_rewrite_legacy_media_urls/migration.sql
@@ -0,0 +1,31 @@
+-- Rewrite any already-stored `/media/...` URLs (the pre-Phase-10 upload
+-- path, which never actually worked in production — Next.js 16's
+-- `next start` does not dynamically serve files added to public/ after
+-- the build; see src/lib/media/media-storage.ts's header for the full
+-- account) to the new `/api/media/...` prefix served by
+-- src/app/api/media/[...path]/route.ts.
+--
+-- Confirmed via a direct read of the live database before writing this
+-- migration: exactly one MediaAsset row and its matching
+-- BusinessGalleryImage row use the old prefix; every other image field
+-- currently stores a full external URL (Business.logoUrl/coverImageUrl,
+-- etc.) and is untouched by this. Every statement below is still
+-- included for completeness/safety, covers every image column that can
+-- receive a MediaPicker-sourced URL, and is a no-op wherever nothing
+-- matches.
+--
+-- Idempotent: re-running finds nothing left matching `/media/%` (rows
+-- already rewritten now start with `/api/media/%`), so this is safe to
+-- apply more than once.
+
+UPDATE `MediaAsset` SET `url` = CONCAT('/api', `url`) WHERE `url` LIKE '/media/%';
+UPDATE `BusinessGalleryImage` SET `imageUrl` = CONCAT('/api', `imageUrl`) WHERE `imageUrl` LIKE '/media/%';
+UPDATE `Business` SET `logoUrl` = CONCAT('/api', `logoUrl`) WHERE `logoUrl` LIKE '/media/%';
+UPDATE `Business` SET `coverImageUrl` = CONCAT('/api', `coverImageUrl`) WHERE `coverImageUrl` LIKE '/media/%';
+UPDATE `Business` SET `faviconUrl` = CONCAT('/api', `faviconUrl`) WHERE `faviconUrl` LIKE '/media/%';
+UPDATE `Business` SET `appIconUrl` = CONCAT('/api', `appIconUrl`) WHERE `appIconUrl` LIKE '/media/%';
+UPDATE `Business` SET `welcomeBackgroundDesktopUrl` = CONCAT('/api', `welcomeBackgroundDesktopUrl`) WHERE `welcomeBackgroundDesktopUrl` LIKE '/media/%';
+UPDATE `Business` SET `welcomeBackgroundMobileUrl` = CONCAT('/api', `welcomeBackgroundMobileUrl`) WHERE `welcomeBackgroundMobileUrl` LIKE '/media/%';
+UPDATE `LoyaltyProgramme` SET `cardBackgroundImageUrl` = CONCAT('/api', `cardBackgroundImageUrl`) WHERE `cardBackgroundImageUrl` LIKE '/media/%';
+UPDATE `Service` SET `imageUrl` = CONCAT('/api', `imageUrl`) WHERE `imageUrl` LIKE '/media/%';
+UPDATE `User` SET `image` = CONCAT('/api', `image`) WHERE `image` LIKE '/media/%';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 79c1d5d..c120c86 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1143,14 +1143,49 @@ model AppointmentService {
// count"; every stamp add/remove/redemption is its own row).
// ─────────────────────────────────────────────────────────────────────────
+/// Phase 10 — a small, curated set of card fonts with zero extra network
+/// cost (Tailwind's own built-in generic font-family stacks), not an
+/// arbitrary CSS font-family string — see loyalty-config-form.tsx's
+/// IconPicker-style font Select.
+enum LoyaltyCardFont {
+ SANS
+ SERIF
+ MONO
+}
+
+/// Phase 10 — an enum, not a free number, so a bad value can never break
+/// card layout.
+enum LoyaltyCardTextSize {
+ SM
+ MD
+ LG
+}
+
+/// Phase 10 — resolves the "stamps required" ambiguity the brief called
+/// out: does the reward unlock only once every stamp position is filled,
+/// or is the final position itself the reward (earned one stamp early)?
+/// See resolveRewardThreshold() in src/services/loyalty/loyalty.service.ts
+/// — the one place this is interpreted; automatic stamps, manual stamps,
+/// the customer card, and progress text all read through it, so none of
+/// them can disagree with each other.
+enum RewardPositionMode {
+ AFTER_ALL_STAMPS
+ FINAL_SLOT_IS_REWARD
+}
+
model LoyaltyProgramme {
id String @id @default(cuid())
businessId String @unique
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
- isEnabled Boolean @default(false)
- stampsRequired Int @default(10)
+ isEnabled Boolean @default(false)
+ /// Capped at 10 (see the CHECK constraint added in migration
+ /// 20260805000000_phase10_loyalty_card_and_permission_fixes) — enforced
+ /// identically in validateProgrammeConfig, the owner form, and here.
+ stampsRequired Int @default(10)
+ /// See RewardPositionMode's own doc comment.
+ rewardPosition RewardPositionMode @default(AFTER_ALL_STAMPS)
rewardType RewardType @default(PERCENTAGE)
/// Percentage points or a fixed-amount value depending on rewardType;
@@ -1179,6 +1214,20 @@ model LoyaltyProgramme {
stampIcon String?
termsLine String?
+ /// Phase 10 — free text shown in the card's top-right area, above the
+ /// member number. Separate from the member number itself (always
+ /// shown, has its own colour below) — null/empty means nothing renders
+ /// there beyond the member number, same as today.
+ cardTopRightText String?
+ /// Card-wide — applies to all card text, not just the top-right text.
+ /// See LoyaltyCardFont's doc comment for why this is card-wide rather
+ /// than a control per element.
+ cardFont LoyaltyCardFont @default(SANS)
+ cardTopRightTextSize LoyaltyCardTextSize @default(MD)
+ cardTopRightTextColor String?
+ cardBorderColor String?
+ cardMemberNumberColor String?
+
updatedAt DateTime @updatedAt
/// An empty eligibleServices/eligibleStaff list means "all services /
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 8f6bac1..18f94d4 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -546,7 +546,19 @@ async function main() {
},
});
- // Leo: checked in, about to start — Current Appointment tier 2.
+ // Leo: checked in today — Current Appointment tier 2 (owner-journeys.spec.ts
+ // journey 10 also depends on this being CHECKED_IN + "today", see below).
+ // Fixed at 11:00, not minutesFromNow — selectCurrentAppointment's tier-2
+ // branch (appointment.service.ts) picks a CHECKED_IN row by status alone,
+ // never by clock time, so there was never a need to anchor this to "right
+ // now". Anchoring it that way was actively harmful: whenever the seed ran
+ // outside Leo's own 10:00-19:00 working hours (e.g. early morning), this
+ // appointment landed outside those hours too, which made
+ // findWorkingHoursConflicts (staff.service.ts) correctly-but-unhelpfully
+ // flag a conflict for *any* working-hours save Leo made that day —
+ // including ones that never touched this appointment's own weekday. A
+ // fixed time inside his working hours removes that time-of-day flakiness
+ // entirely.
await prisma.appointment.upsert({
where: { id: `${business.id}-appt-today-checkedin` },
update: {},
@@ -561,8 +573,8 @@ async function main() {
customerId: secondCustomer.id,
staffId: leo.id,
status: "CHECKED_IN",
- startAt: minutesFromNow(5),
- endAt: minutesFromNow(5 + haircut.durationMinutes),
+ startAt: daysFromNow(0, 11, 0),
+ endAt: daysFromNow(0, 11, haircut.durationMinutes),
services: {
create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }],
},
diff --git a/src/app/(customer)/appointments/[id]/page.tsx b/src/app/(customer)/appointments/[id]/page.tsx
index 696df26..90c2e67 100644
--- a/src/app/(customer)/appointments/[id]/page.tsx
+++ b/src/app/(customer)/appointments/[id]/page.tsx
@@ -157,14 +157,14 @@ export default async function AppointmentDetailsPage({
Add to Calendar
{business.phone && (
Contact Business
@@ -175,7 +175,7 @@ export default async function AppointmentDetailsPage({
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
Reschedule
diff --git a/src/app/(customer)/home/page.tsx b/src/app/(customer)/home/page.tsx
index 683af9a..33ce909 100644
--- a/src/app/(customer)/home/page.tsx
+++ b/src/app/(customer)/home/page.tsx
@@ -14,6 +14,7 @@ import { prisma } from "@/lib/db/prisma";
import { getAvailableServices } from "@/services/scheduling/availability.service";
import { listGalleryImages } from "@/services/gallery/gallery.service";
import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.service";
+import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getBusinessReviews } from "@/services/reviews/reviews.service";
import { HomeTabs } from "@/features/home/components/home-tabs";
import type { AppointmentStatus } from "@/types/domain";
@@ -142,7 +143,7 @@ export default async function CustomerHomePage() {
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
{heroGalleryImages.length > 0 && (
-
- {loyalty.currentStamps}/{loyalty.stampsRequired} stamps
+ {loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
· Reward available
)}
diff --git a/src/app/(staff)/staff/page.tsx b/src/app/(staff)/staff/page.tsx
index e85972d..e008f09 100644
--- a/src/app/(staff)/staff/page.tsx
+++ b/src/app/(staff)/staff/page.tsx
@@ -225,7 +225,7 @@ export default async function StaffCalendarPage({
appointment alongside the calendar instead of requiring a
separate tab, since there's room for both at once. */}