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

18 KiB
Raw Permalink Blame History

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.

  • 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.