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 `