diff --git a/src/app/api/bookings/[bookingId]/review/route.ts b/src/app/api/bookings/[bookingId]/review/route.ts
new file mode 100644
index 0000000..62fe319
--- /dev/null
+++ b/src/app/api/bookings/[bookingId]/review/route.ts
@@ -0,0 +1,112 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { BookingStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import {
+ REVIEW_COMMENT_MAX,
+ REVIEW_RATING_MAX,
+ REVIEW_RATING_MIN,
+ isValidRating,
+} from "@/lib/reviews";
+
+export const runtime = "nodejs";
+
+type CreateReviewBody = {
+ rating?: number;
+ comment?: string;
+};
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ bookingId: string }> },
+) {
+ const { bookingId } = await params;
+
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
+ }
+
+ let body: CreateReviewBody;
+ try {
+ body = (await request.json()) as CreateReviewBody;
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+
+ const rating = Number(body.rating);
+ if (!isValidRating(rating)) {
+ return NextResponse.json(
+ {
+ error: `La note doit être un entier entre ${REVIEW_RATING_MIN} et ${REVIEW_RATING_MAX}.`,
+ },
+ { status: 400 },
+ );
+ }
+
+ const comment =
+ typeof body.comment === "string" ? body.comment.trim() : "";
+ if (comment.length > REVIEW_COMMENT_MAX) {
+ return NextResponse.json(
+ {
+ error: `Le commentaire est limité à ${REVIEW_COMMENT_MAX} caractères.`,
+ },
+ { status: 400 },
+ );
+ }
+
+ const booking = await prisma.booking.findUnique({
+ where: { id: bookingId },
+ select: {
+ id: true,
+ carbetId: true,
+ tenantId: true,
+ status: true,
+ review: { select: { id: true } },
+ },
+ });
+
+ if (!booking) {
+ return NextResponse.json({ error: "Réservation introuvable." }, { status: 404 });
+ }
+
+ if (booking.tenantId !== session.user.id) {
+ return NextResponse.json(
+ { error: "Seul le locataire peut laisser un avis." },
+ { status: 403 },
+ );
+ }
+
+ if (booking.status !== BookingStatus.COMPLETED) {
+ return NextResponse.json(
+ { error: "Un avis ne peut être laissé qu'après un séjour terminé." },
+ { status: 409 },
+ );
+ }
+
+ if (booking.review) {
+ return NextResponse.json(
+ { error: "Un avis a déjà été déposé pour cette réservation." },
+ { status: 409 },
+ );
+ }
+
+ const review = await prisma.review.create({
+ data: {
+ bookingId: booking.id,
+ carbetId: booking.carbetId,
+ authorId: session.user.id,
+ rating,
+ comment: comment.length > 0 ? comment : null,
+ },
+ select: {
+ id: true,
+ rating: true,
+ comment: true,
+ createdAt: true,
+ },
+ });
+
+ return NextResponse.json({ review }, { status: 201 });
+}
diff --git a/src/app/api/carbets/[carbetId]/reviews/route.ts b/src/app/api/carbets/[carbetId]/reviews/route.ts
new file mode 100644
index 0000000..6146f8f
--- /dev/null
+++ b/src/app/api/carbets/[carbetId]/reviews/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+import { getCarbetReviewStats, listCarbetReviews } from "@/lib/reviews-server";
+import { prisma } from "@/lib/prisma";
+
+export const runtime = "nodejs";
+
+const MAX_LIMIT = 100;
+const DEFAULT_LIMIT = 20;
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ carbetId: string }> },
+) {
+ const { carbetId } = await params;
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: { id: true },
+ });
+ if (!carbet) {
+ return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
+ }
+
+ const limitRaw = request.nextUrl.searchParams.get("limit");
+ const parsedLimit = Number(limitRaw);
+ const limit =
+ Number.isInteger(parsedLimit) && parsedLimit > 0
+ ? Math.min(parsedLimit, MAX_LIMIT)
+ : DEFAULT_LIMIT;
+
+ const [stats, reviews] = await Promise.all([
+ getCarbetReviewStats(carbetId),
+ listCarbetReviews(carbetId, limit),
+ ]);
+
+ return NextResponse.json({ stats, reviews });
+}
diff --git a/src/app/api/reviews/[reviewId]/response/route.ts b/src/app/api/reviews/[reviewId]/response/route.ts
new file mode 100644
index 0000000..362e833
--- /dev/null
+++ b/src/app/api/reviews/[reviewId]/response/route.ts
@@ -0,0 +1,85 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { REVIEW_HOST_RESPONSE_MAX } from "@/lib/reviews";
+
+export const runtime = "nodejs";
+
+type HostResponseBody = {
+ response?: string;
+};
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ reviewId: string }> },
+) {
+ const { reviewId } = await params;
+
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
+ }
+
+ let body: HostResponseBody;
+ try {
+ body = (await request.json()) as HostResponseBody;
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+
+ const response =
+ typeof body.response === "string" ? body.response.trim() : "";
+ if (response.length === 0) {
+ return NextResponse.json(
+ { error: "La réponse est requise." },
+ { status: 400 },
+ );
+ }
+ if (response.length > REVIEW_HOST_RESPONSE_MAX) {
+ return NextResponse.json(
+ {
+ error: `La réponse est limitée à ${REVIEW_HOST_RESPONSE_MAX} caractères.`,
+ },
+ { status: 400 },
+ );
+ }
+
+ const review = await prisma.review.findUnique({
+ where: { id: reviewId },
+ select: {
+ id: true,
+ hostResponse: true,
+ carbet: { select: { ownerId: true } },
+ },
+ });
+
+ if (!review) {
+ return NextResponse.json({ error: "Avis introuvable." }, { status: 404 });
+ }
+
+ const isOwner = review.carbet.ownerId === session.user.id;
+ const isAdmin = session.user.role === UserRole.ADMIN;
+ if (!isOwner && !isAdmin) {
+ return NextResponse.json(
+ { error: "Seul le loueur peut répondre à cet avis." },
+ { status: 403 },
+ );
+ }
+
+ const updated = await prisma.review.update({
+ where: { id: reviewId },
+ data: {
+ hostResponse: response,
+ hostRespondedAt: new Date(),
+ },
+ select: {
+ id: true,
+ hostResponse: true,
+ hostRespondedAt: true,
+ },
+ });
+
+ return NextResponse.json({ review: updated }, { status: 200 });
+}
diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx
index cf0ed79..ae88cad 100644
--- a/src/app/carbets/[slug]/page.tsx
+++ b/src/app/carbets/[slug]/page.tsx
@@ -2,15 +2,19 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
+import { auth } from "@/auth";
import { getPublicCarbet } from "@/lib/carbet-public";
import {
formatCoordinate,
formatPirogueDuration,
truncate,
} from "@/lib/format";
-import { MediaType } from "@/generated/prisma/enums";
+import { MediaType, UserRole } from "@/generated/prisma/enums";
+import { formatAverageRating } from "@/lib/reviews";
import { CarbetGallery } from "../_components/carbet-gallery";
+import { ReviewsSection } from "../_components/reviews-section";
+import { StarRating } from "../_components/star-rating";
type PageProps = {
params: Promise<{ slug: string }>;
@@ -60,12 +64,20 @@ export async function generateMetadata({
export default async function PublicCarbetPage({ params }: PageProps) {
const { slug } = await params;
- const carbet = await getPublicCarbet(slug);
+ const [carbet, session] = await Promise.all([
+ getPublicCarbet(slug),
+ auth(),
+ ]);
if (!carbet) {
notFound();
}
+ const viewerId = session?.user?.id ?? null;
+ const isViewerOwner =
+ viewerId !== null &&
+ (viewerId === carbet.ownerId || session?.user?.role === UserRole.ADMIN);
+
return (
+ {carbet.reviewStats.count > 0 &&
+ carbet.reviewStats.averageRating !== null ? (
+
+
+
+ {formatAverageRating(carbet.reviewStats.averageRating)}
+
+
+ · {carbet.reviewStats.count} avis
+
+
+ ) : null}
@@ -166,6 +190,12 @@ export default async function PublicCarbetPage({ params }: PageProps) {
+
+
);
}
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx
index 0667ccf..feecf82 100644
--- a/src/app/carbets/_components/carbet-card.tsx
+++ b/src/app/carbets/_components/carbet-card.tsx
@@ -2,6 +2,9 @@ import Link from "next/link";
import type { CarbetSearchResult } from "@/lib/carbet-search";
import { formatPirogueDuration, truncate } from "@/lib/format";
+import { formatAverageRating } from "@/lib/reviews";
+
+import { StarRating } from "./star-rating";
export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
const href = `/carbets/${carbet.slug}`;
@@ -34,6 +37,15 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
Fleuve {carbet.river} · {carbet.capacity} voyageur
{carbet.capacity > 1 ? "s" : ""}
+ {carbet.reviewCount > 0 && carbet.averageRating !== null ? (
+
+
+
+ {formatAverageRating(carbet.averageRating)}
+
+ ({carbet.reviewCount})
+
+ ) : null}
{truncate(carbet.description, 180)}
diff --git a/src/app/carbets/_components/host-response-form.tsx b/src/app/carbets/_components/host-response-form.tsx
new file mode 100644
index 0000000..2d24a0b
--- /dev/null
+++ b/src/app/carbets/_components/host-response-form.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { REVIEW_HOST_RESPONSE_MAX } from "@/lib/reviews";
+
+export function HostResponseForm({ reviewId }: { reviewId: string }) {
+ const router = useRouter();
+ const [response, setResponse] = useState("");
+ const [error, setError] = useState(null);
+ const [pending, setPending] = useState(false);
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ setError(null);
+
+ const trimmed = response.trim();
+ if (trimmed.length === 0) {
+ setError("Veuillez saisir une réponse.");
+ return;
+ }
+
+ setPending(true);
+ try {
+ const res = await fetch(`/api/reviews/${reviewId}/response`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ response: trimmed }),
+ });
+
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ setError(data.error ?? "Impossible d'envoyer la réponse.");
+ return;
+ }
+
+ router.refresh();
+ } catch {
+ setError("Erreur réseau, veuillez réessayer.");
+ } finally {
+ setPending(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/carbets/_components/review-form.tsx b/src/app/carbets/_components/review-form.tsx
new file mode 100644
index 0000000..cdff7d5
--- /dev/null
+++ b/src/app/carbets/_components/review-form.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+import {
+ REVIEW_COMMENT_MAX,
+ REVIEW_RATING_MAX,
+ REVIEW_RATING_MIN,
+} from "@/lib/reviews";
+
+type ReviewFormProps = {
+ bookingId: string;
+};
+
+export function ReviewForm({ bookingId }: ReviewFormProps) {
+ const router = useRouter();
+ const [rating, setRating] = useState(0);
+ const [hover, setHover] = useState(0);
+ const [comment, setComment] = useState("");
+ const [error, setError] = useState(null);
+ const [pending, setPending] = useState(false);
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ setError(null);
+
+ if (rating < REVIEW_RATING_MIN || rating > REVIEW_RATING_MAX) {
+ setError("Veuillez choisir une note entre 1 et 5.");
+ return;
+ }
+
+ setPending(true);
+ try {
+ const res = await fetch(`/api/bookings/${bookingId}/review`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rating, comment: comment.trim() }),
+ });
+
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ setError(data.error ?? "Impossible d'envoyer votre avis.");
+ return;
+ }
+
+ router.refresh();
+ } catch {
+ setError("Erreur réseau, veuillez réessayer.");
+ } finally {
+ setPending(false);
+ }
+ }
+
+ const display = hover || rating;
+
+ return (
+
+ );
+}
diff --git a/src/app/carbets/_components/reviews-section.tsx b/src/app/carbets/_components/reviews-section.tsx
new file mode 100644
index 0000000..119691a
--- /dev/null
+++ b/src/app/carbets/_components/reviews-section.tsx
@@ -0,0 +1,104 @@
+import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
+import { formatAverageRating } from "@/lib/reviews";
+
+import { HostResponseForm } from "./host-response-form";
+import { StarRating } from "./star-rating";
+
+type ReviewsSectionProps = {
+ stats: CarbetReviewStats;
+ reviews: CarbetReview[];
+ isOwner: boolean;
+};
+
+const dateFormatter = new Intl.DateTimeFormat("fr-FR", {
+ year: "numeric",
+ month: "long",
+});
+
+const stayFormatter = new Intl.DateTimeFormat("fr-FR", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+});
+
+function formatStay(start: string, end: string): string {
+ const startDate = new Date(start);
+ const endDate = new Date(end);
+ return `${stayFormatter.format(startDate)} → ${stayFormatter.format(endDate)}`;
+}
+
+export function ReviewsSection({ stats, reviews, isOwner }: ReviewsSectionProps) {
+ return (
+
+
+ Avis des voyageurs
+ {stats.count > 0 && stats.averageRating !== null ? (
+
+
+
+ {formatAverageRating(stats.averageRating)}
+
+
+ · {stats.count} avis
+
+
+ ) : (
+ Aucun avis pour le moment.
+ )}
+
+
+ {reviews.length > 0 ? (
+
+ {reviews.map((review) => (
+ -
+
+
+
+ {review.authorFirstName}
+
+
+ {dateFormatter.format(new Date(review.createdAt))} ·{" "}
+ Séjour {formatStay(review.stayStartDate, review.stayEndDate)}
+
+
+
+
+
+ {review.rating}/5
+
+
+
+
+ {review.comment ? (
+
+ {review.comment}
+
+ ) : null}
+
+ {review.hostResponse ? (
+
+
+ Réponse du loueur
+ {review.hostRespondedAt ? (
+
+ · {dateFormatter.format(new Date(review.hostRespondedAt))}
+
+ ) : null}
+
+
+ {review.hostResponse}
+
+
+ ) : isOwner ? (
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/carbets/_components/star-rating.tsx b/src/app/carbets/_components/star-rating.tsx
new file mode 100644
index 0000000..00e631a
--- /dev/null
+++ b/src/app/carbets/_components/star-rating.tsx
@@ -0,0 +1,36 @@
+type StarRatingProps = {
+ value: number;
+ ariaLabel?: string;
+ className?: string;
+};
+
+// Static 5-star display. `value` is a 0–5 rating (decimal allowed for averages).
+// Filled portion is achieved by overlaying gold stars on top of grey stars and
+// clipping the overlay by width — works without client JS so it can render on
+// server components.
+export function StarRating({ value, ariaLabel, className }: StarRatingProps) {
+ const clamped = Math.min(5, Math.max(0, value));
+ const fillPct = (clamped / 5) * 100;
+ const label = ariaLabel ?? `Note ${clamped.toFixed(1).replace(".", ",")} sur 5`;
+
+ return (
+
+
+ ★★★★★
+
+
+ ★★★★★
+
+
+ );
+}
diff --git a/src/app/mes-reservations/page.tsx b/src/app/mes-reservations/page.tsx
new file mode 100644
index 0000000..3859e0a
--- /dev/null
+++ b/src/app/mes-reservations/page.tsx
@@ -0,0 +1,135 @@
+import Link from "next/link";
+
+import { requireAuth } from "@/lib/authorization";
+import { BookingStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { formatAverageRating } from "@/lib/reviews";
+
+import { ReviewForm } from "../carbets/_components/review-form";
+import { StarRating } from "../carbets/_components/star-rating";
+
+export const dynamic = "force-dynamic";
+
+const dateFormatter = new Intl.DateTimeFormat("fr-FR", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+});
+
+const statusLabels: Record = {
+ PENDING: "En attente",
+ CONFIRMED: "Confirmée",
+ CANCELLED: "Annulée",
+ COMPLETED: "Terminée",
+};
+
+export default async function MyBookingsPage() {
+ const session = await requireAuth();
+
+ const bookings = await prisma.booking.findMany({
+ where: { tenantId: session.user.id },
+ orderBy: [{ startDate: "desc" }],
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ status: true,
+ guestCount: true,
+ carbet: { select: { slug: true, title: true, river: true } },
+ review: {
+ select: {
+ id: true,
+ rating: true,
+ comment: true,
+ hostResponse: true,
+ createdAt: true,
+ },
+ },
+ },
+ });
+
+ return (
+
+ Mes réservations
+
+ Retrouvez ici vos séjours passés et à venir. Après un séjour terminé,
+ partagez votre expérience en laissant un avis.
+
+
+ {bookings.length === 0 ? (
+
+ Vous n'avez pas encore de réservation.{" "}
+
+ Découvrez les carbets disponibles
+
+ .
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts
index 5735b6c..afc59e0 100644
--- a/src/lib/carbet-public.ts
+++ b/src/lib/carbet-public.ts
@@ -3,6 +3,11 @@ import { cache } from "react";
import { prisma } from "@/lib/prisma";
import { amenityLabel } from "@/lib/amenities";
import { CarbetStatus, MediaType } from "@/generated/prisma/enums";
+import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
+import {
+ getCarbetReviewStats,
+ listCarbetReviews,
+} from "@/lib/reviews-server";
export type PublicCarbetMedia = {
id: string;
@@ -21,9 +26,12 @@ export type PublicCarbetDetail = {
capacity: number;
latitude: string;
longitude: string;
+ ownerId: string;
ownerFirstName: string;
media: PublicCarbetMedia[];
amenities: { key: string; label: string }[];
+ reviewStats: CarbetReviewStats;
+ reviews: CarbetReview[];
};
// Memoized within a single request so generateMetadata() and the page itself
@@ -43,6 +51,7 @@ export const getPublicCarbet = cache(
capacity: true,
latitude: true,
longitude: true,
+ ownerId: true,
owner: { select: { firstName: true } },
media: {
orderBy: { sortOrder: "asc" },
@@ -56,6 +65,11 @@ export const getPublicCarbet = cache(
if (!carbet) return null;
+ const [reviewStats, reviews] = await Promise.all([
+ getCarbetReviewStats(carbet.id),
+ listCarbetReviews(carbet.id, 20),
+ ]);
+
return {
id: carbet.id,
slug: carbet.slug,
@@ -67,6 +81,7 @@ export const getPublicCarbet = cache(
capacity: carbet.capacity,
latitude: carbet.latitude.toString(),
longitude: carbet.longitude.toString(),
+ ownerId: carbet.ownerId,
ownerFirstName: carbet.owner.firstName,
media: carbet.media.map((m) => ({
id: m.id,
@@ -80,6 +95,8 @@ export const getPublicCarbet = cache(
label: amenityLabel(entry.amenity.key) || entry.amenity.label,
}))
.sort((a, b) => a.label.localeCompare(b.label, "fr")),
+ reviewStats,
+ reviews,
};
},
);
diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts
index d6c2fd8..b463430 100644
--- a/src/lib/carbet-search.ts
+++ b/src/lib/carbet-search.ts
@@ -5,6 +5,7 @@ import {
AvailabilityScope,
CarbetStatus,
} from "@/generated/prisma/enums";
+import { getCarbetReviewStatsMany } from "@/lib/reviews-server";
export type CarbetSearchFilters = {
river?: string;
@@ -73,6 +74,8 @@ export type CarbetSearchResult = {
description: string;
coverUrl: string | null;
mediaCount: number;
+ reviewCount: number;
+ averageRating: number | null;
};
// Build the Prisma where-clause for a public carbet search. A carbet is only
@@ -132,18 +135,28 @@ export async function searchCarbets(
},
});
- return carbets.map((carbet) => ({
- id: carbet.id,
- slug: carbet.slug,
- title: carbet.title,
- river: carbet.river,
- embarkPoint: carbet.embarkPoint,
- pirogueDurationMin: carbet.pirogueDurationMin,
- capacity: carbet.capacity,
- description: carbet.description,
- coverUrl: carbet.media[0]?.s3Url ?? null,
- mediaCount: carbet._count.media,
- }));
+ const statsMap = await getCarbetReviewStatsMany(carbets.map((c) => c.id));
+
+ return carbets.map((carbet) => {
+ const stats = statsMap.get(carbet.id) ?? {
+ count: 0,
+ averageRating: null,
+ };
+ return {
+ id: carbet.id,
+ slug: carbet.slug,
+ title: carbet.title,
+ river: carbet.river,
+ embarkPoint: carbet.embarkPoint,
+ pirogueDurationMin: carbet.pirogueDurationMin,
+ capacity: carbet.capacity,
+ description: carbet.description,
+ coverUrl: carbet.media[0]?.s3Url ?? null,
+ mediaCount: carbet._count.media,
+ reviewCount: stats.count,
+ averageRating: stats.averageRating,
+ };
+ });
}
// Distinct list of rivers across the published catalogue, for filter UI hints.
diff --git a/src/lib/reviews-server.ts b/src/lib/reviews-server.ts
new file mode 100644
index 0000000..2bc4e5a
--- /dev/null
+++ b/src/lib/reviews-server.ts
@@ -0,0 +1,88 @@
+// Server-only: this module pulls in Prisma. Do not import from client
+// components — `@/lib/reviews` (constants/types) is the safe surface.
+
+import { prisma } from "@/lib/prisma";
+
+import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
+
+// Aggregate stats used on cards and detail pages. Returns null average when
+// there are no reviews so the caller can render a neutral "no reviews yet"
+// state rather than a misleading 0.0.
+export async function getCarbetReviewStats(
+ carbetId: string,
+): Promise {
+ const agg = await prisma.review.aggregate({
+ where: { carbetId },
+ _count: { _all: true },
+ _avg: { rating: true },
+ });
+
+ const count = agg._count._all;
+ const avg = agg._avg.rating;
+
+ return {
+ count,
+ averageRating: count > 0 && avg !== null ? Number(avg) : null,
+ };
+}
+
+export async function getCarbetReviewStatsMany(
+ carbetIds: string[],
+): Promise