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).
This commit is contained in:
79 files changed
+4121
-450
No files matched your search
+180
-11
@@ -69,6 +69,22 @@ enum Permission {
|
||||
/// MANAGE_STAFF/MANAGE_BUSINESS_PROFILE so "can see aggregate business
|
||||
/// health" is its own grantable capability.
|
||||
VIEW_REPORTING
|
||||
/// Phase 9 — the Media Manager (upload/select/delete library assets),
|
||||
/// used by every image field across the app (branding, loyalty card,
|
||||
/// staff photos, gallery/portfolio). See src/services/media.
|
||||
MANAGE_MEDIA_LIBRARY
|
||||
/// Phase 9 — hide/unhide imported Google reviews and tune the
|
||||
/// visibility filter policy. Separate from MANAGE_BUSINESS_PROFILE since
|
||||
/// it's reviewing third-party content, not editing business data.
|
||||
MANAGE_REVIEWS
|
||||
}
|
||||
|
||||
/// Phase 9 — where a BusinessGalleryImage is displayed: the Home page hero
|
||||
/// gallery, or the Portfolio tab. One model/service backs both rather than
|
||||
/// two near-duplicate "gallery" features — see src/services/gallery.
|
||||
enum GalleryPlacement {
|
||||
HERO
|
||||
PORTFOLIO
|
||||
}
|
||||
|
||||
enum StaffStatus {
|
||||
@@ -224,30 +240,36 @@ enum CalendarSyncStatus {
|
||||
/// every other model hangs off `businessId` rather than assuming a
|
||||
/// singleton so multi-business support later is additive, not a rewrite.
|
||||
model Business {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
/// Short form for tight spaces (PWA home-screen label, browser tab on
|
||||
/// narrow viewports) — falls back to a truncated `name` when unset, see
|
||||
/// src/lib/business/branding.ts.
|
||||
shortName String?
|
||||
description String? @db.Text
|
||||
logoUrl String?
|
||||
shortName String?
|
||||
description String? @db.Text
|
||||
logoUrl String?
|
||||
/// Wide banner image for the Welcome page / booking pages — distinct
|
||||
/// from `logoUrl` (square mark) and optional; falls back to a plain
|
||||
/// color background when unset.
|
||||
coverImageUrl String?
|
||||
coverImageUrl String?
|
||||
/// Falls back to the app's neutral favicon when unset — see
|
||||
/// src/app/icon.png and the branding fallback rules in
|
||||
/// src/lib/business/branding.ts.
|
||||
faviconUrl String?
|
||||
faviconUrl String?
|
||||
/// Used for the PWA manifest icon set — falls back to the same neutral
|
||||
/// app icon as the favicon when unset.
|
||||
appIconUrl String?
|
||||
appIconUrl String?
|
||||
/// Hex color, e.g. "#111b15" — PWA `theme_color` / manifest and
|
||||
/// browser chrome. Falls back to the app's own theme color when unset.
|
||||
themeColor String?
|
||||
backgroundColor String?
|
||||
themeColor String?
|
||||
backgroundColor String?
|
||||
/// Phase 9 — Welcome page background, desktop breakpoint. Falls back to
|
||||
/// the existing plain background when unset. See src/app/page.tsx.
|
||||
welcomeBackgroundDesktopUrl String?
|
||||
/// Phase 9 — Welcome page background, mobile breakpoint. Independent of
|
||||
/// the desktop image (a wide banner rarely crops well to portrait).
|
||||
welcomeBackgroundMobileUrl String?
|
||||
|
||||
phone String?
|
||||
email String?
|
||||
@@ -255,6 +277,20 @@ model Business {
|
||||
instagramUrl String?
|
||||
facebookUrl String?
|
||||
|
||||
/// Phase 9 — Google Places Place ID this business's reviews sync from.
|
||||
/// Null = review sync disabled for this business (see
|
||||
/// src/services/reviews/review-sync.service.ts). Not validated against a
|
||||
/// fixed format — Google Place IDs have no stable public regex.
|
||||
googlePlaceId String?
|
||||
/// Google's own place-level aggregate rating, distinct from the
|
||||
/// individual Review rows below (Google's API only ever returns a
|
||||
/// handful of "most relevant" individual reviews per call, but does
|
||||
/// return the true aggregate rating/count for the whole place) — synced
|
||||
/// by the same job. Used for the customer-facing star/count summary
|
||||
/// instead of averaging just the visible review sample.
|
||||
googleRating Decimal? @db.Decimal(2, 1)
|
||||
googleRatingCount Int?
|
||||
|
||||
/// IANA timezone, e.g. "Europe/London". All wall-clock scheduling math
|
||||
/// (working hours, breaks, booking rules) is interpreted in this zone.
|
||||
timezone String @default("Europe/London")
|
||||
@@ -278,6 +314,9 @@ model Business {
|
||||
staffInvitations StaffInvitation[]
|
||||
notifications Notification[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
mediaAssets MediaAsset[]
|
||||
galleryImages BusinessGalleryImage[]
|
||||
reviews Review[]
|
||||
}
|
||||
|
||||
/// Split from Business (rather than address fields on Business itself) so
|
||||
@@ -334,6 +373,14 @@ model BusinessSettings {
|
||||
bookingTerms String? @db.Text
|
||||
privacyPolicyUrl String?
|
||||
|
||||
/// Phase 9 — Google reviews visibility policy, applied on top of the
|
||||
/// per-review Review.isHidden flag. Null minRating = no floor.
|
||||
reviewsMinRating Int?
|
||||
/// A "rating-only" review is one with no text — excluding those is the
|
||||
/// same underlying check as "does this review have text", so this is
|
||||
/// the single field for it (no separate requireText flag).
|
||||
reviewsExcludeRatingOnly Boolean @default(false)
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -376,6 +423,123 @@ model BookingRule {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Media (Phase 9) — the single Media Manager library backing every image
|
||||
// field app-wide (branding, loyalty card, staff photos, service photos,
|
||||
// gallery/portfolio). Stored on local disk under public/media/ in this
|
||||
// deployment model (see src/services/media) — deliberately not a cloud
|
||||
// object-storage integration in v1, kept behind the same service so that's
|
||||
// swappable later without touching callers.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model MediaAsset {
|
||||
id String @id @default(cuid())
|
||||
|
||||
businessId String
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Public path under /media/... (served directly by Next's static file
|
||||
/// handling for public/ — see docker-compose.local.yaml's media volume).
|
||||
url String @db.VarChar(500)
|
||||
originalFilename String?
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
/// Best-effort, probed at upload time (see src/services/media) — null
|
||||
/// for anything the probe doesn't recognise, never blocks the upload.
|
||||
width Int?
|
||||
height Int?
|
||||
/// Freeform tag (e.g. "branding", "loyalty", "staff", "service",
|
||||
/// "portfolio") set by whichever field uploaded it — used to pre-filter
|
||||
/// the library grid, not an enum, so a new field category never needs a
|
||||
/// migration.
|
||||
category String?
|
||||
|
||||
uploadedByUserId String?
|
||||
uploadedByUser User? @relation("MediaAssetUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
galleryImages BusinessGalleryImage[]
|
||||
|
||||
@@index([businessId, category])
|
||||
}
|
||||
|
||||
/// One row per image in either the Home hero gallery or the Portfolio tab
|
||||
/// — a single model/service for both rather than two near-duplicate
|
||||
/// gallery features (see src/services/gallery). `imageUrl` is denormalized
|
||||
/// (same "just a URL" pattern as every other image field in this schema)
|
||||
/// so deleting the underlying MediaAsset never breaks a live gallery row;
|
||||
/// `mediaAssetId` is only kept for the library UI to know which rows a
|
||||
/// given asset is used in.
|
||||
model BusinessGalleryImage {
|
||||
id String @id @default(cuid())
|
||||
|
||||
businessId String
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
|
||||
placement GalleryPlacement
|
||||
|
||||
mediaAssetId String?
|
||||
mediaAsset MediaAsset? @relation(fields: [mediaAssetId], references: [id], onDelete: SetNull)
|
||||
imageUrl String @db.VarChar(500)
|
||||
|
||||
/// Portfolio-only — powers the category badge filter pills. Ignored for
|
||||
/// HERO placement.
|
||||
category String?
|
||||
caption String?
|
||||
displayOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([businessId, placement, displayOrder])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Reviews (Phase 9) — synced from Google Places, never fetched client-side
|
||||
// (see src/services/reviews/review-sync.service.ts, the 24h cron job at
|
||||
// /api/cron/sync-google-reviews). The pre-existing ReviewsProvider
|
||||
// abstraction (src/services/reviews/reviews.service.ts) reads from this
|
||||
// table instead of demo data; callers (Home) don't change.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Review {
|
||||
id String @id @default(cuid())
|
||||
|
||||
businessId String
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Google's own review id — the idempotency key for upsert-on-sync.
|
||||
externalId String
|
||||
|
||||
authorName String
|
||||
authorPhotoUrl String?
|
||||
rating Int
|
||||
text String? @db.Text
|
||||
/// Google's own pre-formatted "2 weeks ago" string — displayed as-is
|
||||
/// rather than recomputed, since Google doesn't return an exact
|
||||
/// timestamp for every review.
|
||||
relativeTimeDescription String?
|
||||
publishedAt DateTime?
|
||||
|
||||
/// Owner-controlled per-review visibility — see MANAGE_REVIEWS. Applied
|
||||
/// on top of BusinessSettings' reviewsMinRating/reviewsExcludeRatingOnly
|
||||
/// policy, not instead of it.
|
||||
isHidden Boolean @default(false)
|
||||
|
||||
/// When the sync job last saw this review — lets a future "remove
|
||||
/// reviews Google stopped returning" sweep distinguish stale rows
|
||||
/// without deleting anything today.
|
||||
fetchedAt DateTime
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([businessId, externalId])
|
||||
@@index([businessId, isHidden])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Installation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -459,6 +623,9 @@ model User {
|
||||
createdAppointments Appointment[] @relation("AppointmentCreatedBy")
|
||||
/// Owner-side: invitations this user has sent (see StaffInvitation).
|
||||
sentStaffInvitations StaffInvitation[] @relation("StaffInvitationInvitedBy")
|
||||
/// Phase 9 — Media Manager uploads attributed to this user, purely for
|
||||
/// provenance (see MediaAsset). Not used for any permission check.
|
||||
uploadedMediaAssets MediaAsset[] @relation("MediaAssetUploadedBy")
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -742,6 +909,8 @@ model Service {
|
||||
|
||||
name String
|
||||
description String? @db.Text
|
||||
/// Phase 9 — optional service photo, set via the Media Manager.
|
||||
imageUrl String?
|
||||
durationMinutes Int
|
||||
/// Standard SQL DECIMAL — supported natively on both Postgres and MySQL.
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
|
||||
Reference in new issue
Block a user