Files
NextChair/docs/phase-10-summary.md
T
Stefan Lazic d3bbad09c6 Fixes
Fixed an issue where media manager only shows files uploaded in specific sections.

Fixed an issue with icon picker names.

Removed jumbo rounding and a few other style tweaks.
2026-08-05 09:25:59 +01:00

20 KiB
Raw Blame History

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.