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

+19 -1
View File
@@ -21,7 +21,22 @@ export type BusinessServiceErrorCode =
export interface BusinessProfileInput {
name: string;
description?: string;
logoUrl?: string;
/**
* Image/colour branding fields are `string | null` (not `| undefined`)
* and always included on the update payload — see updateBusinessProfile
* below — so MediaPicker/ColorPicker's "Remove" actually clears the
* column to NULL instead of silently being skipped.
*/
logoUrl?: string | null;
coverImageUrl?: string | null;
faviconUrl?: string | null;
appIconUrl?: string | null;
themeColor?: string | null;
backgroundColor?: string | null;
welcomeBackgroundDesktopUrl?: string | null;
welcomeBackgroundMobileUrl?: string | null;
/** Google Places Place ID reviews sync from — see src/services/reviews/review-sync.service.ts. Null/empty disables sync for this business. */
googlePlaceId?: string | null;
phone?: string;
email?: string;
website?: string;
@@ -48,6 +63,9 @@ export interface BusinessSettingsInput {
lateArrivalPolicy?: string;
bookingTerms?: string;
privacyPolicyUrl?: string;
/** Reviews visibility policy — see src/services/reviews. Applied on top of each Review's own isHidden flag. */
reviewsMinRating?: number | null;
reviewsExcludeRatingOnly?: boolean;
}
export function validateBusinessProfileInput(
@@ -153,6 +153,8 @@ export interface ServiceInput {
categoryId: string;
name: string;
description?: string;
/** Set via the Media Manager — see src/features/media/components/media-picker.tsx. */
imageUrl?: string;
durationMinutes: number;
/** Decimal string, e.g. "22.50" — never a JS number, see file header. */
price: string;
@@ -226,6 +228,7 @@ export async function createService(
categoryId: params.input.categoryId,
name: params.input.name.trim(),
description: params.input.description?.trim() || null,
imageUrl: params.input.imageUrl?.trim() || null,
durationMinutes: params.input.durationMinutes,
price: params.input.price,
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
@@ -286,6 +289,7 @@ export async function updateService(
categoryId: params.input.categoryId,
name: params.input.name.trim(),
description: params.input.description?.trim() || null,
imageUrl: params.input.imageUrl?.trim() || null,
durationMinutes: params.input.durationMinutes,
price: params.input.price,
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { GalleryPlacement } from "@/generated/prisma/enums";
import {
addGalleryImage,
deleteGalleryImage,
listGalleryImages,
reorderGalleryImages,
updateGalleryImage,
} from "@/services/gallery/gallery.service";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("Gallery service (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("keeps HERO and PORTFOLIO images in separate lists for the same business", async () => {
await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.HERO, imageUrl: "/media/gallery/hero-1.png" });
await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/gallery/portfolio-1.png", category: "Fades" });
const hero = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.HERO });
const portfolio = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
expect(hero).toHaveLength(1);
expect(portfolio).toHaveLength(1);
expect(portfolio[0]?.category).toBe("Fades");
});
it("assigns increasing displayOrder on add, and reorder fully replaces it", async () => {
const a = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
const b = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/b.png" });
expect(a.displayOrder).toBe(0);
expect(b.displayOrder).toBe(1);
await reorderGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, orderedIds: [b.id, a.id] });
const reordered = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
expect(reordered.map((i) => i.id)).toEqual([b.id, a.id]);
});
it("updates category/caption/isActive, and activeOnly filtering respects isActive", async () => {
const image = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
await updateGalleryImage(prisma, { id: image.id, businessId: business.businessId, category: "Beards", caption: "Client X", isActive: false });
const all = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
expect(all[0]?.category).toBe("Beards");
expect(all[0]?.caption).toBe("Client X");
expect(all[0]?.isActive).toBe(false);
const activeOnly = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, activeOnly: true });
expect(activeOnly).toHaveLength(0);
});
it("never lets one business update or delete another business's gallery image", async () => {
const otherBusiness = await createTestBusiness();
try {
const image = await addGalleryImage(prisma, { businessId: otherBusiness.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/other.png" });
const updateResult = await updateGalleryImage(prisma, { id: image.id, businessId: business.businessId, caption: "hijacked" });
expect(updateResult.success).toBe(false);
const deleteResult = await deleteGalleryImage(prisma, { id: image.id, businessId: business.businessId });
expect(deleteResult.success).toBe(false);
const stillThere = await prisma.businessGalleryImage.findUnique({ where: { id: image.id } });
expect(stillThere).not.toBeNull();
expect(stillThere?.caption).not.toBe("hijacked");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("deletes an image it owns", async () => {
const image = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
const result = await deleteGalleryImage(prisma, { id: image.id, businessId: business.businessId });
expect(result.success).toBe(true);
expect(await prisma.businessGalleryImage.findUnique({ where: { id: image.id } })).toBeNull();
});
});
+108
View File
@@ -0,0 +1,108 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { GalleryPlacement } from "@/generated/prisma/enums";
/**
* Owner-managed image galleries — one model/service backs both the Home
* page hero carousel (placement: HERO) and the Portfolio tab (placement:
* PORTFOLIO) rather than two near-duplicate "gallery" features. See
* docs/phase-9-summary.md, "Home Screen Redesign".
*/
export interface GalleryImageSummary {
id: string;
imageUrl: string;
category: string | null;
caption: string | null;
displayOrder: number;
isActive: boolean;
}
export async function listGalleryImages(
prisma: PrismaClient,
params: { businessId: string; placement: GalleryPlacement; activeOnly?: boolean },
): Promise<GalleryImageSummary[]> {
const images = await prisma.businessGalleryImage.findMany({
where: {
businessId: params.businessId,
placement: params.placement,
...(params.activeOnly ? { isActive: true } : {}),
},
orderBy: { displayOrder: "asc" },
});
return images.map((i) => ({
id: i.id,
imageUrl: i.imageUrl,
category: i.category,
caption: i.caption,
displayOrder: i.displayOrder,
isActive: i.isActive,
}));
}
export async function addGalleryImage(
prisma: PrismaClient,
params: { businessId: string; placement: GalleryPlacement; imageUrl: string; mediaAssetId?: string; category?: string },
): Promise<GalleryImageSummary> {
const maxOrder = await prisma.businessGalleryImage.aggregate({
where: { businessId: params.businessId, placement: params.placement },
_max: { displayOrder: true },
});
const image = await prisma.businessGalleryImage.create({
data: {
businessId: params.businessId,
placement: params.placement,
imageUrl: params.imageUrl,
mediaAssetId: params.mediaAssetId,
category: params.category?.trim() || null,
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
},
});
return {
id: image.id,
imageUrl: image.imageUrl,
category: image.category,
caption: image.caption,
displayOrder: image.displayOrder,
isActive: image.isActive,
};
}
export async function updateGalleryImage(
prisma: PrismaClient,
params: { id: string; businessId: string; category?: string | null; caption?: string | null; isActive?: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.businessGalleryImage.updateMany({
where: { id: params.id, businessId: params.businessId },
data: {
...(params.category !== undefined ? { category: params.category?.trim() || null } : {}),
...(params.caption !== undefined ? { caption: params.caption?.trim() || null } : {}),
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
},
});
return { success: result.count > 0 };
}
export async function deleteGalleryImage(
prisma: PrismaClient,
params: { id: string; businessId: string },
): Promise<{ success: boolean }> {
const result = await prisma.businessGalleryImage.deleteMany({ where: { id: params.id, businessId: params.businessId } });
return { success: result.count > 0 };
}
/** Full replacement of display order — same "send the whole ordered id list" pattern as reorderCategories/reorderServices in service-catalogue.service.ts. */
export async function reorderGalleryImages(
prisma: PrismaClient,
params: { businessId: string; placement: GalleryPlacement; orderedIds: string[] },
): Promise<void> {
await prisma.$transaction(
params.orderedIds.map((id, index) =>
prisma.businessGalleryImage.updateMany({
where: { id, businessId: params.businessId, placement: params.placement },
data: { displayOrder: index },
}),
),
);
}
+2
View File
@@ -49,6 +49,7 @@ export interface LoyaltySummary {
rewardLabel: string | null;
stampIcon: string | null;
cardBackgroundColor: string | null;
cardBackgroundImageUrl: string | null;
}
/** The customer's loyalty standing for this business — null only if no loyalty programme has ever been configured (distinct from "configured but disabled", which still returns a summary with isEnabled: false). */
@@ -89,6 +90,7 @@ export async function getLoyaltySummary(
rewardLabel: programme.rewardLabel,
stampIcon: programme.stampIcon,
cardBackgroundColor: programme.cardBackgroundColor,
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
};
}
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createMediaAsset, deleteMediaAsset, listMediaAssets } from "@/services/media/media.service";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("Media Manager service (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("creates a media asset and lists it back for its own business", async () => {
const asset = await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/branding/test-1.png",
originalFilename: "logo.png",
mimeType: "image/png",
sizeBytes: 12_345,
width: 512,
height: 512,
category: "branding",
uploadedByUserId: null,
});
expect(asset.url).toBe("/media/branding/test-1.png");
const listed = await listMediaAssets(prisma, { businessId: business.businessId });
expect(listed.map((a) => a.id)).toContain(asset.id);
});
it("filters by category and by filename search", async () => {
await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/branding/logo.png",
originalFilename: "shopfront-logo.png",
mimeType: "image/png",
sizeBytes: 100,
width: null,
height: null,
category: "branding",
uploadedByUserId: null,
});
await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/portfolio/haircut.jpg",
originalFilename: "fresh-fade.jpg",
mimeType: "image/jpeg",
sizeBytes: 200,
width: null,
height: null,
category: "portfolio",
uploadedByUserId: null,
});
const brandingOnly = await listMediaAssets(prisma, { businessId: business.businessId, category: "branding" });
expect(brandingOnly).toHaveLength(1);
expect(brandingOnly[0]?.category).toBe("branding");
const searched = await listMediaAssets(prisma, { businessId: business.businessId, q: "fade" });
expect(searched).toHaveLength(1);
expect(searched[0]?.originalFilename).toBe("fresh-fade.jpg");
});
it("never lets one business delete another business's asset", async () => {
const otherBusiness = await createTestBusiness();
try {
const asset = await createMediaAsset(prisma, {
businessId: otherBusiness.businessId,
url: "/media/branding/other.png",
originalFilename: null,
mimeType: "image/png",
sizeBytes: 10,
width: null,
height: null,
category: "branding",
uploadedByUserId: null,
});
const result = await deleteMediaAsset(prisma, { id: asset.id, businessId: business.businessId });
expect(result.success).toBe(false);
const stillExists = await prisma.mediaAsset.findUnique({ where: { id: asset.id } });
expect(stillExists).not.toBeNull();
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("deletes an asset it owns", async () => {
const asset = await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/branding/to-delete.png",
originalFilename: null,
mimeType: "image/png",
sizeBytes: 10,
width: null,
height: null,
category: "branding",
uploadedByUserId: null,
});
const result = await deleteMediaAsset(prisma, { id: asset.id, businessId: business.businessId });
expect(result.success).toBe(true);
const gone = await prisma.mediaAsset.findUnique({ where: { id: asset.id } });
expect(gone).toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { deleteMediaFile } from "@/lib/media/media-storage";
/**
* The Media Manager's library — see docs/phase-9-summary.md. Backs every
* image field app-wide (branding, loyalty card, staff/service photos,
* gallery/portfolio) through one reusable picker rather than each
* settings page implementing its own uploader.
*/
export interface MediaAssetSummary {
id: string;
url: string;
originalFilename: string | null;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
category: string | null;
createdAt: Date;
}
interface MediaAssetRow {
id: string;
url: string;
originalFilename: string | null;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
category: string | null;
createdAt: Date;
}
function toSummary(asset: MediaAssetRow): MediaAssetSummary {
return {
id: asset.id,
url: asset.url,
originalFilename: asset.originalFilename,
mimeType: asset.mimeType,
sizeBytes: asset.sizeBytes,
width: asset.width,
height: asset.height,
category: asset.category,
createdAt: asset.createdAt,
};
}
export async function listMediaAssets(
prisma: PrismaClient,
params: { businessId: string; category?: string; q?: string },
): Promise<MediaAssetSummary[]> {
const assets = await prisma.mediaAsset.findMany({
where: {
businessId: params.businessId,
...(params.category ? { category: params.category } : {}),
...(params.q ? { originalFilename: { contains: params.q } } : {}),
},
orderBy: { createdAt: "desc" },
});
return assets.map(toSummary);
}
export async function createMediaAsset(
prisma: PrismaClient,
params: {
businessId: string;
url: string;
originalFilename: string | null;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
category: string | null;
uploadedByUserId: string | null;
},
): Promise<MediaAssetSummary> {
const asset = await prisma.mediaAsset.create({ data: params });
return toSummary(asset);
}
/**
* Scoped to businessId so one business can never delete another's asset —
* not a real risk in v1's single-business deployment, but a free
* guarantee matching the rest of the schema's tenancy discipline.
*/
export async function deleteMediaAsset(
prisma: PrismaClient,
params: { id: string; businessId: string },
): Promise<{ success: boolean }> {
const asset = await prisma.mediaAsset.findFirst({ where: { id: params.id, businessId: params.businessId } });
if (!asset) return { success: false };
await prisma.mediaAsset.delete({ where: { id: asset.id } });
await deleteMediaFile(asset.url);
return { success: true };
}
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { syncGoogleReviewsForAllBusinesses, syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
import { DbReviewsProvider } from "@/services/reviews/reviews.service";
import { MockReviewsProvider } from "@/services/reviews/mock-reviews-provider";
import { setReviewHidden } from "@/services/reviews/review-management.service";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("Google Reviews sync + DB-backed provider (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("syncs reviews and the place-level aggregate rating, idempotently on re-sync", async () => {
const mock = new MockReviewsProvider();
const first = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
expect(first.synced).toBe(2);
const businessRow = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
expect(Number(businessRow.googleRating)).toBeCloseTo(4.7);
expect(businessRow.googleRatingCount).toBe(128);
const reviews = await prisma.review.findMany({ where: { businessId: business.businessId } });
expect(reviews).toHaveLength(2);
// Re-sync with the same data — upsert, not duplicate.
const second = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
expect(second.synced).toBe(2);
const reviewsAfterResync = await prisma.review.findMany({ where: { businessId: business.businessId } });
expect(reviewsAfterResync).toHaveLength(2);
});
it("only syncs businesses with a configured googlePlaceId", async () => {
const otherBusiness = await createTestBusiness();
try {
await prisma.business.update({ where: { id: business.businessId }, data: { googlePlaceId: "place-configured" } });
// otherBusiness deliberately left without a googlePlaceId.
const mock = new MockReviewsProvider();
const results = await syncGoogleReviewsForAllBusinesses(prisma, mock);
expect(results.map((r) => r.businessId)).toContain(business.businessId);
expect(results.map((r) => r.businessId)).not.toContain(otherBusiness.businessId);
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("records a provider error without throwing", async () => {
const failingProvider = { fetchPlaceReviews: async () => { throw new Error("boom"); } };
const result = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, failingProvider);
expect(result.error).toBe("boom");
expect(result.synced).toBe(0);
});
describe("DbReviewsProvider (customer-facing reads)", () => {
it("falls back to demo data when no reviews have ever been synced", async () => {
const provider = new DbReviewsProvider(prisma);
const summary = await provider.getBusinessReviews(business.businessId);
expect(summary.isDemoData).toBe(true);
});
it("returns real synced reviews once available, honoring isHidden", async () => {
const mock = new MockReviewsProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
const provider = new DbReviewsProvider(prisma);
const before = await provider.getBusinessReviews(business.businessId);
expect(before.isDemoData).toBe(false);
expect(before.reviews).toHaveLength(2);
const [firstReview] = await prisma.review.findMany({ where: { businessId: business.businessId } });
await setReviewHidden(prisma, { reviewId: firstReview!.id, businessId: business.businessId, isHidden: true });
const after = await provider.getBusinessReviews(business.businessId);
expect(after.reviews).toHaveLength(1);
expect(after.reviews.some((r) => r.id === firstReview!.id)).toBe(false);
});
it("honors reviewsMinRating and reviewsExcludeRatingOnly from BusinessSettings", async () => {
const mock = new MockReviewsProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
// MockReviewsProvider's sample: one 5-star review with text, one 3-star review with no text.
await prisma.businessSettings.upsert({
where: { businessId: business.businessId },
create: { businessId: business.businessId, reviewsMinRating: 4 },
update: { reviewsMinRating: 4 },
});
const provider = new DbReviewsProvider(prisma);
const minRatingFiltered = await provider.getBusinessReviews(business.businessId);
expect(minRatingFiltered.reviews.every((r) => r.rating >= 4)).toBe(true);
expect(minRatingFiltered.reviews).toHaveLength(1);
await prisma.businessSettings.update({
where: { businessId: business.businessId },
data: { reviewsMinRating: null, reviewsExcludeRatingOnly: true },
});
const noTextExcluded = await provider.getBusinessReviews(business.businessId);
expect(noTextExcluded.reviews.every((r) => r.text.trim().length > 0)).toBe(true);
});
it("never lets one business's reviews leak into another's summary", async () => {
const otherBusiness = await createTestBusiness();
try {
const mock = new MockReviewsProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
const provider = new DbReviewsProvider(prisma);
const otherSummary = await provider.getBusinessReviews(otherBusiness.businessId);
expect(otherSummary.isDemoData).toBe(true);
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
});
});
@@ -1,8 +1,16 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDemoReviewsProvider, getBusinessReviews, setReviewsProvider } from "../reviews.service";
import type { BusinessReviewSummary, ReviewsProvider } from "../reviews.service";
describe("reviews.service", () => {
// The module has no eager default provider (see reviews.service.ts's
// header — it lazily resolves a real, DB-backed provider on first use
// otherwise, which would need a live DATABASE_URL). Every test here
// explicitly opts into the demo provider so none of them ever touch it.
beforeEach(() => {
setReviewsProvider(createDemoReviewsProvider());
});
afterEach(() => {
setReviewsProvider(createDemoReviewsProvider());
});
@@ -0,0 +1,116 @@
/**
* Google Places integration for the Google Reviews feature (Phase 9) —
* mirrors src/services/calendar/google-calendar.provider.ts's own
* pattern deliberately: a small documented-REST-contract interface, a
* plain-`fetch` real implementation, no SDK dependency, and an injectable
* mock for tests (see mock-reviews-provider.ts). Never called from a
* customer-facing request — only from review-sync.service.ts, itself only
* reachable from the /api/cron/sync-google-reviews route.
*
* Uses the legacy Place Details endpoint (not the newer Places API v1) —
* same "documented REST contract, no SDK" choice as the calendar
* provider, and it returns exactly the fields this needs (rating,
* user_ratings_total, reviews) in one call.
*
* 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 reviews the place actually has. The
* place-level aggregate rating/count (googleRating/googleRatingCount) is
* accurate; the individual review list is always a small sample.
*/
export interface RawGoogleReview {
/**
* Google's legacy API has no stable per-review id — derived from
* author name + the review's own unix timestamp so a re-sync upserts
* the same row instead of duplicating it. Two reviews from the exact
* same author in the exact same second would collide; considered
* acceptable given Google's own API doesn't expose anything better.
*/
externalId: string;
authorName: string;
authorPhotoUrl: string | null;
rating: number;
text: string | null;
relativeTimeDescription: string | null;
publishedAt: Date | null;
}
export interface GooglePlaceReviewsResult {
reviews: RawGoogleReview[];
/** The place's own aggregate rating/count — distinct from (and more accurate than) averaging just the review sample above. */
rating: number | null;
userRatingsTotal: number | null;
}
export interface ReviewsFetchProvider {
fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult>;
}
export class GooglePlacesProviderError extends Error {
constructor(message: string) {
super(message);
this.name = "GooglePlacesProviderError";
}
}
interface LegacyPlaceDetailsReview {
author_name?: string;
profile_photo_url?: string;
rating?: number;
text?: string;
relative_time_description?: string;
time?: number;
}
interface LegacyPlaceDetailsResponse {
status: string;
error_message?: string;
result?: {
rating?: number;
user_ratings_total?: number;
reviews?: LegacyPlaceDetailsReview[];
};
}
export class GooglePlacesReviewsProvider implements ReviewsFetchProvider {
async fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult> {
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
if (!apiKey) {
throw new GooglePlacesProviderError("GOOGLE_PLACES_API_KEY is not configured on this deployment.");
}
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
url.searchParams.set("place_id", placeId);
url.searchParams.set("fields", "rating,user_ratings_total,reviews");
url.searchParams.set("key", apiKey);
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new GooglePlacesProviderError(`Google Places API request failed with HTTP ${response.status}.`);
}
const body = (await response.json()) as LegacyPlaceDetailsResponse;
if (body.status !== "OK") {
throw new GooglePlacesProviderError(
`Google Places API returned status ${body.status}${body.error_message ? `: ${body.error_message}` : ""}.`,
);
}
const reviews: RawGoogleReview[] = (body.result?.reviews ?? []).map((r) => ({
externalId: `${r.author_name ?? "anonymous"}-${r.time ?? 0}`,
authorName: r.author_name ?? "Anonymous",
authorPhotoUrl: r.profile_photo_url ?? null,
rating: typeof r.rating === "number" ? r.rating : 0,
text: r.text || null,
relativeTimeDescription: r.relative_time_description ?? null,
publishedAt: typeof r.time === "number" ? new Date(r.time * 1000) : null,
}));
return {
reviews,
rating: typeof body.result?.rating === "number" ? body.result.rating : null,
userRatingsTotal: typeof body.result?.user_ratings_total === "number" ? body.result.user_ratings_total : null,
};
}
}
@@ -0,0 +1,40 @@
import type { GooglePlaceReviewsResult, ReviewsFetchProvider } from "./google-places.provider";
/**
* In-memory `ReviewsFetchProvider` for tests — never makes a real network
* call, same rationale as MockCalendarProvider (src/services/calendar).
* `result` is settable per-test; defaults to a small realistic sample.
*/
export class MockReviewsProvider implements ReviewsFetchProvider {
result: GooglePlaceReviewsResult = {
rating: 4.7,
userRatingsTotal: 128,
reviews: [
{
externalId: "mock-author-1-1700000000",
authorName: "Mock Author One",
authorPhotoUrl: null,
rating: 5,
text: "Great service, highly recommend.",
relativeTimeDescription: "a week ago",
publishedAt: new Date("2024-01-01T00:00:00Z"),
},
{
externalId: "mock-author-2-1700000100",
authorName: "Mock Author Two",
authorPhotoUrl: null,
rating: 3,
text: null,
relativeTimeDescription: "a month ago",
publishedAt: new Date("2024-01-02T00:00:00Z"),
},
],
};
fetchCallCount = 0;
async fetchPlaceReviews(): Promise<GooglePlaceReviewsResult> {
this.fetchCallCount += 1;
return this.result;
}
}
@@ -0,0 +1,47 @@
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Owner-side review moderation — distinct from reviews.service.ts (the
* customer-facing, policy-filtered read). This always returns everything,
* hidden or not, so the owner can see what they're hiding/unhiding.
*/
export interface OwnerReviewItem {
id: string;
authorName: string;
authorPhotoUrl: string | null;
rating: number;
text: string | null;
relativeTimeDescription: string | null;
publishedAt: Date | null;
isHidden: boolean;
fetchedAt: Date;
}
export async function listReviewsForOwner(prisma: PrismaClient, businessId: string): Promise<OwnerReviewItem[]> {
const reviews = await prisma.review.findMany({
where: { businessId },
orderBy: { publishedAt: "desc" },
});
return reviews.map((r) => ({
id: r.id,
authorName: r.authorName,
authorPhotoUrl: r.authorPhotoUrl,
rating: r.rating,
text: r.text,
relativeTimeDescription: r.relativeTimeDescription,
publishedAt: r.publishedAt,
isHidden: r.isHidden,
fetchedAt: r.fetchedAt,
}));
}
export async function setReviewHidden(
prisma: PrismaClient,
params: { reviewId: string; businessId: string; isHidden: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.review.updateMany({
where: { id: params.reviewId, businessId: params.businessId },
data: { isHidden: params.isHidden },
});
return { success: result.count > 0 };
}
@@ -0,0 +1,86 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { GooglePlacesReviewsProvider } from "./google-places.provider";
import type { ReviewsFetchProvider } from "./google-places.provider";
/**
* The 24h scheduled sync (see /api/cron/sync-google-reviews) — the only
* place that ever calls the Google Places API. Everything customer-facing
* reads the Review table instead (see reviews.service.ts's DbReviewsProvider),
* never Google directly, per the brief's explicit requirement.
*/
function getDefaultProvider(): ReviewsFetchProvider {
return new GooglePlacesReviewsProvider();
}
export interface BusinessReviewSyncResult {
businessId: string;
synced: number;
error?: string;
}
/** One business's sync — upserts every review Google returned (idempotent via the businessId+externalId unique constraint) and refreshes the place-level aggregate rating/count. */
export async function syncGoogleReviewsForBusiness(
prisma: PrismaClient,
params: { businessId: string; placeId: string },
provider: ReviewsFetchProvider = getDefaultProvider(),
): Promise<BusinessReviewSyncResult> {
try {
const result = await provider.fetchPlaceReviews(params.placeId);
const fetchedAt = new Date();
await prisma.$transaction([
prisma.business.update({
where: { id: params.businessId },
data: { googleRating: result.rating, googleRatingCount: result.userRatingsTotal },
}),
...result.reviews.map((review) =>
prisma.review.upsert({
where: { businessId_externalId: { businessId: params.businessId, externalId: review.externalId } },
create: {
businessId: params.businessId,
externalId: review.externalId,
authorName: review.authorName,
authorPhotoUrl: review.authorPhotoUrl,
rating: review.rating,
text: review.text,
relativeTimeDescription: review.relativeTimeDescription,
publishedAt: review.publishedAt,
fetchedAt,
},
update: {
authorName: review.authorName,
authorPhotoUrl: review.authorPhotoUrl,
rating: review.rating,
text: review.text,
relativeTimeDescription: review.relativeTimeDescription,
publishedAt: review.publishedAt,
fetchedAt,
},
}),
),
]);
return { businessId: params.businessId, synced: result.reviews.length };
} catch (error) {
return { businessId: params.businessId, synced: 0, error: error instanceof Error ? error.message : "Unknown error" };
}
}
/** Every business with a configured Google Place ID — businesses without one are silently skipped (review sync is opt-in, see Business.googlePlaceId). */
export async function syncGoogleReviewsForAllBusinesses(
prisma: PrismaClient,
provider: ReviewsFetchProvider = getDefaultProvider(),
): Promise<BusinessReviewSyncResult[]> {
const businesses = await prisma.business.findMany({
where: { googlePlaceId: { not: null } },
select: { id: true, googlePlaceId: true },
});
const results: BusinessReviewSyncResult[] = [];
for (const business of businesses) {
if (!business.googlePlaceId) continue;
results.push(await syncGoogleReviewsForBusiness(prisma, { businessId: business.id, placeId: business.googlePlaceId }, provider));
}
return results;
}
+86 -11
View File
@@ -1,10 +1,16 @@
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Reviews behind a provider abstraction, per the Phase 4 brief — a real
* Google Places/Business Profile integration can implement this same
* interface later without any caller (Customer Home, etc.) changing.
* Backed by clearly-labelled demo data for now; no schema/DB involvement,
* per the Phase 1 plan's recommendation to treat reviews as an external,
* cached read rather than a first-party entity.
* Reviews behind a provider abstraction, per the original Phase 4 brief —
* "a real Google Places/Business Profile integration can implement this
* same interface later without any caller (Customer Home, etc.)
* changing." Phase 9 is that moment: DbReviewsProvider below reads the
* `Review` table (populated by the 24h sync job — see
* review-sync.service.ts) instead of calling Google directly, honoring
* the owner's visibility policy (Review.isHidden,
* BusinessSettings.reviewsMinRating/reviewsExcludeRatingOnly). Falls back
* to demo data only when a business has never had any reviews synced at
* all, so a fresh install doesn't show an empty Reviews tab.
*/
export interface ReviewItem {
@@ -12,7 +18,7 @@ export interface ReviewItem {
authorName: string;
rating: number; // 1–5
text: string;
relativeDate: string; // pre-formatted, e.g. "2 weeks ago" — a real provider would supply this directly
relativeDate: string; // pre-formatted, e.g. "2 weeks ago"
}
export interface BusinessReviewSummary {
@@ -51,7 +57,7 @@ const DEMO_REVIEWS: ReviewItem[] = [
},
];
class DemoReviewsProvider implements ReviewsProvider {
export class DemoReviewsProvider implements ReviewsProvider {
async getBusinessReviews(_businessId: string): Promise<BusinessReviewSummary> {
const totalReviews = 1142; // matches the figure shown in templates/15
const averageRating = 5.0;
@@ -64,13 +70,82 @@ export function createDemoReviewsProvider(): ReviewsProvider {
return new DemoReviewsProvider();
}
let provider: ReviewsProvider = new DemoReviewsProvider();
function averageOf(reviews: { rating: number }[]): number {
if (reviews.length === 0) return 0;
return reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
}
/** Swap the provider (e.g. in a future Google-backed implementation, or a test double) without touching any caller. */
/** The real, DB-backed provider — see this file's header. Never calls Google; only ever reads what review-sync.service.ts already wrote. */
export class DbReviewsProvider implements ReviewsProvider {
constructor(private prisma: PrismaClient) {}
async getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
const totalSynced = await this.prisma.review.count({ where: { businessId } });
if (totalSynced === 0) {
return new DemoReviewsProvider().getBusinessReviews(businessId);
}
const [business, settings] = await Promise.all([
this.prisma.business.findUnique({ where: { id: businessId }, select: { googleRating: true, googleRatingCount: true } }),
this.prisma.businessSettings.findUnique({
where: { businessId },
select: { reviewsMinRating: true, reviewsExcludeRatingOnly: true },
}),
]);
const visibleReviews = await this.prisma.review.findMany({
where: {
businessId,
isHidden: false,
...(settings?.reviewsMinRating ? { rating: { gte: settings.reviewsMinRating } } : {}),
...(settings?.reviewsExcludeRatingOnly ? { text: { not: null } } : {}),
},
orderBy: { publishedAt: "desc" },
take: 20,
});
return {
// The place-level aggregate is more accurate than averaging the
// (Google-capped-at-5) sample — see google-places.provider.ts's
// header for why the two are tracked separately.
averageRating: business?.googleRating ? Number(business.googleRating) : averageOf(visibleReviews),
totalReviews: business?.googleRatingCount ?? visibleReviews.length,
reviews: visibleReviews.map((r) => ({
id: r.id,
authorName: r.authorName,
rating: r.rating,
text: r.text ?? "",
relativeDate: r.relativeTimeDescription ?? "",
})),
isDemoData: false,
};
}
}
/**
* `null` = no explicit override; resolved lazily to a real DbReviewsProvider
* on first use. Deliberately NOT constructed eagerly at module load — this
* file must stay importable from a pure unit-test context with no
* DATABASE_URL (see reviews.service.test.ts), and src/lib/db/prisma.ts
* throws at import time if that's unset.
*/
let provider: ReviewsProvider | null = null;
let cachedDefaultProvider: ReviewsProvider | null = null;
/** Swap the provider (e.g. back to demo data, or a test double) without touching any caller. */
export function setReviewsProvider(next: ReviewsProvider): void {
provider = next;
}
async function resolveDefaultProvider(): Promise<ReviewsProvider> {
if (!cachedDefaultProvider) {
const { prisma } = await import("@/lib/db/prisma");
cachedDefaultProvider = new DbReviewsProvider(prisma);
}
return cachedDefaultProvider;
}
export async function getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
return provider.getBusinessReviews(businessId);
const active = provider ?? (await resolveDefaultProvider());
return active.getBusinessReviews(businessId);
}
@@ -41,6 +41,10 @@ export interface AvailableService {
id: string;
categoryId: string;
name: string;
/** Collapses in the UI when null/empty — see ServiceCard and ServiceSelector. */
description: string | null;
/** Set via the Media Manager — see src/features/media/components/media-picker.tsx. */
imageUrl: string | null;
durationMinutes: number;
price: string; // Decimal serialized to string — currency formatting is a presentation concern, not this layer's
bufferBeforeMinutes: number;
@@ -66,6 +70,8 @@ export async function getAvailableServices(
id: s.id,
categoryId: s.categoryId,
name: s.name,
description: s.description,
imageUrl: s.imageUrl,
durationMinutes: s.durationMinutes,
price: s.price.toString(),
bufferBeforeMinutes: s.bufferBeforeMinutes,