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:
Stefan Lazic committed 2026-08-04 17:10:30 +01:00
1 parent 77f25c0489
commit 9e39908ea7
79 files changed
+4121 -450

No files matched your search

@@ -0,0 +1,88 @@
-- AlterTable
ALTER TABLE `Business` ADD COLUMN `googlePlaceId` VARCHAR(191) NULL,
ADD COLUMN `googleRating` DECIMAL(2, 1) NULL,
ADD COLUMN `googleRatingCount` INTEGER NULL,
ADD COLUMN `welcomeBackgroundDesktopUrl` VARCHAR(191) NULL,
ADD COLUMN `welcomeBackgroundMobileUrl` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `BusinessSettings` ADD COLUMN `reviewsExcludeRatingOnly` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `reviewsMinRating` INTEGER NULL;
-- AlterTable
ALTER TABLE `RolePermission` MODIFY `permission` ENUM('MANAGE_BUSINESS_PROFILE', 'MANAGE_BOOKING_RULES', 'MANAGE_SERVICES', 'MANAGE_STAFF', 'MANAGE_LOYALTY_PROGRAMME', 'MANAGE_NOTIFICATION_SETTINGS', 'VIEW_ALL_STAFF_APPOINTMENTS', 'MANAGE_ALL_APPOINTMENTS', 'MANAGE_OWN_APPOINTMENTS', 'CHECK_IN_CUSTOMERS', 'MANAGE_LOYALTY_STAMPS', 'MANAGE_OWN_AVAILABILITY', 'VIEW_CUSTOMER_DIRECTORY', 'BOOK_ON_BEHALF_OF_CUSTOMER', 'VIEW_REPORTING', 'MANAGE_MEDIA_LIBRARY', 'MANAGE_REVIEWS') NOT NULL;
-- AlterTable
ALTER TABLE `Service` ADD COLUMN `imageUrl` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `MediaAsset` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`url` VARCHAR(500) NOT NULL,
`originalFilename` VARCHAR(191) NULL,
`mimeType` VARCHAR(191) NOT NULL,
`sizeBytes` INTEGER NOT NULL,
`width` INTEGER NULL,
`height` INTEGER NULL,
`category` VARCHAR(191) NULL,
`uploadedByUserId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `MediaAsset_businessId_category_idx`(`businessId`, `category`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BusinessGalleryImage` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`placement` ENUM('HERO', 'PORTFOLIO') NOT NULL,
`mediaAssetId` VARCHAR(191) NULL,
`imageUrl` VARCHAR(500) NOT NULL,
`category` VARCHAR(191) NULL,
`caption` VARCHAR(191) NULL,
`displayOrder` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `BusinessGalleryImage_businessId_placement_displayOrder_idx`(`businessId`, `placement`, `displayOrder`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Review` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`externalId` VARCHAR(191) NOT NULL,
`authorName` VARCHAR(191) NOT NULL,
`authorPhotoUrl` VARCHAR(191) NULL,
`rating` INTEGER NOT NULL,
`text` TEXT NULL,
`relativeTimeDescription` VARCHAR(191) NULL,
`publishedAt` DATETIME(3) NULL,
`isHidden` BOOLEAN NOT NULL DEFAULT false,
`fetchedAt` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Review_businessId_isHidden_idx`(`businessId`, `isHidden`),
UNIQUE INDEX `Review_businessId_externalId_key`(`businessId`, `externalId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- AddForeignKey
ALTER TABLE `MediaAsset` ADD CONSTRAINT `MediaAsset_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MediaAsset` ADD CONSTRAINT `MediaAsset_uploadedByUserId_fkey` FOREIGN KEY (`uploadedByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BusinessGalleryImage` ADD CONSTRAINT `BusinessGalleryImage_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BusinessGalleryImage` ADD CONSTRAINT `BusinessGalleryImage_mediaAssetId_fkey` FOREIGN KEY (`mediaAssetId`) REFERENCES `MediaAsset`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Review` ADD CONSTRAINT `Review_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+180 -11
View File
@@ -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)