Files
NextChair/docs/phase-9-summary.md
Stefan Lazic 9e39908ea7 Post Phase 8 - Fixes 1
Delivered:

Super Administrator — env-var provisioned, no StaffProfile at all (structural exclusion from Team/barber-picker/appointments/staff-totals, not a filter). hasPermission short-circuit + new getActingBusinessContext() helper retrofitted across 16 owner pages so it can actually manage the business.
Media Manager — one upload/library/external-URL modal + field picker, backing every image field app-wide. Local-disk storage + Docker volume, SVG rejected, size-capped.
Welcome backgrounds — desktop/mobile images with CSS breakpoint swap.
Business Branding settings — logo/cover/welcome/favicon/icon/colours all editable post-install; cover image finally rendered (was dead config).
Service descriptions — now surfaced on Home and in the booking flow.
Loyalty card — live preview, ColorPicker/MediaPicker/IconPicker, flip removed for a static premium-styled card, Loyalty added to bottom nav. Fixed a real pre-existing bug where the card's configured colour/image/icon were stored but never actually rendered.
Home Screen Redesign — hero gallery + sticky Services/Portfolio/Reviews/Details tabs, backed by a new unified gallery model/service (no hero/portfolio duplication). Details tab surfaces social links and a map that were never shown before.
Google Reviews — real sync pipeline (24h cron, CRON_SECRET-gated, mirrors the existing notification cron exactly), DB-only customer reads, owner hide/unhide + policy management page.
Architecture cleanup — SettingsPageShell extracted from 6 duplicated page wrappers; fixed a second pre-existing bug where branding image fields could never actually be cleared once set.
Verification: 418 tests passing (236 unit + 182 integration, all against a real MySQL database), full typecheck/lint clean across src, and a real Docker build+run cycle — which caught and fixed a genuine bug (scripts/ wasn't copied into the production image, so Super Admin sync would have crashed on every real deployment).
2026-08-04 17:10:30 +01:00

137 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 9 — Super Administrator, Media Manager, Business Branding, Loyalty Redesign, Home Screen Redesign and Google Reviews: Summary
A large post-Phase-8 pass adding a platform-level administration account, a single reusable Media Manager that becomes the standard image picker everywhere, full post-install editability of every branding asset, a restyled and properly-configured loyalty card, a real Google Reviews pipeline, and a sticky-tabbed Home Screen redesign (Services/Portfolio/Reviews/Details) built on top of a new owner-managed gallery. Executed in nine dependency-ordered sub-phases (9.0–9.9, tracked as such in-session), each fully typechecked, linted, and integration-tested against a real MySQL database before moving to the next — no phase was left partially wired.
---
## 1. Super Administrator
A platform-level account with every Owner permission, provisioned from environment variables (`SUPER_ADMIN_EMAIL`/`PASSWORD`/`FIRST_NAME`/`LAST_NAME`) rather than the installation wizard — deliberately **not** a hidden staff member. It has no `StaffProfile` row at all, which is the whole design: every exclusion the brief asked for (not in the Team list, not selectable as a barber, never receives an appointment, not counted in staff totals, not in customer-facing staff lists) falls out **structurally** from that absence — `StaffProfile`-scoped queries simply never see it — rather than needing a filter added at each of the ~6 call sites that would otherwise need one.
`hasPermission()` (`src/lib/auth/permissions.ts`) short-circuits `true` for any permission when `User.isSuperAdmin` is set — safe because `OWNER_PERMISSIONS` already **is** the full `Permission` enum, so "all Owner permissions" and "unconditionally true" are the same thing. `scripts/ensure-super-admin.ts` is idempotent and re-hashes the password every run (wired into `docker/entrypoint.sh`, after migrations), so rotating `SUPER_ADMIN_PASSWORD` and restarting the container is how you change it; it refuses to silently take over an existing non-super-admin account with the same email.
**Real architectural cost, paid honestly rather than papered over**: every owner page that resolved its business via `getActingStaff()` (a `StaffProfile` lookup) would otherwise 403 a Super Administrator with no such profile. Rather than special-case each one, a new `getActingBusinessContext()` helper (`src/lib/business/get-acting-business-context.ts`) resolves `{userId, businessId, businessName, businessTimezone}` for **either** a real staff member **or** a Super Administrator (falling back to the app's one business, since v1 is single-business) — and all 16 owner pages/actions that only ever needed `businessId`/`timezone` from `getActingStaff()`, not real staff identity, were retrofitted onto it (verified per-file that none of them used `staff.id` for anything beyond the redirect gate). Pages doing genuine staff-ledger work (loyalty stamp authorship, appointment reassignment) still require real `StaffProfile` identity and are out of scope for this account by design — "purely for administration," per the brief.
---
## 2. Media Manager
No upload mechanism existed anywhere before this phase — every image field was a plain-text URL `Input`. Now there is exactly one: `MediaManagerModal` (Upload / Library / External URL tabs) plus the field-level `MediaPicker` wrapper (preview + Change + Remove), both in `src/features/media/components/`. It backs every image field migrated this phase: business logo, cover image, welcome backgrounds, favicon, PWA icon, loyalty card background, staff photo, service photo, and the new gallery/portfolio system.
- **Storage**: local disk under `public/media/<category>/...`, served for free by Next's own static file handling — no custom GET route needed, only the upload path (`src/lib/media/media-storage.ts`). A deliberate v1 choice for this app's single-container Docker model, not a cloud integration; `docker-compose.local.yaml` gained a `next-chair-media` named volume so uploads survive a rebuild, and the Dockerfile pre-creates `public/media` owned by the non-root user before that volume first attaches.
- **Schema**: new `MediaAsset` model (url, dimensions, size, mime type, category, uploader).
- **Upload path**: a Server Action (`src/features/media/actions/media.ts`), not a REST route — Next.js Server Actions accept `FormData`/`File` natively, so this stays consistent with every other mutation in the app rather than introducing a one-off API endpoint. SVG is rejected (stored-XSS risk via inline `<script>`); size capped at `MEDIA_MAX_UPLOAD_BYTES` (default 5MB); dimensions probed with the tiny, dependency-free `image-size` package rather than pulling `sharp`/libvips into the Alpine build.
- **Permissions, deliberately split**: uploading/browsing only requires being signed-in staff (or the Super Administrator) — a barber needs to add their own profile photo without an owner-only permission blocking them. **Deleting** an asset from the shared library requires the new `MANAGE_MEDIA_LIBRARY` permission (Owner/Super Admin), since a delete can affect images other fields/other staff reference.
- **Icon and colour pickers**, built alongside since the loyalty card designer needed both: `ColorPicker` (native swatch + hex text + presets) and `IconPicker` (searchable Font Awesome grid — a **curated** ~40-icon set via individual named imports, not the full ~2000-icon `fas` pack, both for a more relevant picker and to keep Font Awesome's path data out of bundles that don't need it).
---
## 3. Welcome Background Images
`Business.welcomeBackgroundDesktopUrl`/`welcomeBackgroundMobileUrl` — independent images (not one image cropped two ways), editable via two `MediaPicker` fields in Business Branding settings. The Welcome page (`src/app/page.tsx`) swaps between them with a plain Tailwind `hidden md:block` / `block md:hidden` pair of absolutely-positioned layers plus a bottom gradient scrim for text legibility, falling back to the existing plain `bg-ink` background when neither is set.
---
## 4. Business Branding Settings
Every asset configured during installation is now editable afterwards from one place (`/owner/settings/business`, "Branding" section): logo, cover image, welcome backgrounds, favicon, PWA icon, theme colour, background colour. `BusinessProfileInput`'s image/colour fields were changed from `string | undefined` to `string | null`, and are now always included on the update payload (rather than the previous `value || undefined` pattern) — this was a real, if minor, pre-existing gap: the old pattern made it structurally impossible to ever clear `logoUrl` back to empty once set, since `undefined` tells Prisma "don't touch this field," not "clear it." Fixed as part of migrating `logoUrl` onto `MediaPicker`, whose Remove button needs to actually clear the field.
Cover image was configured at install but **never rendered anywhere** — confirmed dead config. It now renders on the Home hero (falling back to the logo, then a plain background) and, once the owner adds Hero gallery images (§7), those take priority over it — a genuine fallback chain, not a value silently overridden.
---
## 5. Service Descriptions
`Service.description` already existed in the schema and was already fetched in the owner catalogue — it was simply never selected by the customer-facing `getAvailableServices()`/`ServiceSummary`/`AvailableService` types. Added to both, plus a collapsing (renders nothing when null/empty) paragraph in `ServiceCard` and the booking flow's `ServiceSelector`. The cheapest, safest change in the phase; verified end-to-end against real seeded data before moving on.
---
## 6. Loyalty Card Improvements
**A second real, if smaller, pre-existing gap fixed here**: `LoyaltyProgramme.cardBackgroundColor`/`cardBackgroundImageUrl`/`stampIcon` were stored and editable in the owner designer, but `LoyaltyCard` (the actual rendered component) never accepted or used any of the three — every business's card looked identical regardless of configuration. `getLoyaltySummary()` was also missing `cardBackgroundImageUrl` from its return type. Both fixed; verified end-to-end (owner sets background colour/image/icon → same values reach `getLoyaltySummary` → same values now genuinely change what `LoyaltyCard` renders).
- **Designer**: live preview (an actual `<LoyaltyCard>` rendered inline with the in-progress, unsaved form state), `ColorPicker` for background colour, `MediaPicker` for background image, `IconPicker` for the stamp icon (replacing free-text — `findCatalogIcon()` falls back gracefully for any legacy free-text value that doesn't match the curated set).
- **Customer card**: the 3D flip (front stamps / back a generic branding panel) is gone — `LoyaltyCard` is now a single static face, restyled with a CSS-only diagonal shine gradient and layered shadow for a "premium plastic card" feel, no extra image asset, so it stays cheap to render.
- **Navigation**: `(customer)/layout.tsx`'s bottom nav is now Home / Bookings / **Loyalty** / Profile (the `/settings` tab relabelled "Profile" to match; route unchanged).
---
## 7. Home Screen Redesign
The largest single piece, built last since it consumes almost everything above. Replaces the old flat, single-scroll Home page with: an owner-managed **hero gallery** (falls back gracefully all the way down to cover image → logo → plain background if empty) matching content width rather than bleeding edge-to-edge, followed by sticky **Services / Portfolio / Reviews / Details** tabs (`src/features/home/components/home-tabs.tsx`, built on the existing Radix `Tabs` primitive with `position: sticky`).
- **Services tab**: the full catalogue (not a 4-item preview), grouped by category, with a live client-side search box and category filter pills — all client-side state over data already fetched once server-side, not a network round trip per keystroke.
- **Portfolio tab**: owner-managed images with category badge filter pills.
- **Reviews tab**: the real (or demo-fallback) review data from §8, moved here instead of duplicating it on the flat page.
- **Details tab**: About Us, Staff, Opening Hours, and Social/Contact (website/Instagram/Facebook were fields on `Business` that were **never rendered anywhere customer-facing before** — confirmed and fixed here) — plus a map, via a **keyless** Google Maps embed iframe (`https://www.google.com/maps?q=<lat,lng or address>&output=embed`) using existing `BusinessLocation` coordinates, falling back to the street address when coordinates aren't set. Zero new dependency, zero API key needed for the map specifically (distinct from the Places API key §8 needs for reviews).
**Consolidation, not duplication**: Home's hero gallery and the Portfolio tab are powered by **one** new model (`BusinessGalleryImage`, discriminated by a `placement: HERO | PORTFOLIO` enum) and **one** service (`src/services/gallery/gallery.service.ts`) — not two near-identical "gallery" features. One owner page, `/owner/settings/gallery`, manages both sections (add via the Media Manager, up/down reorder, delete, and — Portfolio only — a category tag per image).
---
## 8. Google Reviews
The pre-existing `ReviewsProvider` abstraction (`src/services/reviews/reviews.service.ts`, from Phase 4) was explicitly built for this moment — "a real Google Places/Business Profile integration can implement this same interface later without any caller changing" — and that's exactly what happened: a new `DbReviewsProvider` reads the `Review` table instead of demo data, and `getBusinessReviews()`'s call sites (Home) didn't change at all.
- **Schema**: `Business.googlePlaceId`/`googleRating`/`googleRatingCount` (the aggregate, tracked separately from individual reviews — see below), a new `Review` model (unique on `businessId`+`externalId` for idempotent upsert), and `BusinessSettings.reviewsMinRating`/`reviewsExcludeRatingOnly` (the owner-facing visibility policy).
- **Provider**: `google-places.provider.ts` mirrors the existing `CalendarProvider` pattern deliberately — plain `fetch` against a documented REST contract (the legacy Place Details endpoint), no SDK, an injectable `MockReviewsProvider` for tests.
- **Sync**: a 24-hour scheduled job (`POST /api/cron/sync-google-reviews`, `CRON_SECRET`-gated exactly like the existing notification cron) is the **only** place that ever calls Google — `DbReviewsProvider` only ever reads what that job already wrote, satisfying "the customer application should always display reviews from the database" and "do not call the Places API on every page load" directly, not just in spirit. `DbReviewsProvider` falls back to demo data (clearly labelled `isDemoData: true`) only when a business has never had any reviews synced at all, so a fresh install's Reviews tab isn't empty.
- **Owner management**: `/owner/settings/reviews` — Google Place ID + visibility policy (minimum rating, exclude rating-only reviews) reusing the existing `updateBusinessProfile`/`updateBusinessSettings` functions rather than a parallel update path, plus a hide/unhide review list with client-side rating filter and text search, and a manual "Sync Now" button for immediate feedback after first configuring a Place ID.
- **Known, Google-side limitation** (not fixable from this codebase): Place Details only ever returns up to 5 "most relevant" individual reviews per call, regardless of how many the place actually has. The place-level aggregate rating/count is accurate and tracked separately; the individual review list is always a small sample. Using this feature for real also requires a billed Google Cloud project with the Places API enabled — real cost exposure worth being aware of before turning it on for a live business.
---
## Schema changes (all in one migration, `20260804134858_phase9_media_gallery_reviews_super_admin`)
- `Business`: `welcomeBackgroundDesktopUrl`, `welcomeBackgroundMobileUrl`, `googlePlaceId`, `googleRating`, `googleRatingCount`.
- `BusinessSettings`: `reviewsMinRating`, `reviewsExcludeRatingOnly`.
- `Service`: `imageUrl`.
- New models: `MediaAsset`, `BusinessGalleryImage` (+ `GalleryPlacement` enum), `Review`.
- New `Permission` values: `MANAGE_MEDIA_LIBRARY`, `MANAGE_REVIEWS` (both granted to Owner by default).
- Same MySQL-collation fix required as Phase 8 (new tables default to `utf8mb4_unicode_ci`; hand-corrected to match the database's actual `utf8mb4_0900_ai_ci` before applying).
## New domain services
- `src/services/media/media.service.ts` + `src/lib/media/media-storage.ts`
- `src/services/gallery/gallery.service.ts`
- `src/services/reviews/google-places.provider.ts`, `review-sync.service.ts`, `review-management.service.ts`, `mock-reviews-provider.ts` (`reviews.service.ts` itself was extended, not replaced)
- `src/lib/business/get-acting-business-context.ts`
- `scripts/ensure-super-admin.ts`
## New reusable components
- `MediaManagerModal` / `MediaPicker` (`src/features/media/components/`)
- `ColorPicker`, `IconPicker`, `ImageGallery` (`src/components/ui/`)
- `SettingsPageShell` (`src/components/settings/`) — extracted once six owner settings pages shared the identical `TopNav` + centered-container wrapper verbatim
- `HomeTabs` and its four tab sub-components (`src/features/home/components/home-tabs.tsx`)
- `GalleryManager`, `ReviewsManager` (`src/features/owner/components/`)
## New scheduled jobs
- `POST /api/cron/sync-google-reviews` — every 24h, `CRON_SECRET`-gated, same shape as the existing notification cron.
## Architectural improvements made
- Structural (not filter-based) exclusion of the Super Administrator from every staff-scoped query, by giving it no `StaffProfile` at all.
- One Media Manager, one Gallery model/service (Hero + Portfolio), one `ColorPicker`, one `IconPicker` — none duplicated per feature.
- Two real, pre-existing gaps fixed as direct consequences of this work, not separately: `logoUrl` (and siblings) could never actually be cleared once set; `LoyaltyCard` silently ignored its own configured colour/image/icon.
- `SettingsPageShell` extracted from six now-identical page wrappers.
- Media upload permission split into "add/browse" (any staff) vs. "delete" (Owner/Super Admin only) rather than one coarse gate.
## Intentionally postponed
- S3-compatible object storage for the Media Manager (local disk + Docker volume for v1; swappable later behind the same storage-helper interface).
- Full PWA manifest icon regeneration from one uploaded source image — no image-resize pipeline exists (deliberately avoided pulling `sharp`/libvips into the Docker build); the favicon/apple-touch-icon `<head>` tags do update from the uploaded image, the manifest's fixed icon set doesn't.
- Drag-and-drop reordering for gallery/portfolio images (up/down buttons instead — a full DnD library was judged not worth it for this).
- Price/duration range filters on the Home Services tab beyond category + text search.
- Super Administrator performing genuine staff-ledger actions (loyalty stamp authorship, appointment reassignment) — it has no `StaffProfile` to attribute those to, by design.
- A true single-service deep link from the Home Services tab's "Book" button into the booking flow (currently opens `/book` fresh, same as before this phase) — not asked for at that granularity.
## Future recommendations
- Swap local-disk media storage for S3-compatible object storage behind `media-storage.ts`'s existing interface once running more than one app container.
- A real image-resize pipeline (`sharp`/Squoosh) for full multi-size PWA icon generation from one uploaded source image.
- If Google's 5-review-per-call cap becomes limiting, investigate the newer Places API (v1) or a paid third-party reviews aggregator as an alternative data source behind the same `ReviewsFetchProvider` interface.
- Drag-and-drop gallery/portfolio reordering.
- Extend the Super Administrator with a dedicated `/admin` platform-level area if multi-business support is ever added — the schema (`businessId` on every model) already anticipates it.