Reviews & Layout
Scaled back google reviews to just get the star rating and number of reviews as google limits the number of reviews to 5. Also removed the reviews tab from homepage. And updated Appointments button layout
This commit is contained in:
18 files changed
+297
-615
No files matched your search
@@ -154,37 +154,34 @@ export default async function AppointmentDetailsPage({
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<a
|
<a
|
||||||
href={`/api/appointments/${appointment.id}/calendar.ics${token ? `?token=${token}` : ""}`}
|
href={`/api/appointments/${appointment.id}/calendar.ics${token ? `?token=${token}` : ""}`}
|
||||||
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line flex w-full items-center justify-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
||||||
</a>
|
</a>
|
||||||
{business.phone && (
|
{business.phone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${business.phone}`}
|
href={`tel:${business.phone}`}
|
||||||
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line flex w-full items-center justify-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Contact Business
|
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Contact Business
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
||||||
<Link
|
<Link
|
||||||
href={`/appointments/${appointment.id}/reschedule${token ? `?token=${token}` : ""}`}
|
href={`/appointments/${appointment.id}/reschedule${token ? `?token=${token}` : ""}`}
|
||||||
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line block w-full border bg-white px-5 py-3 text-center text-sm font-bold"
|
||||||
>
|
>
|
||||||
Reschedule
|
Reschedule
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{CANCELLABLE_STATUSES.has(appointment.status) && (
|
{CANCELLABLE_STATUSES.has(appointment.status) && (
|
||||||
<CancelAppointmentDialog appointmentId={appointment.id} guestToken={token} />
|
<CancelAppointmentDialog appointmentId={appointment.id} guestToken={token} className="w-full" />
|
||||||
)}
|
)}
|
||||||
{REBOOKABLE_STATUSES.has(appointment.status) && (
|
{REBOOKABLE_STATUSES.has(appointment.status) && (
|
||||||
<BookAgainButton appointmentId={appointment.id} guestToken={token} />
|
<BookAgainButton appointmentId={appointment.id} guestToken={token} className="w-full" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ export default async function CustomerHomePage() {
|
|||||||
services={services}
|
services={services}
|
||||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
portfolioImages={portfolioImages}
|
portfolioImages={portfolioImages}
|
||||||
reviews={reviews}
|
|
||||||
description={business.description}
|
description={business.description}
|
||||||
phone={business.phone}
|
phone={business.phone}
|
||||||
email={business.email}
|
email={business.email}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { hasPermission } from "@/lib/auth/permissions";
|
|||||||
import { Permission } from "@/generated/prisma/enums";
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
import { getBusinessProfile } from "@/services/business/business.service";
|
import { getBusinessProfile } from "@/services/business/business.service";
|
||||||
import { listReviewsForOwner } from "@/services/reviews/review-management.service";
|
|
||||||
import { ReviewsManager } from "@/features/owner/components/reviews-manager";
|
import { ReviewsManager } from "@/features/owner/components/reviews-manager";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Reviews" };
|
export const metadata: Metadata = { title: "Reviews" };
|
||||||
@@ -23,28 +22,15 @@ export default async function OwnerReviewsPage() {
|
|||||||
const context = await getActingBusinessContext();
|
const context = await getActingBusinessContext();
|
||||||
if (!context) redirect("/staff");
|
if (!context) redirect("/staff");
|
||||||
|
|
||||||
const [business, reviews] = await Promise.all([
|
const business = await getBusinessProfile(prisma, context.businessId);
|
||||||
getBusinessProfile(prisma, context.businessId),
|
|
||||||
listReviewsForOwner(prisma, context.businessId),
|
|
||||||
]);
|
|
||||||
if (!business) redirect("/owner");
|
if (!business) redirect("/owner");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsPageShell title="Reviews">
|
<SettingsPageShell title="Reviews">
|
||||||
<ReviewsManager
|
<ReviewsManager
|
||||||
initialGooglePlaceId={business.googlePlaceId ?? ""}
|
initialGooglePlaceId={business.googlePlaceId ?? ""}
|
||||||
initialReviewsMinRating={business.settings?.reviewsMinRating ?? null}
|
|
||||||
initialReviewsExcludeRatingOnly={business.settings?.reviewsExcludeRatingOnly ?? false}
|
|
||||||
googleRating={business.googleRating ? Number(business.googleRating) : null}
|
googleRating={business.googleRating ? Number(business.googleRating) : null}
|
||||||
googleRatingCount={business.googleRatingCount}
|
googleRatingCount={business.googleRatingCount}
|
||||||
reviews={reviews.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
authorName: r.authorName,
|
|
||||||
rating: r.rating,
|
|
||||||
text: r.text,
|
|
||||||
relativeTimeDescription: r.relativeTimeDescription,
|
|
||||||
isHidden: r.isHidden,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</SettingsPageShell>
|
</SettingsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ import { syncGoogleReviewsForAllBusinesses } from "@/services/reviews/review-syn
|
|||||||
* that, and the customer app never calls Google directly regardless (see
|
* that, and the customer app never calls Google directly regardless (see
|
||||||
* src/services/reviews/reviews.service.ts).
|
* src/services/reviews/reviews.service.ts).
|
||||||
*
|
*
|
||||||
* Safe to run concurrently with itself: syncGoogleReviewsForBusiness's
|
* Safe to run concurrently with itself: syncGoogleReviewsForBusiness only
|
||||||
* Review upsert is keyed on (businessId, externalId), so an overlapping
|
* overwrites Business.googleRating/googleRatingCount with whatever Google
|
||||||
* call just re-writes the same rows, never duplicates them.
|
* returns, so an overlapping call just re-writes the same two columns,
|
||||||
|
* never duplicates anything.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const secret = process.env.CRON_SECRET;
|
const secret = process.env.CRON_SECRET;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default async function BookReviewPage() {
|
|||||||
|
|
||||||
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
||||||
<Card className="space-y-6 p-6">
|
<Card className="space-y-6 p-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="hidden flex items-start justify-between">
|
||||||
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||||
Business
|
Business
|
||||||
</span>
|
</span>
|
||||||
@@ -70,7 +70,7 @@ export default async function BookReviewPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-line space-y-4 border-t pt-6">
|
<div className="space-y-4 pt-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
--color-ink: #111b15;
|
--color-ink: #111b15;
|
||||||
--color-stone: #f4f6f4;
|
--color-stone: #f4f6f4;
|
||||||
--color-surface: #ffffff;
|
--color-surface: #ffffff;
|
||||||
|
--color-pure-cloud: #f5f6f8;
|
||||||
/* Darkened from the templates' #6c7870 — that value was ~4.2:1 on
|
/* Darkened from the templates' #6c7870 — that value was ~4.2:1 on
|
||||||
--color-stone (this app's default body background), just under WCAG
|
--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
|
AA's 4.5:1 for normal text, and text-muted is used 300+ times across
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { cancelCustomerAppointmentAction } from "../actions/customer-appointment
|
|||||||
export function CancelAppointmentDialog({
|
export function CancelAppointmentDialog({
|
||||||
appointmentId,
|
appointmentId,
|
||||||
guestToken,
|
guestToken,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
appointmentId: string;
|
appointmentId: string;
|
||||||
guestToken?: string;
|
guestToken?: string;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
@@ -47,7 +49,7 @@ export function CancelAppointmentDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="destructive" size="sm" onClick={() => setOpen(true)}>
|
<Button variant="destructive" size="sm" className={className} onClick={() => setOpen(true)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { AtSign, Globe, Mail, Phone, Search, Share2, Star } from "lucide-react";
|
import { AtSign, Globe, Mail, Phone, Search, Share2 } from "lucide-react";
|
||||||
import { BarberCard } from "@/components/ui/barber-card";
|
import { BarberCard } from "@/components/ui/barber-card";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { ServiceCard } from "@/components/ui/service-card";
|
import { ServiceCard } from "@/components/ui/service-card";
|
||||||
@@ -9,7 +9,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { BookServiceButton } from "@/features/booking/components/book-service-button";
|
import { BookServiceButton } from "@/features/booking/components/book-service-button";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
import type { AvailableService } from "@/services/scheduling/availability.service";
|
import type { AvailableService } from "@/services/scheduling/availability.service";
|
||||||
import type { BusinessReviewSummary } from "@/services/reviews/reviews.service";
|
|
||||||
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
|
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
|
||||||
import type { StaffSummary } from "@/types/domain";
|
import type { StaffSummary } from "@/types/domain";
|
||||||
|
|
||||||
@@ -17,7 +16,6 @@ export interface HomeTabsProps {
|
|||||||
services: AvailableService[];
|
services: AvailableService[];
|
||||||
categories: { id: string; name: string }[];
|
categories: { id: string; name: string }[];
|
||||||
portfolioImages: GalleryImageSummary[];
|
portfolioImages: GalleryImageSummary[];
|
||||||
reviews: BusinessReviewSummary;
|
|
||||||
description: string | null;
|
description: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -30,10 +28,14 @@ export interface HomeTabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sticky Services/Portfolio/Reviews/Details tabs below the Home hero —
|
* Sticky Services/Portfolio/Details tabs below the Home hero — Phase 9's
|
||||||
* Phase 9's Home Screen Redesign. All data is pre-fetched server-side by
|
* Home Screen Redesign (a Reviews tab originally sat between Portfolio and
|
||||||
* the Home page and passed in as plain props; tab switching and the
|
* Details; removed once Google Reviews sync stopped downloading individual
|
||||||
* Services search/category filter are pure client-side state over that
|
* reviews — see reviews.service.ts's header — since it could then only
|
||||||
|
* ever show "No reviews yet"; the aggregate rating/count still show in the
|
||||||
|
* hero above these tabs). All data is pre-fetched server-side by the Home
|
||||||
|
* page and passed in as plain props; tab switching and the Services
|
||||||
|
* search/category filter are pure client-side state over that
|
||||||
* already-fetched data, not new network round trips per interaction.
|
* already-fetched data, not new network round trips per interaction.
|
||||||
*/
|
*/
|
||||||
export function HomeTabs(props: HomeTabsProps) {
|
export function HomeTabs(props: HomeTabsProps) {
|
||||||
@@ -42,7 +44,6 @@ export function HomeTabs(props: HomeTabsProps) {
|
|||||||
<TabsList className="bg-white border-t border-b border-line sticky top-0 z-30 -mx-6 rounded-none px-6 py-3">
|
<TabsList className="bg-white border-t border-b border-line sticky top-0 z-30 -mx-6 rounded-none px-6 py-3">
|
||||||
<TabsTrigger value="services">Services</TabsTrigger>
|
<TabsTrigger value="services">Services</TabsTrigger>
|
||||||
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
|
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
|
||||||
<TabsTrigger value="reviews">Reviews</TabsTrigger>
|
|
||||||
<TabsTrigger value="details">Details</TabsTrigger>
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -52,9 +53,6 @@ export function HomeTabs(props: HomeTabsProps) {
|
|||||||
<TabsContent value="portfolio">
|
<TabsContent value="portfolio">
|
||||||
<PortfolioTab images={props.portfolioImages} />
|
<PortfolioTab images={props.portfolioImages} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="reviews">
|
|
||||||
<ReviewsTab reviews={props.reviews} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="details">
|
<TabsContent value="details">
|
||||||
<DetailsTab
|
<DetailsTab
|
||||||
description={props.description}
|
description={props.description}
|
||||||
@@ -229,34 +227,6 @@ function PortfolioTab({ images }: { images: GalleryImageSummary[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewsTab({ reviews }: { reviews: BusinessReviewSummary }) {
|
|
||||||
if (reviews.reviews.length === 0) {
|
|
||||||
return <p className="text-muted py-10 text-center text-sm">No reviews yet.</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{reviews.isDemoData && (
|
|
||||||
<p className="text-muted text-right text-[9px] font-bold tracking-widest uppercase">Demo data</p>
|
|
||||||
)}
|
|
||||||
{reviews.reviews.map((review) => (
|
|
||||||
<Card key={review.id} className="p-4">
|
|
||||||
<div className="mb-1 flex items-center justify-between">
|
|
||||||
<p className="text-sm font-bold">{review.authorName}</p>
|
|
||||||
<span className="text-muted text-[10px]">{review.relativeDate}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-accent-loyalty mb-2 flex gap-0.5">
|
|
||||||
{Array.from({ length: review.rating }, (_, i) => (
|
|
||||||
<Star key={i} className="h-3 w-3 fill-current" aria-hidden="true" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-muted text-xs">{review.text}</p>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailsTab({
|
function DetailsTab({
|
||||||
description,
|
description,
|
||||||
phone,
|
phone,
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { Permission } from "@/generated/prisma/enums";
|
|||||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||||
import { updateBusinessProfile, updateBusinessSettings } from "@/services/business/business.service";
|
import { updateBusinessProfile } from "@/services/business/business.service";
|
||||||
import { setReviewHidden } from "@/services/reviews/review-management.service";
|
|
||||||
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
||||||
|
|
||||||
export interface ReviewsActionResult {
|
export interface ReviewsActionResult {
|
||||||
@@ -33,25 +32,18 @@ async function requireReviewsManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reuses updateBusinessProfile/updateBusinessSettings (the same functions
|
* Reuses updateBusinessProfile (the same function Business Details uses)
|
||||||
* Business Details uses) rather than a parallel update path —
|
* rather than a parallel update path — googlePlaceId lives on Business.
|
||||||
* googlePlaceId lives on Business, the two policy flags on
|
* Deliberately no format regex on googlePlaceId beyond trimming — Google's
|
||||||
* BusinessSettings. Deliberately no format regex on googlePlaceId beyond
|
* own Place ID formats vary (place, postal code, plus-code IDs all look
|
||||||
* trimming — Google's own Place ID formats vary (place, postal code,
|
* different) and this app has no need to validate more than "the owner
|
||||||
* plus-code IDs all look different) and this app has no need to validate
|
* typed something."
|
||||||
* more than "the owner typed something."
|
|
||||||
*
|
*
|
||||||
* Writes an audit log only when the Place ID actually changed — existing
|
* Writes an audit log only when the Place ID actually changed; the next
|
||||||
* imported Review rows are deliberately left as-is when it changes (kept
|
* sync (scheduled or manual, via syncReviewsNowAction) naturally picks up
|
||||||
* for history/audit, per docs/phase-10-summary.md's "Google Place ID"
|
* whatever rating/count the new Place ID has.
|
||||||
* section); the next sync (scheduled or manual, via syncReviewsNowAction)
|
|
||||||
* naturally reconciles what's currently associated with the new Place ID.
|
|
||||||
*/
|
*/
|
||||||
export async function updateReviewsSettingsAction(input: {
|
export async function updateReviewsSettingsAction(input: { googlePlaceId: string | null }): Promise<ReviewsActionResult> {
|
||||||
googlePlaceId: string | null;
|
|
||||||
reviewsMinRating: number | null;
|
|
||||||
reviewsExcludeRatingOnly: boolean;
|
|
||||||
}): Promise<ReviewsActionResult> {
|
|
||||||
const { businessId, userId, error } = await requireReviewsManager();
|
const { businessId, userId, error } = await requireReviewsManager();
|
||||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
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." };
|
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) {
|
if (placeIdChanged) {
|
||||||
await writeAuditLog(prisma, {
|
await writeAuditLog(prisma, {
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
@@ -101,16 +88,7 @@ export async function updateReviewsSettingsAction(input: {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setReviewHiddenAction(reviewId: string, isHidden: boolean): Promise<ReviewsActionResult> {
|
/** 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. */
|
||||||
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. */
|
|
||||||
export async function syncReviewsNowAction(): Promise<ReviewsActionResult> {
|
export async function syncReviewsNowAction(): Promise<ReviewsActionResult> {
|
||||||
const { businessId, error } = await requireReviewsManager();
|
const { businessId, error } = await requireReviewsManager();
|
||||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||||
|
|||||||
@@ -1,91 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { RefreshCw, Star, StarOff } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select } from "@/components/ui/select";
|
import { syncReviewsNowAction, updateReviewsSettingsAction } from "../actions/reviews";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReviewsManagerProps {
|
export interface ReviewsManagerProps {
|
||||||
initialGooglePlaceId: string;
|
initialGooglePlaceId: string;
|
||||||
initialReviewsMinRating: number | null;
|
|
||||||
initialReviewsExcludeRatingOnly: boolean;
|
|
||||||
googleRating: number | null;
|
googleRating: number | null;
|
||||||
googleRatingCount: number | null;
|
googleRatingCount: number | null;
|
||||||
reviews: OwnerReviewRow[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RATING_FILTERS = [null, 5, 4, 3, 2, 1] as const;
|
/**
|
||||||
|
* Just the Google Place ID + aggregate rating/count sync — no individual
|
||||||
function StarRating({ rating }: { rating: number }) {
|
* review import/management. Google's Place Details API only ever returns
|
||||||
return (
|
* up to 5 "most relevant" reviews with no pagination to get the rest (see
|
||||||
<div className="flex items-center gap-0.5" aria-label={`${rating} out of 5 stars`}>
|
* google-places.provider.ts's header), so a review list here could never
|
||||||
{Array.from({ length: 5 }, (_, i) => (
|
* be a real, representative picture — the aggregate rating/count alone is
|
||||||
<Star
|
* accurate and is all this app surfaces.
|
||||||
key={i}
|
*/
|
||||||
className={cn("h-3.5 w-3.5", i < rating ? "fill-accent-loyalty text-accent-loyalty" : "text-line")}
|
export function ReviewsManager({ initialGooglePlaceId, googleRating, googleRatingCount }: ReviewsManagerProps) {
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReviewsManager({
|
|
||||||
initialGooglePlaceId,
|
|
||||||
initialReviewsMinRating,
|
|
||||||
initialReviewsExcludeRatingOnly,
|
|
||||||
googleRating,
|
|
||||||
googleRatingCount,
|
|
||||||
reviews,
|
|
||||||
}: ReviewsManagerProps) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [googlePlaceId, setGooglePlaceId] = useState(initialGooglePlaceId);
|
const [googlePlaceId, setGooglePlaceId] = useState(initialGooglePlaceId);
|
||||||
const [reviewsMinRating, setReviewsMinRating] = useState(initialReviewsMinRating);
|
|
||||||
const [reviewsExcludeRatingOnly, setReviewsExcludeRatingOnly] = useState(initialReviewsExcludeRatingOnly);
|
|
||||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||||
const [isSettingsPending, startSettingsTransition] = useTransition();
|
const [isSettingsPending, startSettingsTransition] = useTransition();
|
||||||
const [isSyncPending, startSyncTransition] = useTransition();
|
const [isSyncPending, startSyncTransition] = useTransition();
|
||||||
|
|
||||||
const [ratingFilter, setRatingFilter] = useState<number | null>(null);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [rows, setRows] = useState(reviews);
|
|
||||||
const [hidingId, setHidingId] = useState<string | null>(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 = () => {
|
const saveSettings = () => {
|
||||||
setSettingsError(null);
|
setSettingsError(null);
|
||||||
setSettingsSaved(false);
|
setSettingsSaved(false);
|
||||||
startSettingsTransition(async () => {
|
startSettingsTransition(async () => {
|
||||||
const result = await updateReviewsSettingsAction({
|
const result = await updateReviewsSettingsAction({ googlePlaceId: googlePlaceId.trim() || null });
|
||||||
googlePlaceId: googlePlaceId.trim() || null,
|
|
||||||
reviewsMinRating,
|
|
||||||
reviewsExcludeRatingOnly,
|
|
||||||
});
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setSettingsError(result.error ?? "Couldn't save review settings.");
|
setSettingsError(result.error ?? "Couldn't save review settings.");
|
||||||
return;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<section className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<section className="rounded-md border-line space-y-4 border bg-white p-6">
|
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
|
||||||
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
|
{googleRating !== null && (
|
||||||
{googleRating !== null && (
|
<p className="text-muted text-sm">
|
||||||
<p className="text-muted text-sm">
|
Google's aggregate: <span className="text-ink font-bold">{googleRating.toFixed(1)}</span> from{" "}
|
||||||
Google's aggregate: <span className="text-ink font-bold">{googleRating.toFixed(1)}</span> from{" "}
|
{googleRatingCount ?? 0} ratings.
|
||||||
{googleRatingCount ?? 0} ratings.
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
<Input
|
||||||
<Input
|
label="Google Place ID"
|
||||||
label="Google Place ID"
|
value={googlePlaceId}
|
||||||
value={googlePlaceId}
|
onChange={(e) => setGooglePlaceId(e.target.value)}
|
||||||
onChange={(e) => setGooglePlaceId(e.target.value)}
|
placeholder="ChIJ…"
|
||||||
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."
|
||||||
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 && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
<Select
|
{settingsError}
|
||||||
label="Minimum rating shown to customers"
|
</p>
|
||||||
value={reviewsMinRating?.toString() ?? ""}
|
)}
|
||||||
onChange={(e) => setReviewsMinRating(e.target.value ? Number(e.target.value) : null)}
|
{settingsSaved && !settingsError && (
|
||||||
>
|
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||||
<option value="">No minimum</option>
|
Saved.
|
||||||
{[1, 2, 3, 4, 5].map((n) => (
|
</p>
|
||||||
<option key={n} value={n}>
|
)}
|
||||||
{n}+ stars
|
<div className="flex flex-wrap gap-3">
|
||||||
</option>
|
<Button isLoading={isSettingsPending} onClick={saveSettings}>
|
||||||
))}
|
Save
|
||||||
</Select>
|
</Button>
|
||||||
<div className="border-line flex items-center rounded-xl border p-4">
|
<Button variant="secondary" isLoading={isSyncPending} onClick={syncNow}>
|
||||||
<Switch
|
<RefreshCw className="h-4 w-4" aria-hidden="true" /> Sync Now
|
||||||
label="Exclude rating-only reviews"
|
</Button>
|
||||||
checked={reviewsExcludeRatingOnly}
|
</div>
|
||||||
onCheckedChange={setReviewsExcludeRatingOnly}
|
{syncMessage && <p className="text-muted text-xs">{syncMessage}</p>}
|
||||||
/>
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{settingsError && (
|
|
||||||
<p role="alert" className="text-danger text-sm font-medium">
|
|
||||||
{settingsError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{settingsSaved && !settingsError && (
|
|
||||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
|
||||||
Saved.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button isLoading={isSettingsPending} onClick={saveSettings}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" isLoading={isSyncPending} onClick={syncNow}>
|
|
||||||
<RefreshCw className="h-4 w-4" aria-hidden="true" /> Sync Now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{syncMessage && <p className="text-muted text-xs">{syncMessage}</p>}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Imported Reviews</h3>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{RATING_FILTERS.map((r) => (
|
|
||||||
<button
|
|
||||||
key={r ?? "all"}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRatingFilter(r)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
|
||||||
ratingFilter === r ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{r ? `${r}★` : "All"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => 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 ? (
|
|
||||||
<p className="text-muted py-6 text-center text-sm">No reviews match this filter.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{filtered.map((review) => (
|
|
||||||
<Card key={review.id} className={cn("space-y-2 p-5", review.isHidden && "opacity-60")}>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold">{review.authorName}</p>
|
|
||||||
<div className="mt-1 flex items-center gap-2">
|
|
||||||
<StarRating rating={review.rating} />
|
|
||||||
{review.relativeTimeDescription && (
|
|
||||||
<span className="text-muted text-xs">{review.relativeTimeDescription}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
isLoading={hidingId === review.id}
|
|
||||||
onClick={() => toggleHidden(review)}
|
|
||||||
>
|
|
||||||
{review.isHidden ? (
|
|
||||||
<>
|
|
||||||
<Star className="h-3.5 w-3.5" aria-hidden="true" /> Unhide
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<StarOff className="h-3.5 w-3.5" aria-hidden="true" /> Hide
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{review.text && <p className="text-muted text-sm">{review.text}</p>}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { GooglePlaceRatingProvider, GooglePlacesProviderError } from "../google-places.provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the Places API (New) request/response contract this provider
|
||||||
|
* depends on — worth a dedicated test since it was silently broken for a
|
||||||
|
* while by calling the deprecated legacy Place Details endpoint (which
|
||||||
|
* returns REQUEST_DENIED on any Cloud project without that legacy API
|
||||||
|
* enabled) instead of this one.
|
||||||
|
*/
|
||||||
|
describe("GooglePlaceRatingProvider (Places API New)", () => {
|
||||||
|
const originalKey = process.env.GOOGLE_PLACES_API_KEY;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.GOOGLE_PLACES_API_KEY = "test-key";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.GOOGLE_PLACES_API_KEY = originalKey;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the New Places endpoint with the API key and a rating-only field mask as headers, not a query param", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ rating: 4.5, userRatingCount: 10 }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0]!;
|
||||||
|
expect(url).toBe("https://places.googleapis.com/v1/places/ChIJabc123");
|
||||||
|
expect(url).not.toContain("key=");
|
||||||
|
expect(init.headers["X-Goog-Api-Key"]).toBe("test-key");
|
||||||
|
// No `reviews` in the mask — this provider only ever wants the
|
||||||
|
// aggregate, never individual reviews (see the file header).
|
||||||
|
expect(init.headers["X-Goog-FieldMask"]).toBe("rating,userRatingCount");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a real New-API response shape into the normalized aggregate shape", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ rating: 4.7, userRatingCount: 128 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
|
||||||
|
|
||||||
|
expect(result).toEqual({ rating: 4.7, userRatingsTotal: 128 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nulls when Google omits rating/userRatingCount (e.g. a place with no ratings yet)", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
|
||||||
|
|
||||||
|
expect(result).toEqual({ rating: null, userRatingsTotal: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the New API's standard error envelope (error.status/message), not the legacy status field", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({
|
||||||
|
error: { code: 403, message: "This API method requires billing to be enabled.", status: "PERMISSION_DENIED" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123")).rejects.toMatchObject({
|
||||||
|
message: expect.stringContaining("PERMISSION_DENIED"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws GooglePlacesProviderError when no API key is configured", async () => {
|
||||||
|
delete process.env.GOOGLE_PLACES_API_KEY;
|
||||||
|
await expect(new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123")).rejects.toBeInstanceOf(
|
||||||
|
GooglePlacesProviderError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { syncGoogleReviewsForAllBusinesses, syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
import { syncGoogleReviewsForAllBusinesses, syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
||||||
import { DbReviewsProvider } from "@/services/reviews/reviews.service";
|
import { DbReviewsProvider } from "@/services/reviews/reviews.service";
|
||||||
import { MockReviewsProvider } from "@/services/reviews/mock-reviews-provider";
|
import { MockPlaceRatingProvider } from "@/services/reviews/mock-place-rating-provider";
|
||||||
import { setReviewHidden } from "@/services/reviews/review-management.service";
|
|
||||||
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
|
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||||
import type { TestBusiness } 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);
|
await cleanupTestBusiness(business);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("syncs reviews and the place-level aggregate rating, idempotently on re-sync", async () => {
|
it("syncs the place-level aggregate rating, idempotently on re-sync — no individual review rows", async () => {
|
||||||
const mock = new MockReviewsProvider();
|
const mock = new MockPlaceRatingProvider();
|
||||||
const first = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||||
expect(first.synced).toBe(2);
|
|
||||||
|
|
||||||
const businessRow = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
|
const businessRow = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
|
||||||
expect(Number(businessRow.googleRating)).toBeCloseTo(4.7);
|
expect(Number(businessRow.googleRating)).toBeCloseTo(4.7);
|
||||||
expect(businessRow.googleRatingCount).toBe(128);
|
expect(businessRow.googleRatingCount).toBe(128);
|
||||||
|
|
||||||
const reviews = await prisma.review.findMany({ where: { businessId: business.businessId } });
|
const reviewCount = await prisma.review.count({ where: { businessId: business.businessId } });
|
||||||
expect(reviews).toHaveLength(2);
|
expect(reviewCount).toBe(0);
|
||||||
|
|
||||||
// Re-sync with the same data — upsert, not duplicate.
|
// Re-sync with updated data — overwrites, doesn't accumulate anything.
|
||||||
const second = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
mock.result = { rating: 4.8, userRatingsTotal: 140 };
|
||||||
expect(second.synced).toBe(2);
|
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||||
const reviewsAfterResync = await prisma.review.findMany({ where: { businessId: business.businessId } });
|
const resynced = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
|
||||||
expect(reviewsAfterResync).toHaveLength(2);
|
expect(Number(resynced.googleRating)).toBeCloseTo(4.8);
|
||||||
|
expect(resynced.googleRatingCount).toBe(140);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only syncs businesses with a configured googlePlaceId", async () => {
|
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" } });
|
await prisma.business.update({ where: { id: business.businessId }, data: { googlePlaceId: "place-configured" } });
|
||||||
// otherBusiness deliberately left without a googlePlaceId.
|
// otherBusiness deliberately left without a googlePlaceId.
|
||||||
|
|
||||||
const mock = new MockReviewsProvider();
|
const mock = new MockPlaceRatingProvider();
|
||||||
const results = await syncGoogleReviewsForAllBusinesses(prisma, mock);
|
const results = await syncGoogleReviewsForAllBusinesses(prisma, mock);
|
||||||
|
|
||||||
expect(results.map((r) => r.businessId)).toContain(business.businessId);
|
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 () => {
|
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);
|
const result = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, failingProvider);
|
||||||
expect(result.error).toBe("boom");
|
expect(result.error).toBe("boom");
|
||||||
expect(result.synced).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("DbReviewsProvider (customer-facing reads)", () => {
|
describe("DbReviewsProvider (customer-facing reads)", () => {
|
||||||
@@ -66,52 +64,22 @@ describe("Google Reviews sync + DB-backed provider (integration)", () => {
|
|||||||
expect(summary.isDemoData).toBe(true);
|
expect(summary.isDemoData).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns real synced reviews once available, honoring isHidden", async () => {
|
it("returns the real synced aggregate once available, with an empty individual-review list", async () => {
|
||||||
const mock = new MockReviewsProvider();
|
const mock = new MockPlaceRatingProvider();
|
||||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||||
|
|
||||||
const provider = new DbReviewsProvider(prisma);
|
const provider = new DbReviewsProvider(prisma);
|
||||||
const before = await provider.getBusinessReviews(business.businessId);
|
const summary = await provider.getBusinessReviews(business.businessId);
|
||||||
expect(before.isDemoData).toBe(false);
|
expect(summary.isDemoData).toBe(false);
|
||||||
expect(before.reviews).toHaveLength(2);
|
expect(summary.averageRating).toBeCloseTo(4.7);
|
||||||
|
expect(summary.totalReviews).toBe(128);
|
||||||
const [firstReview] = await prisma.review.findMany({ where: { businessId: business.businessId } });
|
expect(summary.reviews).toHaveLength(0);
|
||||||
await setReviewHidden(prisma, { reviewId: firstReview!.id, businessId: business.businessId, isHidden: true });
|
|
||||||
|
|
||||||
const after = await provider.getBusinessReviews(business.businessId);
|
|
||||||
expect(after.reviews).toHaveLength(1);
|
|
||||||
expect(after.reviews.some((r) => r.id === firstReview!.id)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("honors reviewsMinRating and reviewsExcludeRatingOnly from BusinessSettings", async () => {
|
it("never lets one business's aggregate leak into another's summary", async () => {
|
||||||
const mock = new MockReviewsProvider();
|
|
||||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
|
||||||
// MockReviewsProvider's sample: one 5-star review with text, one 3-star review with no text.
|
|
||||||
|
|
||||||
await prisma.businessSettings.upsert({
|
|
||||||
where: { businessId: business.businessId },
|
|
||||||
create: { businessId: business.businessId, reviewsMinRating: 4 },
|
|
||||||
update: { reviewsMinRating: 4 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const provider = new DbReviewsProvider(prisma);
|
|
||||||
const minRatingFiltered = await provider.getBusinessReviews(business.businessId);
|
|
||||||
expect(minRatingFiltered.reviews.every((r) => r.rating >= 4)).toBe(true);
|
|
||||||
expect(minRatingFiltered.reviews).toHaveLength(1);
|
|
||||||
|
|
||||||
await prisma.businessSettings.update({
|
|
||||||
where: { businessId: business.businessId },
|
|
||||||
data: { reviewsMinRating: null, reviewsExcludeRatingOnly: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const noTextExcluded = await provider.getBusinessReviews(business.businessId);
|
|
||||||
expect(noTextExcluded.reviews.every((r) => r.text.trim().length > 0)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never lets one business's reviews leak into another's summary", async () => {
|
|
||||||
const otherBusiness = await createTestBusiness();
|
const otherBusiness = await createTestBusiness();
|
||||||
try {
|
try {
|
||||||
const mock = new MockReviewsProvider();
|
const mock = new MockPlaceRatingProvider();
|
||||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||||
|
|
||||||
const provider = new DbReviewsProvider(prisma);
|
const provider = new DbReviewsProvider(prisma);
|
||||||
|
|||||||
@@ -3,48 +3,37 @@
|
|||||||
* mirrors src/services/calendar/google-calendar.provider.ts's own
|
* mirrors src/services/calendar/google-calendar.provider.ts's own
|
||||||
* pattern deliberately: a small documented-REST-contract interface, a
|
* pattern deliberately: a small documented-REST-contract interface, a
|
||||||
* plain-`fetch` real implementation, no SDK dependency, and an injectable
|
* 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
|
* customer-facing request — only from review-sync.service.ts, itself only
|
||||||
* reachable from the /api/cron/sync-google-reviews route.
|
* reachable from the /api/cron/sync-google-reviews route.
|
||||||
*
|
*
|
||||||
* Uses the legacy Place Details endpoint (not the newer Places API v1) —
|
* Uses Place Details (New) (places.googleapis.com/v1) — the legacy
|
||||||
* same "documented REST contract, no SDK" choice as the calendar
|
* maps.googleapis.com/maps/api/place/details endpoint this used to call
|
||||||
* provider, and it returns exactly the fields this needs (rating,
|
* returns REQUEST_DENIED on any Cloud project created after Google
|
||||||
* user_ratings_total, reviews) in one call.
|
* 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
|
* Deliberately fetches only the place-level aggregate (`rating`,
|
||||||
* Details only ever returns up to 5 "most relevant" individual reviews
|
* `userRatingCount`), not individual reviews — Google's Place Details only
|
||||||
* per call, regardless of how many reviews the place actually has. The
|
* ever returns up to 5 "most relevant" reviews per call regardless of how
|
||||||
* place-level aggregate rating/count (googleRating/googleRatingCount) is
|
* many the place actually has, with no pagination to get the rest (see
|
||||||
* accurate; the individual review list is always a small sample.
|
* 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 {
|
export interface GooglePlaceRatingResult {
|
||||||
/**
|
|
||||||
* Google's legacy API has no stable per-review id — derived from
|
|
||||||
* author name + the review's own unix timestamp so a re-sync upserts
|
|
||||||
* the same row instead of duplicating it. Two reviews from the exact
|
|
||||||
* same author in the exact same second would collide; considered
|
|
||||||
* acceptable given Google's own API doesn't expose anything better.
|
|
||||||
*/
|
|
||||||
externalId: string;
|
|
||||||
authorName: string;
|
|
||||||
authorPhotoUrl: string | null;
|
|
||||||
rating: number;
|
|
||||||
text: string | null;
|
|
||||||
relativeTimeDescription: string | null;
|
|
||||||
publishedAt: Date | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GooglePlaceReviewsResult {
|
|
||||||
reviews: RawGoogleReview[];
|
|
||||||
/** The place's own aggregate rating/count — distinct from (and more accurate than) averaging just the review sample above. */
|
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
userRatingsTotal: number | null;
|
userRatingsTotal: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewsFetchProvider {
|
export interface PlaceRatingProvider {
|
||||||
fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult>;
|
fetchPlaceRating(placeId: string): Promise<GooglePlaceRatingResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GooglePlacesProviderError extends Error {
|
export class GooglePlacesProviderError extends Error {
|
||||||
@@ -54,63 +43,39 @@ export class GooglePlacesProviderError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LegacyPlaceDetailsReview {
|
interface PlaceDetailsNewResponse {
|
||||||
author_name?: string;
|
|
||||||
profile_photo_url?: string;
|
|
||||||
rating?: number;
|
rating?: number;
|
||||||
text?: string;
|
userRatingCount?: number;
|
||||||
relative_time_description?: string;
|
error?: { code?: number; message?: string; status?: string };
|
||||||
time?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LegacyPlaceDetailsResponse {
|
export class GooglePlaceRatingProvider implements PlaceRatingProvider {
|
||||||
status: string;
|
async fetchPlaceRating(placeId: string): Promise<GooglePlaceRatingResult> {
|
||||||
error_message?: string;
|
|
||||||
result?: {
|
|
||||||
rating?: number;
|
|
||||||
user_ratings_total?: number;
|
|
||||||
reviews?: LegacyPlaceDetailsReview[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GooglePlacesReviewsProvider implements ReviewsFetchProvider {
|
|
||||||
async fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult> {
|
|
||||||
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
|
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new GooglePlacesProviderError("GOOGLE_PLACES_API_KEY is not configured on this deployment.");
|
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");
|
const response = await fetch(`https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}`, {
|
||||||
url.searchParams.set("place_id", placeId);
|
headers: {
|
||||||
url.searchParams.set("fields", "rating,user_ratings_total,reviews");
|
"X-Goog-Api-Key": apiKey,
|
||||||
url.searchParams.set("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) {
|
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(
|
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 {
|
return {
|
||||||
reviews,
|
rating: typeof body.rating === "number" ? body.rating : null,
|
||||||
rating: typeof body.result?.rating === "number" ? body.result.rating : null,
|
userRatingsTotal: typeof body.userRatingCount === "number" ? body.userRatingCount : null,
|
||||||
userRatingsTotal: typeof body.result?.user_ratings_total === "number" ? body.result.user_ratings_total : null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { GooglePlaceRatingResult, PlaceRatingProvider } from "./google-places.provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory `PlaceRatingProvider` for tests — never makes a real network
|
||||||
|
* call, same rationale as MockCalendarProvider (src/services/calendar).
|
||||||
|
* `result` is settable per-test; defaults to a small realistic sample.
|
||||||
|
*/
|
||||||
|
export class MockPlaceRatingProvider implements PlaceRatingProvider {
|
||||||
|
result: GooglePlaceRatingResult = {
|
||||||
|
rating: 4.7,
|
||||||
|
userRatingsTotal: 128,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCallCount = 0;
|
||||||
|
|
||||||
|
async fetchPlaceRating(): Promise<GooglePlaceRatingResult> {
|
||||||
|
this.fetchCallCount += 1;
|
||||||
|
return this.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { GooglePlaceReviewsResult, ReviewsFetchProvider } from "./google-places.provider";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory `ReviewsFetchProvider` for tests — never makes a real network
|
|
||||||
* call, same rationale as MockCalendarProvider (src/services/calendar).
|
|
||||||
* `result` is settable per-test; defaults to a small realistic sample.
|
|
||||||
*/
|
|
||||||
export class MockReviewsProvider implements ReviewsFetchProvider {
|
|
||||||
result: GooglePlaceReviewsResult = {
|
|
||||||
rating: 4.7,
|
|
||||||
userRatingsTotal: 128,
|
|
||||||
reviews: [
|
|
||||||
{
|
|
||||||
externalId: "mock-author-1-1700000000",
|
|
||||||
authorName: "Mock Author One",
|
|
||||||
authorPhotoUrl: null,
|
|
||||||
rating: 5,
|
|
||||||
text: "Great service, highly recommend.",
|
|
||||||
relativeTimeDescription: "a week ago",
|
|
||||||
publishedAt: new Date("2024-01-01T00:00:00Z"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
externalId: "mock-author-2-1700000100",
|
|
||||||
authorName: "Mock Author Two",
|
|
||||||
authorPhotoUrl: null,
|
|
||||||
rating: 3,
|
|
||||||
text: null,
|
|
||||||
relativeTimeDescription: "a month ago",
|
|
||||||
publishedAt: new Date("2024-01-02T00:00:00Z"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchCallCount = 0;
|
|
||||||
|
|
||||||
async fetchPlaceReviews(): Promise<GooglePlaceReviewsResult> {
|
|
||||||
this.fetchCallCount += 1;
|
|
||||||
return this.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import type { PrismaClient } from "@/generated/prisma/client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owner-side review moderation — distinct from reviews.service.ts (the
|
|
||||||
* customer-facing, policy-filtered read). This always returns everything,
|
|
||||||
* hidden or not, so the owner can see what they're hiding/unhiding.
|
|
||||||
*/
|
|
||||||
export interface OwnerReviewItem {
|
|
||||||
id: string;
|
|
||||||
authorName: string;
|
|
||||||
authorPhotoUrl: string | null;
|
|
||||||
rating: number;
|
|
||||||
text: string | null;
|
|
||||||
relativeTimeDescription: string | null;
|
|
||||||
publishedAt: Date | null;
|
|
||||||
isHidden: boolean;
|
|
||||||
fetchedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listReviewsForOwner(prisma: PrismaClient, businessId: string): Promise<OwnerReviewItem[]> {
|
|
||||||
const reviews = await prisma.review.findMany({
|
|
||||||
where: { businessId },
|
|
||||||
orderBy: { publishedAt: "desc" },
|
|
||||||
});
|
|
||||||
return reviews.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
authorName: r.authorName,
|
|
||||||
authorPhotoUrl: r.authorPhotoUrl,
|
|
||||||
rating: r.rating,
|
|
||||||
text: r.text,
|
|
||||||
relativeTimeDescription: r.relativeTimeDescription,
|
|
||||||
publishedAt: r.publishedAt,
|
|
||||||
isHidden: r.isHidden,
|
|
||||||
fetchedAt: r.fetchedAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setReviewHidden(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
params: { reviewId: string; businessId: string; isHidden: boolean },
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
const result = await prisma.review.updateMany({
|
|
||||||
where: { id: params.reviewId, businessId: params.businessId },
|
|
||||||
data: { isHidden: params.isHidden },
|
|
||||||
});
|
|
||||||
return { success: result.count > 0 };
|
|
||||||
}
|
|
||||||
@@ -1,76 +1,47 @@
|
|||||||
import type { PrismaClient } from "@/generated/prisma/client";
|
import type { PrismaClient } from "@/generated/prisma/client";
|
||||||
import { GooglePlacesReviewsProvider } from "./google-places.provider";
|
import { GooglePlaceRatingProvider } from "./google-places.provider";
|
||||||
import type { ReviewsFetchProvider } from "./google-places.provider";
|
import type { PlaceRatingProvider } from "./google-places.provider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 24h scheduled sync (see /api/cron/sync-google-reviews) — the only
|
* The 24h scheduled sync (see /api/cron/sync-google-reviews) — the only
|
||||||
* place that ever calls the Google Places API. Everything customer-facing
|
* place that ever calls the Google Places API. Only syncs the place-level
|
||||||
* reads the Review table instead (see reviews.service.ts's DbReviewsProvider),
|
* aggregate rating/count, not individual reviews — see
|
||||||
* never Google directly, per the brief's explicit requirement.
|
* 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 {
|
function getDefaultProvider(): PlaceRatingProvider {
|
||||||
return new GooglePlacesReviewsProvider();
|
return new GooglePlaceRatingProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BusinessReviewSyncResult {
|
export interface BusinessReviewSyncResult {
|
||||||
businessId: string;
|
businessId: string;
|
||||||
synced: number;
|
|
||||||
error?: string;
|
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(
|
export async function syncGoogleReviewsForBusiness(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
params: { businessId: string; placeId: string },
|
params: { businessId: string; placeId: string },
|
||||||
provider: ReviewsFetchProvider = getDefaultProvider(),
|
provider: PlaceRatingProvider = getDefaultProvider(),
|
||||||
): Promise<BusinessReviewSyncResult> {
|
): Promise<BusinessReviewSyncResult> {
|
||||||
try {
|
try {
|
||||||
const result = await provider.fetchPlaceReviews(params.placeId);
|
const result = await provider.fetchPlaceRating(params.placeId);
|
||||||
const fetchedAt = new Date();
|
await prisma.business.update({
|
||||||
|
where: { id: params.businessId },
|
||||||
await prisma.$transaction([
|
data: { googleRating: result.rating, googleRatingCount: result.userRatingsTotal },
|
||||||
prisma.business.update({
|
});
|
||||||
where: { id: params.businessId },
|
return { businessId: params.businessId };
|
||||||
data: { googleRating: result.rating, googleRatingCount: result.userRatingsTotal },
|
|
||||||
}),
|
|
||||||
...result.reviews.map((review) =>
|
|
||||||
prisma.review.upsert({
|
|
||||||
where: { businessId_externalId: { businessId: params.businessId, externalId: review.externalId } },
|
|
||||||
create: {
|
|
||||||
businessId: params.businessId,
|
|
||||||
externalId: review.externalId,
|
|
||||||
authorName: review.authorName,
|
|
||||||
authorPhotoUrl: review.authorPhotoUrl,
|
|
||||||
rating: review.rating,
|
|
||||||
text: review.text,
|
|
||||||
relativeTimeDescription: review.relativeTimeDescription,
|
|
||||||
publishedAt: review.publishedAt,
|
|
||||||
fetchedAt,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
authorName: review.authorName,
|
|
||||||
authorPhotoUrl: review.authorPhotoUrl,
|
|
||||||
rating: review.rating,
|
|
||||||
text: review.text,
|
|
||||||
relativeTimeDescription: review.relativeTimeDescription,
|
|
||||||
publishedAt: review.publishedAt,
|
|
||||||
fetchedAt,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { businessId: params.businessId, synced: result.reviews.length };
|
|
||||||
} catch (error) {
|
} 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). */
|
/** 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(
|
export async function syncGoogleReviewsForAllBusinesses(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
provider: ReviewsFetchProvider = getDefaultProvider(),
|
provider: PlaceRatingProvider = getDefaultProvider(),
|
||||||
): Promise<BusinessReviewSyncResult[]> {
|
): Promise<BusinessReviewSyncResult[]> {
|
||||||
const businesses = await prisma.business.findMany({
|
const businesses = await prisma.business.findMany({
|
||||||
where: { googlePlaceId: { not: null } },
|
where: { googlePlaceId: { not: null } },
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import type { PrismaClient } from "@/generated/prisma/client";
|
|||||||
* Reviews behind a provider abstraction, per the original Phase 4 brief —
|
* Reviews behind a provider abstraction, per the original Phase 4 brief —
|
||||||
* "a real Google Places/Business Profile integration can implement this
|
* "a real Google Places/Business Profile integration can implement this
|
||||||
* same interface later without any caller (Customer Home, etc.)
|
* same interface later without any caller (Customer Home, etc.)
|
||||||
* changing." Phase 9 is that moment: DbReviewsProvider below reads the
|
* changing." DbReviewsProvider below reads Business.googleRating/
|
||||||
* `Review` table (populated by the 24h sync job — see
|
* googleRatingCount (populated by the 24h sync job — see
|
||||||
* review-sync.service.ts) instead of calling Google directly, honoring
|
* review-sync.service.ts) instead of calling Google directly. Only the
|
||||||
* the owner's visibility policy (Review.isHidden,
|
* place-level aggregate is synced, never individual reviews (Google's
|
||||||
* BusinessSettings.reviewsMinRating/reviewsExcludeRatingOnly). Falls back
|
* Place Details caps those at 5 per place with no way to get the rest —
|
||||||
* to demo data only when a business has never had any reviews synced at
|
* see google-places.provider.ts's header) — `reviews` below is always
|
||||||
* all, so a fresh install doesn't show an empty Reviews tab.
|
* 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 {
|
export interface ReviewItem {
|
||||||
@@ -70,53 +72,27 @@ export function createDemoReviewsProvider(): ReviewsProvider {
|
|||||||
return new DemoReviewsProvider();
|
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. */
|
/** 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 {
|
export class DbReviewsProvider implements ReviewsProvider {
|
||||||
constructor(private prisma: PrismaClient) {}
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
|
||||||
async getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
|
async getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
|
||||||
const totalSynced = await this.prisma.review.count({ where: { businessId } });
|
const business = await this.prisma.business.findUnique({
|
||||||
if (totalSynced === 0) {
|
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);
|
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 {
|
return {
|
||||||
// The place-level aggregate is more accurate than averaging the
|
averageRating: Number(business.googleRating),
|
||||||
// (Google-capped-at-5) sample — see google-places.provider.ts's
|
totalReviews: business.googleRatingCount ?? 0,
|
||||||
// header for why the two are tracked separately.
|
reviews: [],
|
||||||
averageRating: business?.googleRating ? Number(business.googleRating) : averageOf(visibleReviews),
|
|
||||||
totalReviews: business?.googleRatingCount ?? visibleReviews.length,
|
|
||||||
reviews: visibleReviews.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
authorName: r.authorName,
|
|
||||||
rating: r.rating,
|
|
||||||
text: r.text ?? "",
|
|
||||||
relativeDate: r.relativeTimeDescription ?? "",
|
|
||||||
})),
|
|
||||||
isDemoData: false,
|
isDemoData: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user