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, }; }