From f07c30da0df6691799d1d9523f7966d5cb2f5011 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Thu, 6 Aug 2026 09:57:24 +0100 Subject: [PATCH] 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 --- src/app/(customer)/appointments/[id]/page.tsx | 15 +- src/app/(customer)/home/page.tsx | 1 - .../(owner)/owner/settings/reviews/page.tsx | 16 +- src/app/api/cron/sync-google-reviews/route.ts | 7 +- src/app/book/review/page.tsx | 4 +- src/app/globals.css | 1 + .../components/cancel-appointment-dialog.tsx | 4 +- src/features/home/components/home-tabs.tsx | 48 +--- src/features/owner/actions/reviews.ts | 46 +--- .../owner/components/reviews-manager.tsx | 250 ++++-------------- .../__tests__/google-places.provider.test.ts | 89 +++++++ .../review-sync.service.integration.test.ts | 78 ++---- .../reviews/google-places.provider.ts | 113 +++----- .../reviews/mock-place-rating-provider.ts | 20 ++ src/services/reviews/mock-reviews-provider.ts | 40 --- .../reviews/review-management.service.ts | 47 ---- src/services/reviews/review-sync.service.ts | 67 ++--- src/services/reviews/reviews.service.ts | 66 ++--- 18 files changed, 297 insertions(+), 615 deletions(-) create mode 100644 src/services/reviews/__tests__/google-places.provider.test.ts create mode 100644 src/services/reviews/mock-place-rating-provider.ts delete mode 100644 src/services/reviews/mock-reviews-provider.ts delete mode 100644 src/services/reviews/review-management.service.ts diff --git a/src/app/(customer)/appointments/[id]/page.tsx b/src/app/(customer)/appointments/[id]/page.tsx index 90c2e67..938cce2 100644 --- a/src/app/(customer)/appointments/[id]/page.tsx +++ b/src/app/(customer)/appointments/[id]/page.tsx @@ -154,37 +154,34 @@ export default async function AppointmentDetailsPage({ )} -
+
{business.phone && ( )} -
- -
{RESCHEDULABLE_STATUSES.has(appointment.status) && ( Reschedule )} {CANCELLABLE_STATUSES.has(appointment.status) && ( - + )} {REBOOKABLE_STATUSES.has(appointment.status) && ( - + )}
diff --git a/src/app/(customer)/home/page.tsx b/src/app/(customer)/home/page.tsx index 0e49857..4f679ea 100644 --- a/src/app/(customer)/home/page.tsx +++ b/src/app/(customer)/home/page.tsx @@ -275,7 +275,6 @@ export default async function CustomerHomePage() { services={services} categories={categories.map((c) => ({ id: c.id, name: c.name }))} portfolioImages={portfolioImages} - reviews={reviews} description={business.description} phone={business.phone} email={business.email} diff --git a/src/app/(owner)/owner/settings/reviews/page.tsx b/src/app/(owner)/owner/settings/reviews/page.tsx index 2d1291f..503fecd 100644 --- a/src/app/(owner)/owner/settings/reviews/page.tsx +++ b/src/app/(owner)/owner/settings/reviews/page.tsx @@ -7,7 +7,6 @@ import { hasPermission } from "@/lib/auth/permissions"; import { Permission } from "@/generated/prisma/enums"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getBusinessProfile } from "@/services/business/business.service"; -import { listReviewsForOwner } from "@/services/reviews/review-management.service"; import { ReviewsManager } from "@/features/owner/components/reviews-manager"; export const metadata: Metadata = { title: "Reviews" }; @@ -23,28 +22,15 @@ export default async function OwnerReviewsPage() { const context = await getActingBusinessContext(); if (!context) redirect("/staff"); - const [business, reviews] = await Promise.all([ - getBusinessProfile(prisma, context.businessId), - listReviewsForOwner(prisma, context.businessId), - ]); + const business = await getBusinessProfile(prisma, context.businessId); if (!business) redirect("/owner"); return ( ({ - id: r.id, - authorName: r.authorName, - rating: r.rating, - text: r.text, - relativeTimeDescription: r.relativeTimeDescription, - isHidden: r.isHidden, - }))} /> ); diff --git a/src/app/api/cron/sync-google-reviews/route.ts b/src/app/api/cron/sync-google-reviews/route.ts index 31b1dd1..5cfec13 100644 --- a/src/app/api/cron/sync-google-reviews/route.ts +++ b/src/app/api/cron/sync-google-reviews/route.ts @@ -13,9 +13,10 @@ import { syncGoogleReviewsForAllBusinesses } from "@/services/reviews/review-syn * that, and the customer app never calls Google directly regardless (see * src/services/reviews/reviews.service.ts). * - * Safe to run concurrently with itself: syncGoogleReviewsForBusiness's - * Review upsert is keyed on (businessId, externalId), so an overlapping - * call just re-writes the same rows, never duplicates them. + * Safe to run concurrently with itself: syncGoogleReviewsForBusiness only + * overwrites Business.googleRating/googleRatingCount with whatever Google + * returns, so an overlapping call just re-writes the same two columns, + * never duplicates anything. */ export async function POST(request: Request) { const secret = process.env.CRON_SECRET; diff --git a/src/app/book/review/page.tsx b/src/app/book/review/page.tsx index 8f9de17..6ac0202 100644 --- a/src/app/book/review/page.tsx +++ b/src/app/book/review/page.tsx @@ -58,7 +58,7 @@ export default async function BookReviewPage() {
-
+
Business @@ -70,7 +70,7 @@ export default async function BookReviewPage() {
-
+
diff --git a/src/app/globals.css b/src/app/globals.css index b9cdd38..f16378a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,6 +13,7 @@ --color-ink: #111b15; --color-stone: #f4f6f4; --color-surface: #ffffff; + --color-pure-cloud: #f5f6f8; /* Darkened from the templates' #6c7870 — that value was ~4.2:1 on --color-stone (this app's default body background), just under WCAG AA's 4.5:1 for normal text, and text-muted is used 300+ times across diff --git a/src/features/booking/components/cancel-appointment-dialog.tsx b/src/features/booking/components/cancel-appointment-dialog.tsx index 5a310ac..ef4aed2 100644 --- a/src/features/booking/components/cancel-appointment-dialog.tsx +++ b/src/features/booking/components/cancel-appointment-dialog.tsx @@ -10,9 +10,11 @@ import { cancelCustomerAppointmentAction } from "../actions/customer-appointment export function CancelAppointmentDialog({ appointmentId, guestToken, + className, }: { appointmentId: string; guestToken?: string; + className?: string; }) { const [open, setOpen] = useState(false); const [reason, setReason] = useState(""); @@ -47,7 +49,7 @@ export function CancelAppointmentDialog({ return ( <> - Services Portfolio - Reviews Details @@ -52,9 +53,6 @@ export function HomeTabs(props: HomeTabsProps) { - - - No reviews yet.

; - } - - return ( -
- {reviews.isDemoData && ( -

Demo data

- )} - {reviews.reviews.map((review) => ( - -
-

{review.authorName}

- {review.relativeDate} -
-
- {Array.from({ length: review.rating }, (_, i) => ( -
-

{review.text}

-
- ))} -
- ); -} - function DetailsTab({ description, phone, diff --git a/src/features/owner/actions/reviews.ts b/src/features/owner/actions/reviews.ts index 4383228..0a259a6 100644 --- a/src/features/owner/actions/reviews.ts +++ b/src/features/owner/actions/reviews.ts @@ -6,8 +6,7 @@ import { Permission } from "@/generated/prisma/enums"; import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { writeAuditLog } from "@/lib/audit/write-audit-log"; -import { updateBusinessProfile, updateBusinessSettings } from "@/services/business/business.service"; -import { setReviewHidden } from "@/services/reviews/review-management.service"; +import { updateBusinessProfile } from "@/services/business/business.service"; import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service"; export interface ReviewsActionResult { @@ -33,25 +32,18 @@ async function requireReviewsManager() { } /** - * Reuses updateBusinessProfile/updateBusinessSettings (the same functions - * Business Details uses) rather than a parallel update path — - * googlePlaceId lives on Business, the two policy flags on - * BusinessSettings. Deliberately no format regex on googlePlaceId beyond - * trimming — Google's own Place ID formats vary (place, postal code, - * plus-code IDs all look different) and this app has no need to validate - * more than "the owner typed something." + * Reuses updateBusinessProfile (the same function Business Details uses) + * rather than a parallel update path — googlePlaceId lives on Business. + * Deliberately no format regex on googlePlaceId beyond trimming — Google's + * own Place ID formats vary (place, postal code, plus-code IDs all look + * different) and this app has no need to validate more than "the owner + * typed something." * - * Writes an audit log only when the Place ID actually changed — existing - * imported Review rows are deliberately left as-is when it changes (kept - * for history/audit, per docs/phase-10-summary.md's "Google Place ID" - * section); the next sync (scheduled or manual, via syncReviewsNowAction) - * naturally reconciles what's currently associated with the new Place ID. + * Writes an audit log only when the Place ID actually changed; the next + * sync (scheduled or manual, via syncReviewsNowAction) naturally picks up + * whatever rating/count the new Place ID has. */ -export async function updateReviewsSettingsAction(input: { - googlePlaceId: string | null; - reviewsMinRating: number | null; - reviewsExcludeRatingOnly: boolean; -}): Promise { +export async function updateReviewsSettingsAction(input: { googlePlaceId: string | null }): Promise { const { businessId, userId, error } = await requireReviewsManager(); if (!businessId) return { success: false, error: error ?? "Not authorized." }; @@ -83,11 +75,6 @@ export async function updateReviewsSettingsAction(input: { return { success: false, error: profileResult.validation.issues[0]?.message ?? "Couldn't save the Google Place ID." }; } - await updateBusinessSettings(prisma, { - businessId, - settings: { reviewsMinRating: input.reviewsMinRating, reviewsExcludeRatingOnly: input.reviewsExcludeRatingOnly }, - }); - if (placeIdChanged) { await writeAuditLog(prisma, { actorUserId: userId, @@ -101,16 +88,7 @@ export async function updateReviewsSettingsAction(input: { return { success: true }; } -export async function setReviewHiddenAction(reviewId: string, isHidden: boolean): Promise { - const { businessId, error } = await requireReviewsManager(); - if (!businessId) return { success: false, error: error ?? "Not authorized." }; - - const result = await setReviewHidden(prisma, { reviewId, businessId, isHidden }); - if (!result.success) return { success: false, error: "That review couldn't be found." }; - return { success: true }; -} - -/** Manual "sync now" — the scheduled job (/api/cron/sync-google-reviews) covers the normal 24h cadence; this lets an owner see results immediately after entering a Place ID for the first time. */ +/** Manual "sync now" — the scheduled job (/api/cron/sync-google-reviews) covers the normal 24h cadence; this lets an owner see the rating/count immediately after entering a Place ID for the first time. */ export async function syncReviewsNowAction(): Promise { const { businessId, error } = await requireReviewsManager(); if (!businessId) return { success: false, error: error ?? "Not authorized." }; diff --git a/src/features/owner/components/reviews-manager.tsx b/src/features/owner/components/reviews-manager.tsx index dd01c0a..fba10f8 100644 --- a/src/features/owner/components/reviews-manager.tsx +++ b/src/features/owner/components/reviews-manager.tsx @@ -1,91 +1,40 @@ "use client"; -import { useMemo, useState, useTransition } from "react"; -import { RefreshCw, Star, StarOff } from "lucide-react"; +import { useState, useTransition } from "react"; +import { RefreshCw } from "lucide-react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { cn } from "@/lib/utils/cn"; -import { setReviewHiddenAction, syncReviewsNowAction, updateReviewsSettingsAction } from "../actions/reviews"; - -export interface OwnerReviewRow { - id: string; - authorName: string; - rating: number; - text: string | null; - relativeTimeDescription: string | null; - isHidden: boolean; -} +import { syncReviewsNowAction, updateReviewsSettingsAction } from "../actions/reviews"; export interface ReviewsManagerProps { initialGooglePlaceId: string; - initialReviewsMinRating: number | null; - initialReviewsExcludeRatingOnly: boolean; googleRating: number | null; googleRatingCount: number | null; - reviews: OwnerReviewRow[]; } -const RATING_FILTERS = [null, 5, 4, 3, 2, 1] as const; - -function StarRating({ rating }: { rating: number }) { - return ( -
- {Array.from({ length: 5 }, (_, i) => ( -
- ); -} - -export function ReviewsManager({ - initialGooglePlaceId, - initialReviewsMinRating, - initialReviewsExcludeRatingOnly, - googleRating, - googleRatingCount, - reviews, -}: ReviewsManagerProps) { +/** + * Just the Google Place ID + aggregate rating/count sync — no individual + * review import/management. Google's Place Details API only ever returns + * up to 5 "most relevant" reviews with no pagination to get the rest (see + * google-places.provider.ts's header), so a review list here could never + * be a real, representative picture — the aggregate rating/count alone is + * accurate and is all this app surfaces. + */ +export function ReviewsManager({ initialGooglePlaceId, googleRating, googleRatingCount }: ReviewsManagerProps) { const router = useRouter(); const [googlePlaceId, setGooglePlaceId] = useState(initialGooglePlaceId); - const [reviewsMinRating, setReviewsMinRating] = useState(initialReviewsMinRating); - const [reviewsExcludeRatingOnly, setReviewsExcludeRatingOnly] = useState(initialReviewsExcludeRatingOnly); const [settingsError, setSettingsError] = useState(null); const [settingsSaved, setSettingsSaved] = useState(false); const [syncMessage, setSyncMessage] = useState(null); const [isSettingsPending, startSettingsTransition] = useTransition(); const [isSyncPending, startSyncTransition] = useTransition(); - const [ratingFilter, setRatingFilter] = useState(null); - const [query, setQuery] = useState(""); - const [rows, setRows] = useState(reviews); - const [hidingId, setHidingId] = useState(null); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - return rows.filter((r) => { - if (ratingFilter && r.rating !== ratingFilter) return false; - if (q && !(r.authorName.toLowerCase().includes(q) || (r.text ?? "").toLowerCase().includes(q))) return false; - return true; - }); - }, [rows, ratingFilter, query]); - const saveSettings = () => { setSettingsError(null); setSettingsSaved(false); startSettingsTransition(async () => { - const result = await updateReviewsSettingsAction({ - googlePlaceId: googlePlaceId.trim() || null, - reviewsMinRating, - reviewsExcludeRatingOnly, - }); + const result = await updateReviewsSettingsAction({ googlePlaceId: googlePlaceId.trim() || null }); if (!result.success) { setSettingsError(result.error ?? "Couldn't save review settings."); return; @@ -104,144 +53,41 @@ export function ReviewsManager({ }); }; - const toggleHidden = (review: OwnerReviewRow) => { - setHidingId(review.id); - const nextHidden = !review.isHidden; - setRows((prev) => prev.map((r) => (r.id === review.id ? { ...r, isHidden: nextHidden } : r))); - startSettingsTransition(async () => { - const result = await setReviewHiddenAction(review.id, nextHidden); - if (!result.success) { - // Revert on failure. - setRows((prev) => prev.map((r) => (r.id === review.id ? { ...r, isHidden: review.isHidden } : r))); - } - setHidingId(null); - }); - }; - return ( -
-
-

Google Reviews Sync

- {googleRating !== null && ( -

- Google's aggregate: {googleRating.toFixed(1)} from{" "} - {googleRatingCount ?? 0} ratings. -

- )} - setGooglePlaceId(e.target.value)} - placeholder="ChIJ…" - hint="Used to import this business's reviews from Google. Find it by searching your business on Google Maps, opening Share → Embed a map, and copying the id from the embed code — or search “Google Place ID Finder” for Google's own lookup tool. Leave blank to disable review syncing." - /> -
- -
- -
-
- {settingsError && ( -

- {settingsError} -

- )} - {settingsSaved && !settingsError && ( -

- Saved. -

- )} -
- - -
- {syncMessage &&

{syncMessage}

} -
- -
-

Imported Reviews

- -
- {RATING_FILTERS.map((r) => ( - - ))} -
- setQuery(e.target.value)} - placeholder="Search by author or review text" - className="border-line focus:ring-ink w-full rounded-xl border bg-white px-4 py-2.5 text-sm outline-none focus:ring-2" - /> - - {filtered.length === 0 ? ( -

No reviews match this filter.

- ) : ( -
- {filtered.map((review) => ( - -
-
-

{review.authorName}

-
- - {review.relativeTimeDescription && ( - {review.relativeTimeDescription} - )} -
-
- -
- {review.text &&

{review.text}

} -
- ))} -
- )} -
-
+
+

Google Reviews Sync

+ {googleRating !== null && ( +

+ Google's aggregate: {googleRating.toFixed(1)} from{" "} + {googleRatingCount ?? 0} ratings. +

+ )} + setGooglePlaceId(e.target.value)} + placeholder="ChIJ…" + hint="Used to pull this business's rating and review count from Google. Find it by searching your business on Google Maps, opening Share → Embed a map, and copying the id from the embed code — or search “Google Place ID Finder” for Google's own lookup tool. Leave blank to disable syncing." + /> + {settingsError && ( +

+ {settingsError} +

+ )} + {settingsSaved && !settingsError && ( +

+ Saved. +

+ )} +
+ + +
+ {syncMessage &&

{syncMessage}

} +
); } diff --git a/src/services/reviews/__tests__/google-places.provider.test.ts b/src/services/reviews/__tests__/google-places.provider.test.ts new file mode 100644 index 0000000..576cdaf --- /dev/null +++ b/src/services/reviews/__tests__/google-places.provider.test.ts @@ -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, + ); + }); +}); diff --git a/src/services/reviews/__tests__/review-sync.service.integration.test.ts b/src/services/reviews/__tests__/review-sync.service.integration.test.ts index f01b479..7e65dc5 100644 --- a/src/services/reviews/__tests__/review-sync.service.integration.test.ts +++ b/src/services/reviews/__tests__/review-sync.service.integration.test.ts @@ -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); diff --git a/src/services/reviews/google-places.provider.ts b/src/services/reviews/google-places.provider.ts index e44d373..35fac12 100644 --- a/src/services/reviews/google-places.provider.ts +++ b/src/services/reviews/google-places.provider.ts @@ -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; +export interface PlaceRatingProvider { + fetchPlaceRating(placeId: string): Promise; } 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 { +export class GooglePlaceRatingProvider implements PlaceRatingProvider { + async fetchPlaceRating(placeId: string): Promise { 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, }; } } diff --git a/src/services/reviews/mock-place-rating-provider.ts b/src/services/reviews/mock-place-rating-provider.ts new file mode 100644 index 0000000..8a8753e --- /dev/null +++ b/src/services/reviews/mock-place-rating-provider.ts @@ -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 { + this.fetchCallCount += 1; + return this.result; + } +} diff --git a/src/services/reviews/mock-reviews-provider.ts b/src/services/reviews/mock-reviews-provider.ts deleted file mode 100644 index 2781665..0000000 --- a/src/services/reviews/mock-reviews-provider.ts +++ /dev/null @@ -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 { - this.fetchCallCount += 1; - return this.result; - } -} diff --git a/src/services/reviews/review-management.service.ts b/src/services/reviews/review-management.service.ts deleted file mode 100644 index 100ffa1..0000000 --- a/src/services/reviews/review-management.service.ts +++ /dev/null @@ -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 { - 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 }; -} diff --git a/src/services/reviews/review-sync.service.ts b/src/services/reviews/review-sync.service.ts index 4fcc87f..c9532c2 100644 --- a/src/services/reviews/review-sync.service.ts +++ b/src/services/reviews/review-sync.service.ts @@ -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 { 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 { const businesses = await prisma.business.findMany({ where: { googlePlaceId: { not: null } }, diff --git a/src/services/reviews/reviews.service.ts b/src/services/reviews/reviews.service.ts index 0346a9b..c738426 100644 --- a/src/services/reviews/reviews.service.ts +++ b/src/services/reviews/reviews.service.ts @@ -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 { - 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, }; }