feat(reviews): avis & notes carbet (SYS-8)

- Lib reviews: constants/types (client-safe) + DB helpers (server-only)
- API POST /api/bookings/[bookingId]/review : avis locataire après séjour COMPLETED
- API POST /api/reviews/[reviewId]/response : réponse loueur
- API GET /api/carbets/[carbetId]/reviews : liste + stats agrégées
- Fiche carbet : note moyenne + nombre d'avis + liste avec réponses loueur
- Carte carbet : étoiles + note moyenne + compteur
- /mes-reservations : formulaire d'avis pour les séjours terminés du locataire
This commit is contained in:
Karbé Frontend 2026-05-30 15:08:55 +00:00
parent f0d6cdf46c
commit 7515981336
14 changed files with 903 additions and 14 deletions

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}