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:
parent
f0d6cdf46c
commit
7515981336
14 changed files with 903 additions and 14 deletions
112
src/app/api/bookings/[bookingId]/review/route.ts
Normal file
112
src/app/api/bookings/[bookingId]/review/route.ts
Normal 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 });
|
||||
}
|
||||
39
src/app/api/carbets/[carbetId]/reviews/route.ts
Normal file
39
src/app/api/carbets/[carbetId]/reviews/route.ts
Normal 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 });
|
||||
}
|
||||
85
src/app/api/reviews/[reviewId]/response/route.ts
Normal file
85
src/app/api/reviews/[reviewId]/response/route.ts
Normal 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 });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue