Reviews & Layout

Scaled back google reviews to just get the star rating and number of reviews as google limits the number of reviews to 5.

Also removed the reviews tab from homepage.

And updated Appointments button layout
This commit is contained in:
Stefan Lazic committed 2026-08-06 09:57:24 +01:00
1 parent 92f062a861
commit f07c30da0d
18 files changed
+297 -615

No files matched your search

@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GooglePlaceRatingProvider, GooglePlacesProviderError } from "../google-places.provider";
/**
* Covers the Places API (New) request/response contract this provider
* depends on — worth a dedicated test since it was silently broken for a
* while by calling the deprecated legacy Place Details endpoint (which
* returns REQUEST_DENIED on any Cloud project without that legacy API
* enabled) instead of this one.
*/
describe("GooglePlaceRatingProvider (Places API New)", () => {
const originalKey = process.env.GOOGLE_PLACES_API_KEY;
beforeEach(() => {
process.env.GOOGLE_PLACES_API_KEY = "test-key";
});
afterEach(() => {
process.env.GOOGLE_PLACES_API_KEY = originalKey;
vi.unstubAllGlobals();
});
it("calls the New Places endpoint with the API key and a rating-only field mask as headers, not a query param", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ rating: 4.5, userRatingCount: 10 }),
});
vi.stubGlobal("fetch", fetchMock);
await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://places.googleapis.com/v1/places/ChIJabc123");
expect(url).not.toContain("key=");
expect(init.headers["X-Goog-Api-Key"]).toBe("test-key");
// No `reviews` in the mask — this provider only ever wants the
// aggregate, never individual reviews (see the file header).
expect(init.headers["X-Goog-FieldMask"]).toBe("rating,userRatingCount");
});
it("maps a real New-API response shape into the normalized aggregate shape", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ rating: 4.7, userRatingCount: 128 }),
}),
);
const result = await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
expect(result).toEqual({ rating: 4.7, userRatingsTotal: 128 });
});
it("returns nulls when Google omits rating/userRatingCount (e.g. a place with no ratings yet)", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }),
);
const result = await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
expect(result).toEqual({ rating: null, userRatingsTotal: null });
});
it("surfaces the New API's standard error envelope (error.status/message), not the legacy status field", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({
error: { code: 403, message: "This API method requires billing to be enabled.", status: "PERMISSION_DENIED" },
}),
}),
);
await expect(new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123")).rejects.toMatchObject({
message: expect.stringContaining("PERMISSION_DENIED"),
});
});
it("throws GooglePlacesProviderError when no API key is configured", async () => {
delete process.env.GOOGLE_PLACES_API_KEY;
await expect(new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123")).rejects.toBeInstanceOf(
GooglePlacesProviderError,
);
});
});
@@ -1,8 +1,7 @@
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 { MockPlaceRatingProvider } from "@/services/reviews/mock-place-rating-provider";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
@@ -17,23 +16,23 @@ describe("Google Reviews sync + DB-backed provider (integration)", () => {
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);
it("syncs the place-level aggregate rating, idempotently on re-sync — no individual review rows", async () => {
const mock = new MockPlaceRatingProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
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);
const reviewCount = await prisma.review.count({ where: { businessId: business.businessId } });
expect(reviewCount).toBe(0);
// 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);
// Re-sync with updated data — overwrites, doesn't accumulate anything.
mock.result = { rating: 4.8, userRatingsTotal: 140 };
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
const resynced = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
expect(Number(resynced.googleRating)).toBeCloseTo(4.8);
expect(resynced.googleRatingCount).toBe(140);
});
it("only syncs businesses with a configured googlePlaceId", async () => {
@@ -42,7 +41,7 @@ describe("Google Reviews sync + DB-backed provider (integration)", () => {
await prisma.business.update({ where: { id: business.businessId }, data: { googlePlaceId: "place-configured" } });
// otherBusiness deliberately left without a googlePlaceId.
const mock = new MockReviewsProvider();
const mock = new MockPlaceRatingProvider();
const results = await syncGoogleReviewsForAllBusinesses(prisma, mock);
expect(results.map((r) => r.businessId)).toContain(business.businessId);
@@ -53,10 +52,9 @@ describe("Google Reviews sync + DB-backed provider (integration)", () => {
});
it("records a provider error without throwing", async () => {
const failingProvider = { fetchPlaceReviews: async () => { throw new Error("boom"); } };
const failingProvider = { fetchPlaceRating: 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)", () => {
@@ -66,52 +64,22 @@ describe("Google Reviews sync + DB-backed provider (integration)", () => {
expect(summary.isDemoData).toBe(true);
});
it("returns real synced reviews once available, honoring isHidden", async () => {
const mock = new MockReviewsProvider();
it("returns the real synced aggregate once available, with an empty individual-review list", async () => {
const mock = new MockPlaceRatingProvider();
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);
const summary = await provider.getBusinessReviews(business.businessId);
expect(summary.isDemoData).toBe(false);
expect(summary.averageRating).toBeCloseTo(4.7);
expect(summary.totalReviews).toBe(128);
expect(summary.reviews).toHaveLength(0);
});
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 () => {
it("never lets one business's aggregate leak into another's summary", async () => {
const otherBusiness = await createTestBusiness();
try {
const mock = new MockReviewsProvider();
const mock = new MockPlaceRatingProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
const provider = new DbReviewsProvider(prisma);
+39 -74
View File
@@ -3,48 +3,37 @@
* 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
* mock for tests (see mock-place-rating-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.
* Uses Place Details (New) (places.googleapis.com/v1) — the legacy
* maps.googleapis.com/maps/api/place/details endpoint this used to call
* returns REQUEST_DENIED on any Cloud project created after Google
* deprecated it ("legacy API, which is not enabled for your project" —
* see https://developers.google.com/maps/legacy#LegacyApiNotActivatedMapError).
* The New API is a different Cloud API product (enable "Places API (New)",
* not "Places API", in Google Cloud Console) with a different auth style
* (API key as a header, not a query param).
*
* 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.
* Deliberately fetches only the place-level aggregate (`rating`,
* `userRatingCount`), not individual reviews — Google's Place Details only
* ever returns up to 5 "most relevant" reviews per call regardless of how
* many the place actually has, with no pagination to get the rest (see
* https://developers.google.com/maps/documentation/places/web-service/place-details
* — "can return a maximum of five reviews per place"). Given that hard
* cap, downloading and displaying a tiny, non-representative review
* sample added complexity without adding real value; the aggregate
* rating/count alone is accurate and is all this app surfaces.
*/
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. */
export interface GooglePlaceRatingResult {
rating: number | null;
userRatingsTotal: number | null;
}
export interface ReviewsFetchProvider {
fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult>;
export interface PlaceRatingProvider {
fetchPlaceRating(placeId: string): Promise<GooglePlaceRatingResult>;
}
export class GooglePlacesProviderError extends Error {
@@ -54,63 +43,39 @@ export class GooglePlacesProviderError extends Error {
}
}
interface LegacyPlaceDetailsReview {
author_name?: string;
profile_photo_url?: string;
interface PlaceDetailsNewResponse {
rating?: number;
text?: string;
relative_time_description?: string;
time?: number;
userRatingCount?: number;
error?: { code?: number; message?: string; status?: string };
}
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> {
export class GooglePlaceRatingProvider implements PlaceRatingProvider {
async fetchPlaceRating(placeId: string): Promise<GooglePlaceRatingResult> {
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(`https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}`, {
headers: {
"X-Goog-Api-Key": apiKey,
// Places API (New) is opt-in per field for billing — omitting this
// header fails the request outright, it doesn't just return less.
"X-Goog-FieldMask": "rating,userRatingCount",
},
cache: "no-store",
});
const response = await fetch(url, { cache: "no-store" });
const body = (await response.json()) as PlaceDetailsNewResponse;
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}` : ""}.`,
`Google Places API returned ${body.error?.status ?? response.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,
rating: typeof body.rating === "number" ? body.rating : null,
userRatingsTotal: typeof body.userRatingCount === "number" ? body.userRatingCount : null,
};
}
}
@@ -0,0 +1,20 @@
import type { GooglePlaceRatingResult, PlaceRatingProvider } from "./google-places.provider";
/**
* In-memory `PlaceRatingProvider` 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 MockPlaceRatingProvider implements PlaceRatingProvider {
result: GooglePlaceRatingResult = {
rating: 4.7,
userRatingsTotal: 128,
};
fetchCallCount = 0;
async fetchPlaceRating(): Promise<GooglePlaceRatingResult> {
this.fetchCallCount += 1;
return this.result;
}
}
@@ -1,40 +0,0 @@
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;
}
}
@@ -1,47 +0,0 @@
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 };
}
+19 -48
View File
@@ -1,76 +1,47 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { GooglePlacesReviewsProvider } from "./google-places.provider";
import type { ReviewsFetchProvider } from "./google-places.provider";
import { GooglePlaceRatingProvider } from "./google-places.provider";
import type { PlaceRatingProvider } 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.
* place that ever calls the Google Places API. Only syncs the place-level
* aggregate rating/count, not individual reviews — see
* google-places.provider.ts's header for why. Everything customer-facing
* reads Business.googleRating/googleRatingCount instead (see
* reviews.service.ts's DbReviewsProvider), never Google directly.
*/
function getDefaultProvider(): ReviewsFetchProvider {
return new GooglePlacesReviewsProvider();
function getDefaultProvider(): PlaceRatingProvider {
return new GooglePlaceRatingProvider();
}
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. */
/** One business's sync — refreshes the place-level aggregate rating/count. */
export async function syncGoogleReviewsForBusiness(
prisma: PrismaClient,
params: { businessId: string; placeId: string },
provider: ReviewsFetchProvider = getDefaultProvider(),
provider: PlaceRatingProvider = 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 };
const result = await provider.fetchPlaceRating(params.placeId);
await prisma.business.update({
where: { id: params.businessId },
data: { googleRating: result.rating, googleRatingCount: result.userRatingsTotal },
});
return { businessId: params.businessId };
} catch (error) {
return { businessId: params.businessId, synced: 0, error: error instanceof Error ? error.message : "Unknown error" };
return { businessId: params.businessId, 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(),
provider: PlaceRatingProvider = getDefaultProvider(),
): Promise<BusinessReviewSyncResult[]> {
const businesses = await prisma.business.findMany({
where: { googlePlaceId: { not: null } },
+21 -45
View File
@@ -4,13 +4,15 @@ import type { PrismaClient } from "@/generated/prisma/client";
* 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.
* changing." DbReviewsProvider below reads Business.googleRating/
* googleRatingCount (populated by the 24h sync job — see
* review-sync.service.ts) instead of calling Google directly. Only the
* place-level aggregate is synced, never individual reviews (Google's
* Place Details caps those at 5 per place with no way to get the rest —
* see google-places.provider.ts's header) — `reviews` below is always
* empty for real (non-demo) data. Falls back to demo data only when a
* business has never synced at all, so a fresh install doesn't show an
* empty Reviews tab.
*/
export interface ReviewItem {
@@ -70,53 +72,27 @@ export function createDemoReviewsProvider(): ReviewsProvider {
return 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;
}
/** 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) {
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { googleRating: true, googleRatingCount: true },
});
// Whether a sync has ever run is signalled by the place-level
// aggregate — this is the only thing review-sync.service.ts ever
// writes (see this file's header).
if (business?.googleRating === null || business?.googleRating === undefined) {
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 ?? "",
})),
averageRating: Number(business.googleRating),
totalReviews: business.googleRatingCount ?? 0,
reviews: [],
isDemoData: false,
};
}