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>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
{business.phone && (
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
)}
|
||||
{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) && (
|
||||
<BookAgainButton appointmentId={appointment.id} guestToken={token} />
|
||||
<BookAgainButton appointmentId={appointment.id} guestToken={token} className="w-full" />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<SettingsPageShell title="Reviews">
|
||||
<ReviewsManager
|
||||
initialGooglePlaceId={business.googlePlaceId ?? ""}
|
||||
initialReviewsMinRating={business.settings?.reviewsMinRating ?? null}
|
||||
initialReviewsExcludeRatingOnly={business.settings?.reviewsExcludeRatingOnly ?? false}
|
||||
googleRating={business.googleRating ? Number(business.googleRating) : null}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,7 +58,7 @@ export default async function BookReviewPage() {
|
||||
|
||||
<main className="mx-auto max-w-2xl space-y-4 px-6 py-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">
|
||||
Business
|
||||
</span>
|
||||
@@ -70,7 +70,7 @@ export default async function BookReviewPage() {
|
||||
</Link>
|
||||
</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>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Button variant="destructive" size="sm" onClick={() => setOpen(true)}>
|
||||
<Button variant="destructive" size="sm" className={className} onClick={() => setOpen(true)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Modal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { Card } from "@/components/ui/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 { cn } from "@/lib/utils/cn";
|
||||
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 { StaffSummary } from "@/types/domain";
|
||||
|
||||
@@ -17,7 +16,6 @@ export interface HomeTabsProps {
|
||||
services: AvailableService[];
|
||||
categories: { id: string; name: string }[];
|
||||
portfolioImages: GalleryImageSummary[];
|
||||
reviews: BusinessReviewSummary;
|
||||
description: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
@@ -30,10 +28,14 @@ export interface HomeTabsProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky Services/Portfolio/Reviews/Details tabs below the Home hero —
|
||||
* Phase 9's Home Screen Redesign. 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
|
||||
* Sticky Services/Portfolio/Details tabs below the Home hero — Phase 9's
|
||||
* Home Screen Redesign (a Reviews tab originally sat between Portfolio and
|
||||
* Details; removed once Google Reviews sync stopped downloading individual
|
||||
* 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.
|
||||
*/
|
||||
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">
|
||||
<TabsTrigger value="services">Services</TabsTrigger>
|
||||
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
|
||||
<TabsTrigger value="reviews">Reviews</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -52,9 +53,6 @@ export function HomeTabs(props: HomeTabsProps) {
|
||||
<TabsContent value="portfolio">
|
||||
<PortfolioTab images={props.portfolioImages} />
|
||||
</TabsContent>
|
||||
<TabsContent value="reviews">
|
||||
<ReviewsTab reviews={props.reviews} />
|
||||
</TabsContent>
|
||||
<TabsContent value="details">
|
||||
<DetailsTab
|
||||
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({
|
||||
description,
|
||||
phone,
|
||||
|
||||
@@ -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<ReviewsActionResult> {
|
||||
export async function updateReviewsSettingsAction(input: { googlePlaceId: string | null }): Promise<ReviewsActionResult> {
|
||||
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<ReviewsActionResult> {
|
||||
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<ReviewsActionResult> {
|
||||
const { businessId, error } = await requireReviewsManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-0.5" aria-label={`${rating} out of 5 stars`}>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cn("h-3.5 w-3.5", i < rating ? "fill-accent-loyalty text-accent-loyalty" : "text-line")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const [isSettingsPending, startSettingsTransition] = 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 = () => {
|
||||
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 (
|
||||
<div className="space-y-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>
|
||||
{googleRating !== null && (
|
||||
<p className="text-muted text-sm">
|
||||
Google's aggregate: <span className="text-ink font-bold">{googleRating.toFixed(1)}</span> from{" "}
|
||||
{googleRatingCount ?? 0} ratings.
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Google Place ID"
|
||||
value={googlePlaceId}
|
||||
onChange={(e) => 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."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Minimum rating shown to customers"
|
||||
value={reviewsMinRating?.toString() ?? ""}
|
||||
onChange={(e) => setReviewsMinRating(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">No minimum</option>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}+ stars
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="border-line flex items-center rounded-xl border p-4">
|
||||
<Switch
|
||||
label="Exclude rating-only reviews"
|
||||
checked={reviewsExcludeRatingOnly}
|
||||
onCheckedChange={setReviewsExcludeRatingOnly}
|
||||
/>
|
||||
</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>
|
||||
<section className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
|
||||
{googleRating !== null && (
|
||||
<p className="text-muted text-sm">
|
||||
Google's aggregate: <span className="text-ink font-bold">{googleRating.toFixed(1)}</span> from{" "}
|
||||
{googleRatingCount ?? 0} ratings.
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Google Place ID"
|
||||
value={googlePlaceId}
|
||||
onChange={(e) => 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 && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GooglePlaceRatingProvider, GooglePlacesProviderError } from "../google-places.provider";
|
||||
|
||||
/**
|
||||
* Covers the Places API (New) request/response contract this provider
|
||||
* depends on — worth a dedicated test since it was silently broken for a
|
||||
* while by calling the deprecated legacy Place Details endpoint (which
|
||||
* returns REQUEST_DENIED on any Cloud project without that legacy API
|
||||
* enabled) instead of this one.
|
||||
*/
|
||||
describe("GooglePlaceRatingProvider (Places API New)", () => {
|
||||
const originalKey = process.env.GOOGLE_PLACES_API_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_PLACES_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.GOOGLE_PLACES_API_KEY = originalKey;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("calls the New Places endpoint with the API key and a rating-only field mask as headers, not a query param", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rating: 4.5, userRatingCount: 10 }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe("https://places.googleapis.com/v1/places/ChIJabc123");
|
||||
expect(url).not.toContain("key=");
|
||||
expect(init.headers["X-Goog-Api-Key"]).toBe("test-key");
|
||||
// No `reviews` in the mask — this provider only ever wants the
|
||||
// aggregate, never individual reviews (see the file header).
|
||||
expect(init.headers["X-Goog-FieldMask"]).toBe("rating,userRatingCount");
|
||||
});
|
||||
|
||||
it("maps a real New-API response shape into the normalized aggregate shape", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rating: 4.7, userRatingCount: 128 }),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
|
||||
|
||||
expect(result).toEqual({ rating: 4.7, userRatingsTotal: 128 });
|
||||
});
|
||||
|
||||
it("returns nulls when Google omits rating/userRatingCount (e.g. a place with no ratings yet)", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }),
|
||||
);
|
||||
|
||||
const result = await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
|
||||
|
||||
expect(result).toEqual({ rating: null, userRatingsTotal: null });
|
||||
});
|
||||
|
||||
it("surfaces the New API's standard error envelope (error.status/message), not the legacy status field", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({
|
||||
error: { code: 403, message: "This API method requires billing to be enabled.", status: "PERMISSION_DENIED" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123")).rejects.toMatchObject({
|
||||
message: expect.stringContaining("PERMISSION_DENIED"),
|
||||
});
|
||||
});
|
||||
|
||||
it("throws GooglePlacesProviderError when no API key is configured", async () => {
|
||||
delete process.env.GOOGLE_PLACES_API_KEY;
|
||||
await expect(new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123")).rejects.toBeInstanceOf(
|
||||
GooglePlacesProviderError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { syncGoogleReviewsForAllBusinesses, syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
||||
import { DbReviewsProvider } from "@/services/reviews/reviews.service";
|
||||
import { MockReviewsProvider } from "@/services/reviews/mock-reviews-provider";
|
||||
import { setReviewHidden } from "@/services/reviews/review-management.service";
|
||||
import { MockPlaceRatingProvider } from "@/services/reviews/mock-place-rating-provider";
|
||||
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
@@ -17,23 +16,23 @@ describe("Google Reviews sync + DB-backed provider (integration)", () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("syncs reviews and the place-level aggregate rating, idempotently on re-sync", async () => {
|
||||
const mock = new MockReviewsProvider();
|
||||
const first = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
expect(first.synced).toBe(2);
|
||||
it("syncs the place-level aggregate rating, idempotently on re-sync — no individual review rows", async () => {
|
||||
const mock = new MockPlaceRatingProvider();
|
||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
|
||||
const businessRow = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
|
||||
expect(Number(businessRow.googleRating)).toBeCloseTo(4.7);
|
||||
expect(businessRow.googleRatingCount).toBe(128);
|
||||
|
||||
const reviews = await prisma.review.findMany({ where: { businessId: business.businessId } });
|
||||
expect(reviews).toHaveLength(2);
|
||||
const reviewCount = await prisma.review.count({ where: { businessId: business.businessId } });
|
||||
expect(reviewCount).toBe(0);
|
||||
|
||||
// Re-sync with the same data — upsert, not duplicate.
|
||||
const second = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
expect(second.synced).toBe(2);
|
||||
const reviewsAfterResync = await prisma.review.findMany({ where: { businessId: business.businessId } });
|
||||
expect(reviewsAfterResync).toHaveLength(2);
|
||||
// Re-sync with updated data — overwrites, doesn't accumulate anything.
|
||||
mock.result = { rating: 4.8, userRatingsTotal: 140 };
|
||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
const resynced = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
|
||||
expect(Number(resynced.googleRating)).toBeCloseTo(4.8);
|
||||
expect(resynced.googleRatingCount).toBe(140);
|
||||
});
|
||||
|
||||
it("only syncs businesses with a configured googlePlaceId", async () => {
|
||||
@@ -42,7 +41,7 @@ describe("Google Reviews sync + DB-backed provider (integration)", () => {
|
||||
await prisma.business.update({ where: { id: business.businessId }, data: { googlePlaceId: "place-configured" } });
|
||||
// otherBusiness deliberately left without a googlePlaceId.
|
||||
|
||||
const mock = new MockReviewsProvider();
|
||||
const mock = new MockPlaceRatingProvider();
|
||||
const results = await syncGoogleReviewsForAllBusinesses(prisma, mock);
|
||||
|
||||
expect(results.map((r) => r.businessId)).toContain(business.businessId);
|
||||
@@ -53,10 +52,9 @@ describe("Google Reviews sync + DB-backed provider (integration)", () => {
|
||||
});
|
||||
|
||||
it("records a provider error without throwing", async () => {
|
||||
const failingProvider = { fetchPlaceReviews: async () => { throw new Error("boom"); } };
|
||||
const failingProvider = { fetchPlaceRating: async () => { throw new Error("boom"); } };
|
||||
const result = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, failingProvider);
|
||||
expect(result.error).toBe("boom");
|
||||
expect(result.synced).toBe(0);
|
||||
});
|
||||
|
||||
describe("DbReviewsProvider (customer-facing reads)", () => {
|
||||
@@ -66,52 +64,22 @@ describe("Google Reviews sync + DB-backed provider (integration)", () => {
|
||||
expect(summary.isDemoData).toBe(true);
|
||||
});
|
||||
|
||||
it("returns real synced reviews once available, honoring isHidden", async () => {
|
||||
const mock = new MockReviewsProvider();
|
||||
it("returns the real synced aggregate once available, with an empty individual-review list", async () => {
|
||||
const mock = new MockPlaceRatingProvider();
|
||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
|
||||
const provider = new DbReviewsProvider(prisma);
|
||||
const before = await provider.getBusinessReviews(business.businessId);
|
||||
expect(before.isDemoData).toBe(false);
|
||||
expect(before.reviews).toHaveLength(2);
|
||||
|
||||
const [firstReview] = await prisma.review.findMany({ where: { businessId: business.businessId } });
|
||||
await setReviewHidden(prisma, { reviewId: firstReview!.id, businessId: business.businessId, isHidden: true });
|
||||
|
||||
const after = await provider.getBusinessReviews(business.businessId);
|
||||
expect(after.reviews).toHaveLength(1);
|
||||
expect(after.reviews.some((r) => r.id === firstReview!.id)).toBe(false);
|
||||
const summary = await provider.getBusinessReviews(business.businessId);
|
||||
expect(summary.isDemoData).toBe(false);
|
||||
expect(summary.averageRating).toBeCloseTo(4.7);
|
||||
expect(summary.totalReviews).toBe(128);
|
||||
expect(summary.reviews).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("honors reviewsMinRating and reviewsExcludeRatingOnly from BusinessSettings", async () => {
|
||||
const mock = new MockReviewsProvider();
|
||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
// MockReviewsProvider's sample: one 5-star review with text, one 3-star review with no text.
|
||||
|
||||
await prisma.businessSettings.upsert({
|
||||
where: { businessId: business.businessId },
|
||||
create: { businessId: business.businessId, reviewsMinRating: 4 },
|
||||
update: { reviewsMinRating: 4 },
|
||||
});
|
||||
|
||||
const provider = new DbReviewsProvider(prisma);
|
||||
const minRatingFiltered = await provider.getBusinessReviews(business.businessId);
|
||||
expect(minRatingFiltered.reviews.every((r) => r.rating >= 4)).toBe(true);
|
||||
expect(minRatingFiltered.reviews).toHaveLength(1);
|
||||
|
||||
await prisma.businessSettings.update({
|
||||
where: { businessId: business.businessId },
|
||||
data: { reviewsMinRating: null, reviewsExcludeRatingOnly: true },
|
||||
});
|
||||
|
||||
const noTextExcluded = await provider.getBusinessReviews(business.businessId);
|
||||
expect(noTextExcluded.reviews.every((r) => r.text.trim().length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("never lets one business's reviews leak into another's summary", async () => {
|
||||
it("never lets one business's aggregate leak into another's summary", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
const mock = new MockReviewsProvider();
|
||||
const mock = new MockPlaceRatingProvider();
|
||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
|
||||
const provider = new DbReviewsProvider(prisma);
|
||||
|
||||
@@ -3,48 +3,37 @@
|
||||
* mirrors src/services/calendar/google-calendar.provider.ts's own
|
||||
* pattern deliberately: a small documented-REST-contract interface, a
|
||||
* plain-`fetch` real implementation, no SDK dependency, and an injectable
|
||||
* mock for tests (see mock-reviews-provider.ts). Never called from a
|
||||
* mock for tests (see mock-place-rating-provider.ts). Never called from a
|
||||
* customer-facing request — only from review-sync.service.ts, itself only
|
||||
* reachable from the /api/cron/sync-google-reviews route.
|
||||
*
|
||||
* Uses the legacy Place Details endpoint (not the newer Places API v1) —
|
||||
* same "documented REST contract, no SDK" choice as the calendar
|
||||
* provider, and it returns exactly the fields this needs (rating,
|
||||
* user_ratings_total, reviews) in one call.
|
||||
* Uses Place Details (New) (places.googleapis.com/v1) — the legacy
|
||||
* maps.googleapis.com/maps/api/place/details endpoint this used to call
|
||||
* returns REQUEST_DENIED on any Cloud project created after Google
|
||||
* deprecated it ("legacy API, which is not enabled for your project" —
|
||||
* see https://developers.google.com/maps/legacy#LegacyApiNotActivatedMapError).
|
||||
* The New API is a different Cloud API product (enable "Places API (New)",
|
||||
* not "Places API", in Google Cloud Console) with a different auth style
|
||||
* (API key as a header, not a query param).
|
||||
*
|
||||
* Known Google-side limitation, not fixable from this codebase: Place
|
||||
* Details only ever returns up to 5 "most relevant" individual reviews
|
||||
* per call, regardless of how many reviews the place actually has. The
|
||||
* place-level aggregate rating/count (googleRating/googleRatingCount) is
|
||||
* accurate; the individual review list is always a small sample.
|
||||
* Deliberately fetches only the place-level aggregate (`rating`,
|
||||
* `userRatingCount`), not individual reviews — Google's Place Details only
|
||||
* ever returns up to 5 "most relevant" reviews per call regardless of how
|
||||
* many the place actually has, with no pagination to get the rest (see
|
||||
* https://developers.google.com/maps/documentation/places/web-service/place-details
|
||||
* — "can return a maximum of five reviews per place"). Given that hard
|
||||
* cap, downloading and displaying a tiny, non-representative review
|
||||
* sample added complexity without adding real value; the aggregate
|
||||
* rating/count alone is accurate and is all this app surfaces.
|
||||
*/
|
||||
|
||||
export interface RawGoogleReview {
|
||||
/**
|
||||
* Google's legacy API has no stable per-review id — derived from
|
||||
* author name + the review's own unix timestamp so a re-sync upserts
|
||||
* the same row instead of duplicating it. Two reviews from the exact
|
||||
* same author in the exact same second would collide; considered
|
||||
* acceptable given Google's own API doesn't expose anything better.
|
||||
*/
|
||||
externalId: string;
|
||||
authorName: string;
|
||||
authorPhotoUrl: string | null;
|
||||
rating: number;
|
||||
text: string | null;
|
||||
relativeTimeDescription: string | null;
|
||||
publishedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface GooglePlaceReviewsResult {
|
||||
reviews: RawGoogleReview[];
|
||||
/** The place's own aggregate rating/count — distinct from (and more accurate than) averaging just the review sample above. */
|
||||
export interface GooglePlaceRatingResult {
|
||||
rating: number | null;
|
||||
userRatingsTotal: number | null;
|
||||
}
|
||||
|
||||
export interface ReviewsFetchProvider {
|
||||
fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult>;
|
||||
export interface PlaceRatingProvider {
|
||||
fetchPlaceRating(placeId: string): Promise<GooglePlaceRatingResult>;
|
||||
}
|
||||
|
||||
export class GooglePlacesProviderError extends Error {
|
||||
@@ -54,63 +43,39 @@ export class GooglePlacesProviderError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface LegacyPlaceDetailsReview {
|
||||
author_name?: string;
|
||||
profile_photo_url?: string;
|
||||
interface PlaceDetailsNewResponse {
|
||||
rating?: number;
|
||||
text?: string;
|
||||
relative_time_description?: string;
|
||||
time?: number;
|
||||
userRatingCount?: number;
|
||||
error?: { code?: number; message?: string; status?: string };
|
||||
}
|
||||
|
||||
interface LegacyPlaceDetailsResponse {
|
||||
status: string;
|
||||
error_message?: string;
|
||||
result?: {
|
||||
rating?: number;
|
||||
user_ratings_total?: number;
|
||||
reviews?: LegacyPlaceDetailsReview[];
|
||||
};
|
||||
}
|
||||
|
||||
export class GooglePlacesReviewsProvider implements ReviewsFetchProvider {
|
||||
async fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult> {
|
||||
export class GooglePlaceRatingProvider implements PlaceRatingProvider {
|
||||
async fetchPlaceRating(placeId: string): Promise<GooglePlaceRatingResult> {
|
||||
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new GooglePlacesProviderError("GOOGLE_PLACES_API_KEY is not configured on this deployment.");
|
||||
}
|
||||
|
||||
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
|
||||
url.searchParams.set("place_id", placeId);
|
||||
url.searchParams.set("fields", "rating,user_ratings_total,reviews");
|
||||
url.searchParams.set("key", apiKey);
|
||||
const response = await fetch(`https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}`, {
|
||||
headers: {
|
||||
"X-Goog-Api-Key": apiKey,
|
||||
// Places API (New) is opt-in per field for billing — omitting this
|
||||
// header fails the request outright, it doesn't just return less.
|
||||
"X-Goog-FieldMask": "rating,userRatingCount",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
const body = (await response.json()) as PlaceDetailsNewResponse;
|
||||
if (!response.ok) {
|
||||
throw new GooglePlacesProviderError(`Google Places API request failed with HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as LegacyPlaceDetailsResponse;
|
||||
if (body.status !== "OK") {
|
||||
throw new GooglePlacesProviderError(
|
||||
`Google Places API returned status ${body.status}${body.error_message ? `: ${body.error_message}` : ""}.`,
|
||||
`Google Places API returned ${body.error?.status ?? response.status}${body.error?.message ? `: ${body.error.message}` : ""}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const reviews: RawGoogleReview[] = (body.result?.reviews ?? []).map((r) => ({
|
||||
externalId: `${r.author_name ?? "anonymous"}-${r.time ?? 0}`,
|
||||
authorName: r.author_name ?? "Anonymous",
|
||||
authorPhotoUrl: r.profile_photo_url ?? null,
|
||||
rating: typeof r.rating === "number" ? r.rating : 0,
|
||||
text: r.text || null,
|
||||
relativeTimeDescription: r.relative_time_description ?? null,
|
||||
publishedAt: typeof r.time === "number" ? new Date(r.time * 1000) : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
reviews,
|
||||
rating: typeof body.result?.rating === "number" ? body.result.rating : null,
|
||||
userRatingsTotal: typeof body.result?.user_ratings_total === "number" ? body.result.user_ratings_total : null,
|
||||
rating: typeof body.rating === "number" ? body.rating : null,
|
||||
userRatingsTotal: typeof body.userRatingCount === "number" ? body.userRatingCount : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { GooglePlaceRatingResult, PlaceRatingProvider } from "./google-places.provider";
|
||||
|
||||
/**
|
||||
* In-memory `PlaceRatingProvider` for tests — never makes a real network
|
||||
* call, same rationale as MockCalendarProvider (src/services/calendar).
|
||||
* `result` is settable per-test; defaults to a small realistic sample.
|
||||
*/
|
||||
export class MockPlaceRatingProvider implements PlaceRatingProvider {
|
||||
result: GooglePlaceRatingResult = {
|
||||
rating: 4.7,
|
||||
userRatingsTotal: 128,
|
||||
};
|
||||
|
||||
fetchCallCount = 0;
|
||||
|
||||
async fetchPlaceRating(): Promise<GooglePlaceRatingResult> {
|
||||
this.fetchCallCount += 1;
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { GooglePlaceReviewsResult, ReviewsFetchProvider } from "./google-places.provider";
|
||||
|
||||
/**
|
||||
* In-memory `ReviewsFetchProvider` for tests — never makes a real network
|
||||
* call, same rationale as MockCalendarProvider (src/services/calendar).
|
||||
* `result` is settable per-test; defaults to a small realistic sample.
|
||||
*/
|
||||
export class MockReviewsProvider implements ReviewsFetchProvider {
|
||||
result: GooglePlaceReviewsResult = {
|
||||
rating: 4.7,
|
||||
userRatingsTotal: 128,
|
||||
reviews: [
|
||||
{
|
||||
externalId: "mock-author-1-1700000000",
|
||||
authorName: "Mock Author One",
|
||||
authorPhotoUrl: null,
|
||||
rating: 5,
|
||||
text: "Great service, highly recommend.",
|
||||
relativeTimeDescription: "a week ago",
|
||||
publishedAt: new Date("2024-01-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
externalId: "mock-author-2-1700000100",
|
||||
authorName: "Mock Author Two",
|
||||
authorPhotoUrl: null,
|
||||
rating: 3,
|
||||
text: null,
|
||||
relativeTimeDescription: "a month ago",
|
||||
publishedAt: new Date("2024-01-02T00:00:00Z"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
fetchCallCount = 0;
|
||||
|
||||
async fetchPlaceReviews(): Promise<GooglePlaceReviewsResult> {
|
||||
this.fetchCallCount += 1;
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Owner-side review moderation — distinct from reviews.service.ts (the
|
||||
* customer-facing, policy-filtered read). This always returns everything,
|
||||
* hidden or not, so the owner can see what they're hiding/unhiding.
|
||||
*/
|
||||
export interface OwnerReviewItem {
|
||||
id: string;
|
||||
authorName: string;
|
||||
authorPhotoUrl: string | null;
|
||||
rating: number;
|
||||
text: string | null;
|
||||
relativeTimeDescription: string | null;
|
||||
publishedAt: Date | null;
|
||||
isHidden: boolean;
|
||||
fetchedAt: Date;
|
||||
}
|
||||
|
||||
export async function listReviewsForOwner(prisma: PrismaClient, businessId: string): Promise<OwnerReviewItem[]> {
|
||||
const reviews = await prisma.review.findMany({
|
||||
where: { businessId },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
});
|
||||
return reviews.map((r) => ({
|
||||
id: r.id,
|
||||
authorName: r.authorName,
|
||||
authorPhotoUrl: r.authorPhotoUrl,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
relativeTimeDescription: r.relativeTimeDescription,
|
||||
publishedAt: r.publishedAt,
|
||||
isHidden: r.isHidden,
|
||||
fetchedAt: r.fetchedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function setReviewHidden(
|
||||
prisma: PrismaClient,
|
||||
params: { reviewId: string; businessId: string; isHidden: boolean },
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await prisma.review.updateMany({
|
||||
where: { id: params.reviewId, businessId: params.businessId },
|
||||
data: { isHidden: params.isHidden },
|
||||
});
|
||||
return { success: result.count > 0 };
|
||||
}
|
||||
@@ -1,76 +1,47 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { GooglePlacesReviewsProvider } from "./google-places.provider";
|
||||
import type { ReviewsFetchProvider } from "./google-places.provider";
|
||||
import { GooglePlaceRatingProvider } from "./google-places.provider";
|
||||
import type { PlaceRatingProvider } from "./google-places.provider";
|
||||
|
||||
/**
|
||||
* The 24h scheduled sync (see /api/cron/sync-google-reviews) — the only
|
||||
* place that ever calls the Google Places API. Everything customer-facing
|
||||
* reads the Review table instead (see reviews.service.ts's DbReviewsProvider),
|
||||
* never Google directly, per the brief's explicit requirement.
|
||||
* place that ever calls the Google Places API. Only syncs the place-level
|
||||
* aggregate rating/count, not individual reviews — see
|
||||
* google-places.provider.ts's header for why. Everything customer-facing
|
||||
* reads Business.googleRating/googleRatingCount instead (see
|
||||
* reviews.service.ts's DbReviewsProvider), never Google directly.
|
||||
*/
|
||||
|
||||
function getDefaultProvider(): ReviewsFetchProvider {
|
||||
return new GooglePlacesReviewsProvider();
|
||||
function getDefaultProvider(): PlaceRatingProvider {
|
||||
return new GooglePlaceRatingProvider();
|
||||
}
|
||||
|
||||
export interface BusinessReviewSyncResult {
|
||||
businessId: string;
|
||||
synced: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** One business's sync — upserts every review Google returned (idempotent via the businessId+externalId unique constraint) and refreshes the place-level aggregate rating/count. */
|
||||
/** One business's sync — refreshes the place-level aggregate rating/count. */
|
||||
export async function syncGoogleReviewsForBusiness(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; placeId: string },
|
||||
provider: ReviewsFetchProvider = getDefaultProvider(),
|
||||
provider: PlaceRatingProvider = getDefaultProvider(),
|
||||
): Promise<BusinessReviewSyncResult> {
|
||||
try {
|
||||
const result = await provider.fetchPlaceReviews(params.placeId);
|
||||
const fetchedAt = new Date();
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.business.update({
|
||||
where: { id: params.businessId },
|
||||
data: { googleRating: result.rating, googleRatingCount: result.userRatingsTotal },
|
||||
}),
|
||||
...result.reviews.map((review) =>
|
||||
prisma.review.upsert({
|
||||
where: { businessId_externalId: { businessId: params.businessId, externalId: review.externalId } },
|
||||
create: {
|
||||
businessId: params.businessId,
|
||||
externalId: review.externalId,
|
||||
authorName: review.authorName,
|
||||
authorPhotoUrl: review.authorPhotoUrl,
|
||||
rating: review.rating,
|
||||
text: review.text,
|
||||
relativeTimeDescription: review.relativeTimeDescription,
|
||||
publishedAt: review.publishedAt,
|
||||
fetchedAt,
|
||||
},
|
||||
update: {
|
||||
authorName: review.authorName,
|
||||
authorPhotoUrl: review.authorPhotoUrl,
|
||||
rating: review.rating,
|
||||
text: review.text,
|
||||
relativeTimeDescription: review.relativeTimeDescription,
|
||||
publishedAt: review.publishedAt,
|
||||
fetchedAt,
|
||||
},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
return { businessId: params.businessId, synced: result.reviews.length };
|
||||
const result = await provider.fetchPlaceRating(params.placeId);
|
||||
await prisma.business.update({
|
||||
where: { id: params.businessId },
|
||||
data: { googleRating: result.rating, googleRatingCount: result.userRatingsTotal },
|
||||
});
|
||||
return { businessId: params.businessId };
|
||||
} catch (error) {
|
||||
return { businessId: params.businessId, synced: 0, error: error instanceof Error ? error.message : "Unknown error" };
|
||||
return { businessId: params.businessId, error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Every business with a configured Google Place ID — businesses without one are silently skipped (review sync is opt-in, see Business.googlePlaceId). */
|
||||
export async function syncGoogleReviewsForAllBusinesses(
|
||||
prisma: PrismaClient,
|
||||
provider: ReviewsFetchProvider = getDefaultProvider(),
|
||||
provider: PlaceRatingProvider = getDefaultProvider(),
|
||||
): Promise<BusinessReviewSyncResult[]> {
|
||||
const businesses = await prisma.business.findMany({
|
||||
where: { googlePlaceId: { not: null } },
|
||||
|
||||
@@ -4,13 +4,15 @@ import type { PrismaClient } from "@/generated/prisma/client";
|
||||
* Reviews behind a provider abstraction, per the original Phase 4 brief —
|
||||
* "a real Google Places/Business Profile integration can implement this
|
||||
* same interface later without any caller (Customer Home, etc.)
|
||||
* changing." Phase 9 is that moment: DbReviewsProvider below reads the
|
||||
* `Review` table (populated by the 24h sync job — see
|
||||
* review-sync.service.ts) instead of calling Google directly, honoring
|
||||
* the owner's visibility policy (Review.isHidden,
|
||||
* BusinessSettings.reviewsMinRating/reviewsExcludeRatingOnly). Falls back
|
||||
* to demo data only when a business has never had any reviews synced at
|
||||
* all, so a fresh install doesn't show an empty Reviews tab.
|
||||
* changing." DbReviewsProvider below reads Business.googleRating/
|
||||
* googleRatingCount (populated by the 24h sync job — see
|
||||
* review-sync.service.ts) instead of calling Google directly. Only the
|
||||
* place-level aggregate is synced, never individual reviews (Google's
|
||||
* Place Details caps those at 5 per place with no way to get the rest —
|
||||
* see google-places.provider.ts's header) — `reviews` below is always
|
||||
* empty for real (non-demo) data. Falls back to demo data only when a
|
||||
* business has never synced at all, so a fresh install doesn't show an
|
||||
* empty Reviews tab.
|
||||
*/
|
||||
|
||||
export interface ReviewItem {
|
||||
@@ -70,53 +72,27 @@ export function createDemoReviewsProvider(): ReviewsProvider {
|
||||
return new DemoReviewsProvider();
|
||||
}
|
||||
|
||||
function averageOf(reviews: { rating: number }[]): number {
|
||||
if (reviews.length === 0) return 0;
|
||||
return reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
|
||||
}
|
||||
|
||||
/** The real, DB-backed provider — see this file's header. Never calls Google; only ever reads what review-sync.service.ts already wrote. */
|
||||
export class DbReviewsProvider implements ReviewsProvider {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
async getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
|
||||
const totalSynced = await this.prisma.review.count({ where: { businessId } });
|
||||
if (totalSynced === 0) {
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { googleRating: true, googleRatingCount: true },
|
||||
});
|
||||
|
||||
// Whether a sync has ever run is signalled by the place-level
|
||||
// aggregate — this is the only thing review-sync.service.ts ever
|
||||
// writes (see this file's header).
|
||||
if (business?.googleRating === null || business?.googleRating === undefined) {
|
||||
return new DemoReviewsProvider().getBusinessReviews(businessId);
|
||||
}
|
||||
|
||||
const [business, settings] = await Promise.all([
|
||||
this.prisma.business.findUnique({ where: { id: businessId }, select: { googleRating: true, googleRatingCount: true } }),
|
||||
this.prisma.businessSettings.findUnique({
|
||||
where: { businessId },
|
||||
select: { reviewsMinRating: true, reviewsExcludeRatingOnly: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const visibleReviews = await this.prisma.review.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
isHidden: false,
|
||||
...(settings?.reviewsMinRating ? { rating: { gte: settings.reviewsMinRating } } : {}),
|
||||
...(settings?.reviewsExcludeRatingOnly ? { text: { not: null } } : {}),
|
||||
},
|
||||
orderBy: { publishedAt: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return {
|
||||
// The place-level aggregate is more accurate than averaging the
|
||||
// (Google-capped-at-5) sample — see google-places.provider.ts's
|
||||
// header for why the two are tracked separately.
|
||||
averageRating: business?.googleRating ? Number(business.googleRating) : averageOf(visibleReviews),
|
||||
totalReviews: business?.googleRatingCount ?? visibleReviews.length,
|
||||
reviews: visibleReviews.map((r) => ({
|
||||
id: r.id,
|
||||
authorName: r.authorName,
|
||||
rating: r.rating,
|
||||
text: r.text ?? "",
|
||||
relativeDate: r.relativeTimeDescription ?? "",
|
||||
})),
|
||||
averageRating: Number(business.googleRating),
|
||||
totalReviews: business.googleRatingCount ?? 0,
|
||||
reviews: [],
|
||||
isDemoData: false,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user