From 9e39908ea78dee5c1a7ee8eeb0e4e17d7ef6aade Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Tue, 4 Aug 2026 17:10:30 +0100 Subject: [PATCH] Post Phase 8 - Fixes 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .env.example | 22 + Dockerfile | 7 + docker-compose.local.yaml | 9 + docker/entrypoint.sh | 6 + docs/phase-9-summary.md | 136 +++++++ package-lock.json | 62 +++ package.json | 4 + .../migration.sql | 88 ++++ prisma/schema.prisma | 191 ++++++++- scripts/ensure-super-admin.ts | 83 ++++ src/app/(customer)/home/page.tsx | 209 +++------- src/app/(customer)/layout.tsx | 3 +- src/app/(customer)/loyalty/page.tsx | 3 + .../(owner)/owner/appointments/[id]/page.tsx | 15 +- src/app/(owner)/owner/calendar/page.tsx | 22 +- src/app/(owner)/owner/notifications/page.tsx | 8 +- src/app/(owner)/owner/page.tsx | 14 +- src/app/(owner)/owner/reports/page.tsx | 20 +- src/app/(owner)/owner/services/page.tsx | 9 +- .../owner/settings/booking-rules/page.tsx | 49 ++- .../(owner)/owner/settings/business/page.tsx | 32 +- .../(owner)/owner/settings/gallery/page.tsx | 49 +++ src/app/(owner)/owner/settings/hours/page.tsx | 19 +- .../(owner)/owner/settings/loyalty/page.tsx | 36 +- src/app/(owner)/owner/settings/page.tsx | 4 +- .../(owner)/owner/settings/reviews/page.tsx | 51 +++ .../(owner)/owner/team/[id]/breaks/page.tsx | 8 +- .../(owner)/owner/team/[id]/hours/page.tsx | 10 +- src/app/(owner)/owner/team/[id]/page.tsx | 12 +- .../(owner)/owner/team/[id]/time-off/page.tsx | 8 +- src/app/(owner)/owner/team/add/page.tsx | 10 +- src/app/(owner)/owner/team/page.tsx | 8 +- src/app/api/cron/sync-google-reviews/route.ts | 37 ++ src/app/page.tsx | 97 +++-- src/components/navigation/bottom-nav.tsx | 3 +- .../settings/settings-page-shell.tsx | 26 ++ src/components/ui/color-picker.tsx | 82 ++++ src/components/ui/icon-picker.tsx | 87 ++++ src/components/ui/image-gallery.tsx | 77 ++++ src/components/ui/loyalty-card.tsx | 152 +++---- src/components/ui/service-card.tsx | 7 + .../booking/components/service-selector.tsx | 7 + src/features/gallery/actions/gallery.ts | 79 ++++ src/features/home/components/home-tabs.tsx | 385 ++++++++++++++++++ src/features/media/actions/media.ts | 121 ++++++ .../media/components/media-manager-modal.tsx | 235 +++++++++++ .../media/components/media-picker.tsx | 55 +++ .../authorization.integration.test.ts | 45 +- src/features/owner/actions/business.ts | 12 +- src/features/owner/actions/reviews.ts | 98 +++++ .../components/business-details-form.tsx | 66 ++- .../owner/components/catalogue-manager.tsx | 24 +- .../owner/components/gallery-manager.tsx | 149 +++++++ .../owner/components/loyalty-config-form.tsx | 48 ++- .../owner/components/reviews-manager.tsx | 247 +++++++++++ .../owner/components/service-form-sheet.tsx | 5 + .../staff/components/profile-form.tsx | 8 +- src/lib/auth/permissions.ts | 20 + src/lib/business/branding.ts | 9 + .../business/get-acting-business-context.ts | 51 +++ src/lib/icons/icon-catalog.ts | 104 +++++ src/lib/media/media-storage.ts | 68 ++++ src/lib/roles/default-permissions.ts | 2 + src/services/business/business.service.ts | 20 +- .../catalogue/service-catalogue.service.ts | 4 + .../gallery.service.integration.test.ts | 86 ++++ src/services/gallery/gallery.service.ts | 108 +++++ src/services/loyalty/loyalty.service.ts | 2 + .../media.service.integration.test.ts | 113 +++++ src/services/media/media.service.ts | 95 +++++ .../review-sync.service.integration.test.ts | 125 ++++++ .../reviews/__tests__/reviews.service.test.ts | 10 +- .../reviews/google-places.provider.ts | 116 ++++++ src/services/reviews/mock-reviews-provider.ts | 40 ++ .../reviews/review-management.service.ts | 47 +++ src/services/reviews/review-sync.service.ts | 86 ++++ src/services/reviews/reviews.service.ts | 97 ++++- .../scheduling/availability.service.ts | 6 + src/types/domain.ts | 3 + 79 files changed, 4121 insertions(+), 450 deletions(-) create mode 100644 docs/phase-9-summary.md create mode 100644 prisma/migrations/20260804134858_phase9_media_gallery_reviews_super_admin/migration.sql create mode 100644 scripts/ensure-super-admin.ts create mode 100644 src/app/(owner)/owner/settings/gallery/page.tsx create mode 100644 src/app/(owner)/owner/settings/reviews/page.tsx create mode 100644 src/app/api/cron/sync-google-reviews/route.ts create mode 100644 src/components/settings/settings-page-shell.tsx create mode 100644 src/components/ui/color-picker.tsx create mode 100644 src/components/ui/icon-picker.tsx create mode 100644 src/components/ui/image-gallery.tsx create mode 100644 src/features/gallery/actions/gallery.ts create mode 100644 src/features/home/components/home-tabs.tsx create mode 100644 src/features/media/actions/media.ts create mode 100644 src/features/media/components/media-manager-modal.tsx create mode 100644 src/features/media/components/media-picker.tsx create mode 100644 src/features/owner/actions/reviews.ts create mode 100644 src/features/owner/components/gallery-manager.tsx create mode 100644 src/features/owner/components/reviews-manager.tsx create mode 100644 src/lib/business/get-acting-business-context.ts create mode 100644 src/lib/icons/icon-catalog.ts create mode 100644 src/lib/media/media-storage.ts create mode 100644 src/services/gallery/__tests__/gallery.service.integration.test.ts create mode 100644 src/services/gallery/gallery.service.ts create mode 100644 src/services/media/__tests__/media.service.integration.test.ts create mode 100644 src/services/media/media.service.ts create mode 100644 src/services/reviews/__tests__/review-sync.service.integration.test.ts create mode 100644 src/services/reviews/google-places.provider.ts create mode 100644 src/services/reviews/mock-reviews-provider.ts create mode 100644 src/services/reviews/review-management.service.ts create mode 100644 src/services/reviews/review-sync.service.ts diff --git a/.env.example b/.env.example index f2157a6..4adde89 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,16 @@ AUTH_GOOGLE_SECRET="" # Base URL of the app (used for callback URLs, email links, etc.) NEXT_PUBLIC_APP_URL="http://localhost:3000" +# Media Manager (Phase 9) — max upload size in bytes. Defaults to +# 5,000,000 (5MB) if unset. See src/lib/media/media-storage.ts. +MEDIA_MAX_UPLOAD_BYTES="5000000" + +# Google Reviews (Phase 9) — from https://console.cloud.google.com/apis/credentials, +# needs the Places API enabled. Leave unset to keep review sync disabled +# (Home shows clearly-labelled demo reviews instead). See +# src/services/reviews/review-sync.service.ts and /owner/settings/reviews. +GOOGLE_PLACES_API_KEY="" + # Email (Phase 7) — see src/services/notifications/providers. # "console" logs would-be emails to the server console instead of sending # (safe default — never send real email from an unconfigured environment). @@ -52,6 +62,18 @@ CRON_SECRET="" # openssl rand -hex 32 INSTALLATION_TOKEN="" +# Super Administrator (Phase 9) — platform-level account with every Owner +# permission, provisioned from these env vars rather than the installation +# wizard (see scripts/ensure-super-admin.ts, run on every container start). +# Has no StaffProfile by design: never appears in Team, the customer-facing +# barber picker, or staff totals, and can never receive an appointment. All +# four optional — leave SUPER_ADMIN_EMAIL unset to skip provisioning one +# entirely. Re-run to rotate the password (re-hashed every run). +SUPER_ADMIN_EMAIL="" +SUPER_ADMIN_PASSWORD="" +SUPER_ADMIN_FIRST_NAME="" +SUPER_ADMIN_LAST_NAME="" + # Staff Google Calendar sync (Phase 8) — separate OAuth client from # AUTH_GOOGLE_ID/SECRET above (different scopes; see # src/services/calendar/google-calendar.provider.ts). Leave unset to keep diff --git a/Dockerfile b/Dockerfile index 26e0b99..ebc544a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,12 +53,19 @@ COPY --from=builder --chown=nextchair:nodejs /app/public ./public COPY --from=builder --chown=nextchair:nodejs /app/generated ./generated COPY --from=builder --chown=nextchair:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextchair:nodejs /app/src ./src +COPY --from=builder --chown=nextchair:nodejs /app/scripts ./scripts COPY --from=builder --chown=nextchair:nodejs /app/package.json ./package.json COPY --from=builder --chown=nextchair:nodejs /app/prisma.config.ts ./prisma.config.ts COPY --from=builder --chown=nextchair:nodejs /app/next.config.ts ./next.config.ts 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 USER nextchair EXPOSE 3000 diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml index 8339a0e..eb4712b 100644 --- a/docker-compose.local.yaml +++ b/docker-compose.local.yaml @@ -44,6 +44,13 @@ services: - .env.local 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 healthcheck: # No curl/wget in the node:22-alpine base image — a plain Node # request avoids adding a package just for this. See @@ -85,6 +92,8 @@ services: volumes: next-chair-mysql-data: name: next-chair-mysql-data + next-chair-media: + name: next-chair-media networks: next-chair: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ad99724..abc6fc9 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -13,6 +13,12 @@ set -e echo "[entrypoint] Applying database migrations..." npx prisma migrate deploy +# Optional — only provisions/syncs a Super Administrator when +# SUPER_ADMIN_EMAIL is set (see scripts/ensure-super-admin.ts). Runs after +# migrations so the User.isSuperAdmin column always exists by this point. +echo "[entrypoint] Syncing Super Administrator..." +npx tsx scripts/ensure-super-admin.ts + echo "[entrypoint] Starting application..." # `exec` + calling the `next` binary directly (not `npm run start`) so # the Next.js server process itself receives SIGTERM directly from diff --git a/docs/phase-9-summary.md b/docs/phase-9-summary.md new file mode 100644 index 0000000..3b6e5b2 --- /dev/null +++ b/docs/phase-9-summary.md @@ -0,0 +1,136 @@ +# 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//...`, 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 `