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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user