From 0de034022a2c734cf8f3d9c2a5414cc892ecfd0b Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 14:42:29 +0000 Subject: [PATCH 01/79] =?UTF-8?q?feat(booking):=20API=20r=C3=A9servation?= =?UTF-8?q?=20+=20availability=20+=20lib=20m=C3=A9tier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Récupéré du workspace Backend (3 fichiers, 406 lignes) : - src/lib/booking.ts : logique métier réservation - src/app/api/bookings/route.ts : POST/GET bookings - src/app/api/carbets/[carbetId]/availability/route.ts : calendrier dispo Le schéma Booking/Availability était déjà dans main. --- src/app/api/bookings/route.ts | 211 ++++++++++++++++++ .../carbets/[carbetId]/availability/route.ts | 145 ++++++++++++ src/lib/booking.ts | 50 +++++ 3 files changed, 406 insertions(+) create mode 100644 src/app/api/bookings/route.ts create mode 100644 src/app/api/carbets/[carbetId]/availability/route.ts create mode 100644 src/lib/booking.ts diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts new file mode 100644 index 0000000..11f94ad --- /dev/null +++ b/src/app/api/bookings/route.ts @@ -0,0 +1,211 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { + AvailabilityScope, + BookingStatus, + CarbetStatus, + UserRole, +} from "@/generated/prisma/enums"; +import { + enumerateUtcDays, + hasOverlap, + isPublicAllowedByDefaultPolicy, + isCeUserRole, + normalizeUtcDayStart, + parseIsoDate, +} from "@/lib/booking"; +import { prisma } from "@/lib/prisma"; + +export const runtime = "nodejs"; + +type CreateBookingBody = { + carbetId?: string; + startDate?: string; + endDate?: string; + guestCount?: number; +}; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié." }, { status: 401 }); + } + + let body: CreateBookingBody; + try { + body = (await request.json()) as CreateBookingBody; + } catch { + return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 }); + } + + if (!body.carbetId) { + return NextResponse.json({ error: "carbetId requis." }, { status: 400 }); + } + + const startDateRaw = parseIsoDate(body.startDate); + const endDateRaw = parseIsoDate(body.endDate); + const guestCount = Number(body.guestCount); + + if (!startDateRaw || !endDateRaw) { + return NextResponse.json( + { error: "startDate et endDate valides sont requis." }, + { status: 400 }, + ); + } + + const startDate = normalizeUtcDayStart(startDateRaw); + const endDate = normalizeUtcDayStart(endDateRaw); + + if (endDate <= startDate) { + return NextResponse.json( + { error: "La date de fin doit être après la date de début." }, + { status: 400 }, + ); + } + + if (!Number.isInteger(guestCount) || guestCount <= 0) { + return NextResponse.json( + { error: "guestCount doit être un entier strictement positif." }, + { status: 400 }, + ); + } + + const carbet = await prisma.carbet.findUnique({ + where: { id: body.carbetId }, + select: { + id: true, + ownerId: true, + capacity: true, + status: true, + }, + }); + + if (!carbet) { + return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 }); + } + + const isManager = + session.user.role === UserRole.ADMIN || session.user.id === carbet.ownerId; + + if (!isManager && carbet.status !== CarbetStatus.PUBLISHED) { + return NextResponse.json({ error: "Carbet indisponible." }, { status: 404 }); + } + + if (guestCount > carbet.capacity) { + return NextResponse.json( + { error: `Capacité max dépassée (${carbet.capacity}).` }, + { status: 400 }, + ); + } + + const [overlappingBookings, availabilities] = await Promise.all([ + prisma.booking.findMany({ + where: { + carbetId: carbet.id, + status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] }, + startDate: { lt: endDate }, + endDate: { gt: startDate }, + }, + select: { + id: true, + startDate: true, + endDate: true, + }, + }), + prisma.availability.findMany({ + where: { + carbetId: carbet.id, + startDate: { lt: endDate }, + endDate: { gt: startDate }, + }, + select: { + id: true, + startDate: true, + endDate: true, + isAvailable: true, + scope: true, + }, + }), + ]); + + if (overlappingBookings.length > 0) { + return NextResponse.json( + { error: "Ce créneau est déjà réservé." }, + { status: 409 }, + ); + } + + const ceAccess = isCeUserRole(session.user.role); + const days = enumerateUtcDays(startDate, endDate); + + for (const day of days) { + const nextDay = new Date(day); + nextDay.setUTCDate(nextDay.getUTCDate() + 1); + + const coveredSlots = availabilities.filter((a) => + hasOverlap(day, nextDay, a.startDate, a.endDate), + ); + + if (coveredSlots.length === 0) { + const defaultAllowed = isManager || ceAccess || isPublicAllowedByDefaultPolicy(day); + if (defaultAllowed) { + continue; + } + + return NextResponse.json( + { + error: `Créneau non réservable le ${day.toISOString().slice(0, 10)} (week-end réservé CE).`, + }, + { status: 409 }, + ); + } + + const allowedSlot = coveredSlots.find((slot) => { + if (!slot.isAvailable) { + return false; + } + + if (slot.scope === AvailabilityScope.CE_ONLY && !ceAccess && !isManager) { + return false; + } + + return true; + }); + + if (!allowedSlot) { + return NextResponse.json( + { + error: `Créneau non réservable le ${day.toISOString().slice(0, 10)} (restriction CE ou blocage).`, + }, + { status: 409 }, + ); + } + } + + const booking = await prisma.booking.create({ + data: { + carbetId: carbet.id, + tenantId: session.user.id, + startDate, + endDate, + guestCount, + status: BookingStatus.PENDING, + amount: 0, + currency: "EUR", + }, + select: { + id: true, + carbetId: true, + tenantId: true, + startDate: true, + endDate: true, + guestCount: true, + status: true, + paymentStatus: true, + createdAt: true, + }, + }); + + return NextResponse.json({ booking }, { status: 201 }); +} diff --git a/src/app/api/carbets/[carbetId]/availability/route.ts b/src/app/api/carbets/[carbetId]/availability/route.ts new file mode 100644 index 0000000..1840d0a --- /dev/null +++ b/src/app/api/carbets/[carbetId]/availability/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +import { auth } from "@/auth"; +import { UserRole, BookingStatus, CarbetStatus } from "@/generated/prisma/enums"; +import { + enumerateUtcDays, + hasOverlap, + isPublicAllowedByDefaultPolicy, + isCeUserRole, + normalizeUtcDayStart, + parseIsoDate, +} from "@/lib/booking"; +import { prisma } from "@/lib/prisma"; + +export const runtime = "nodejs"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ carbetId: string }> }, +) { + const { carbetId } = await params; + const session = await auth(); + + const from = parseIsoDate(request.nextUrl.searchParams.get("from")); + const to = parseIsoDate(request.nextUrl.searchParams.get("to")); + + if (!from || !to) { + return NextResponse.json( + { error: "Paramètres from et to (ISO date) requis." }, + { status: 400 }, + ); + } + + const startDate = normalizeUtcDayStart(from); + const endDate = normalizeUtcDayStart(to); + + if (endDate <= startDate) { + return NextResponse.json( + { error: "La date de fin doit être après la date de début." }, + { status: 400 }, + ); + } + + const carbet = await prisma.carbet.findUnique({ + where: { id: carbetId }, + select: { id: true, ownerId: true, status: true }, + }); + + if (!carbet) { + return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 }); + } + + const isManager = + session?.user?.id && + (session.user.role === UserRole.ADMIN || session.user.id === carbet.ownerId); + + if (!isManager && carbet.status !== CarbetStatus.PUBLISHED) { + return NextResponse.json({ error: "Carbet indisponible." }, { status: 404 }); + } + + const [availabilities, bookings] = await Promise.all([ + prisma.availability.findMany({ + where: { + carbetId, + startDate: { lt: endDate }, + endDate: { gt: startDate }, + }, + orderBy: { startDate: "asc" }, + select: { + id: true, + startDate: true, + endDate: true, + isAvailable: true, + scope: true, + blockReason: true, + }, + }), + prisma.booking.findMany({ + where: { + carbetId, + status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] }, + startDate: { lt: endDate }, + endDate: { gt: startDate }, + }, + select: { + id: true, + startDate: true, + endDate: true, + }, + }), + ]); + + const ceAccess = isCeUserRole(session?.user?.role); + const days = enumerateUtcDays(startDate, endDate); + + const calendar = days.map((day) => { + const nextDay = new Date(day); + nextDay.setUTCDate(nextDay.getUTCDate() + 1); + + const dayAvailability = availabilities.filter((a) => + hasOverlap(day, nextDay, a.startDate, a.endDate), + ); + + const isBooked = bookings.some((b) => + hasOverlap(day, nextDay, b.startDate, b.endDate), + ); + + const applicable = dayAvailability.find((a) => { + if (!a.isAvailable) { + return false; + } + + if (a.scope === "CE_ONLY" && !ceAccess && !isManager) { + return false; + } + + return true; + }); + + const defaultPolicyAllows = isManager || ceAccess || isPublicAllowedByDefaultPolicy(day); + const hasConfiguredSlot = dayAvailability.length > 0; + const isAvailable = (hasConfiguredSlot ? Boolean(applicable) : defaultPolicyAllows) && !isBooked; + + return { + date: day.toISOString().slice(0, 10), + isAvailable, + scope: applicable?.scope ?? (defaultPolicyAllows ? (ceAccess || isManager ? "CE_ONLY" : "PUBLIC") : null), + blockReason: + isBooked + ? "BOOKED" + : dayAvailability.find((a) => !a.isAvailable)?.blockReason ?? + (hasConfiguredSlot ? null : defaultPolicyAllows ? null : "WEEKEND_BLOCKED"), + hasCeSlot: dayAvailability.some((a) => a.scope === "CE_ONLY"), + source: hasConfiguredSlot ? "CONFIGURED" : "DEFAULT_POLICY", + }; + }); + + return NextResponse.json({ + carbetId, + from: startDate.toISOString(), + to: endDate.toISOString(), + calendar, + }); +} diff --git a/src/lib/booking.ts b/src/lib/booking.ts new file mode 100644 index 0000000..961f73c --- /dev/null +++ b/src/lib/booking.ts @@ -0,0 +1,50 @@ +import { UserRole } from "@/generated/prisma/enums"; + +export const DAY_MS = 24 * 60 * 60 * 1000; + +export function parseIsoDate(value: unknown): Date | null { + if (typeof value !== "string") { + return null; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + + return date; +} + +export function normalizeUtcDayStart(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +export function hasOverlap(startA: Date, endA: Date, startB: Date, endB: Date): boolean { + return startA < endB && endA > startB; +} + +export function enumerateUtcDays(start: Date, end: Date): Date[] { + const days: Date[] = []; + const cursor = normalizeUtcDayStart(start); + const limit = normalizeUtcDayStart(end); + + while (cursor < limit) { + days.push(new Date(cursor)); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + return days; +} + +export function isCeUserRole(role?: UserRole): boolean { + return role === UserRole.CE_MANAGER || role === UserRole.CE_MEMBER; +} + +export function isWeekendUtcDay(date: Date): boolean { + const day = date.getUTCDay(); + return day === 0 || day === 6; +} + +export function isPublicAllowedByDefaultPolicy(day: Date): boolean { + return !isWeekendUtcDay(day); +} From 74f39293cce603e920b1c404cd107d24ada2778a Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 15:00:21 +0000 Subject: [PATCH 02/79] =?UTF-8?q?feat(payment):=20int=C3=A9gration=20Strip?= =?UTF-8?q?e=20(subscription=20loueur=20+=20booking=20checkout=20+=20webho?= =?UTF-8?q?ok)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- src/app/api/stripe/checkout/booking/route.ts | 252 ++++++++++++++++++ .../api/stripe/checkout/subscription/route.ts | 77 ++++++ src/app/api/stripe/webhook/route.ts | 149 +++++++++++ src/lib/stripe.ts | 33 +++ 5 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 src/app/api/stripe/checkout/booking/route.ts create mode 100644 src/app/api/stripe/checkout/subscription/route.ts create mode 100644 src/app/api/stripe/webhook/route.ts create mode 100644 src/lib/stripe.ts diff --git a/package.json b/package.json index f6e1dc5..4b2d77d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "next-auth": "^5.0.0-beta.31", "pg": "^8.21.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "stripe": "^18.3.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/api/stripe/checkout/booking/route.ts b/src/app/api/stripe/checkout/booking/route.ts new file mode 100644 index 0000000..b786f61 --- /dev/null +++ b/src/app/api/stripe/checkout/booking/route.ts @@ -0,0 +1,252 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { + AvailabilityScope, + BookingStatus, + CarbetStatus, + PaymentStatus, + UserRole, +} from "@/generated/prisma/enums"; +import { + enumerateUtcDays, + hasOverlap, + isCeUserRole, + isPublicAllowedByDefaultPolicy, + normalizeUtcDayStart, + parseIsoDate, +} from "@/lib/booking"; +import { prisma } from "@/lib/prisma"; +import { getStripeClient, toStripeAmountCents } from "@/lib/stripe"; + +export const runtime = "nodejs"; + +type BookingCheckoutBody = { + carbetId?: string; + startDate?: string; + endDate?: string; + guestCount?: number; + amount?: number; + currency?: string; +}; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié." }, { status: 401 }); + } + + let body: BookingCheckoutBody; + try { + body = (await request.json()) as BookingCheckoutBody; + } catch { + return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 }); + } + + if (!body.carbetId) { + return NextResponse.json({ error: "carbetId requis." }, { status: 400 }); + } + + const startDateRaw = parseIsoDate(body.startDate); + const endDateRaw = parseIsoDate(body.endDate); + const guestCount = Number(body.guestCount); + const amount = Number(body.amount); + const currency = (body.currency ?? "EUR").toLowerCase(); + + if (!startDateRaw || !endDateRaw) { + return NextResponse.json( + { error: "startDate et endDate valides sont requis." }, + { status: 400 }, + ); + } + + const startDate = normalizeUtcDayStart(startDateRaw); + const endDate = normalizeUtcDayStart(endDateRaw); + + if (endDate <= startDate) { + return NextResponse.json( + { error: "La date de fin doit être après la date de début." }, + { status: 400 }, + ); + } + + if (!Number.isInteger(guestCount) || guestCount <= 0) { + return NextResponse.json( + { error: "guestCount doit être un entier strictement positif." }, + { status: 400 }, + ); + } + + if (!Number.isFinite(amount) || amount <= 0) { + return NextResponse.json({ error: "amount doit être > 0." }, { status: 400 }); + } + + const carbet = await prisma.carbet.findUnique({ + where: { id: body.carbetId }, + select: { + id: true, + ownerId: true, + title: true, + capacity: true, + status: true, + }, + }); + + if (!carbet) { + return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 }); + } + + const isManager = + session.user.role === UserRole.ADMIN || session.user.id === carbet.ownerId; + + if (!isManager && carbet.status !== CarbetStatus.PUBLISHED) { + return NextResponse.json({ error: "Carbet indisponible." }, { status: 404 }); + } + + if (guestCount > carbet.capacity) { + return NextResponse.json( + { error: `Capacité max dépassée (${carbet.capacity}).` }, + { status: 400 }, + ); + } + + const [overlappingBookings, availabilities] = await Promise.all([ + prisma.booking.findMany({ + where: { + carbetId: carbet.id, + status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] }, + startDate: { lt: endDate }, + endDate: { gt: startDate }, + }, + select: { id: true, startDate: true, endDate: true }, + }), + prisma.availability.findMany({ + where: { + carbetId: carbet.id, + startDate: { lt: endDate }, + endDate: { gt: startDate }, + }, + select: { + id: true, + startDate: true, + endDate: true, + isAvailable: true, + scope: true, + }, + }), + ]); + + if (overlappingBookings.length > 0) { + return NextResponse.json( + { error: "Ce créneau est déjà réservé." }, + { status: 409 }, + ); + } + + const ceAccess = isCeUserRole(session.user.role); + const days = enumerateUtcDays(startDate, endDate); + + for (const day of days) { + const nextDay = new Date(day); + nextDay.setUTCDate(nextDay.getUTCDate() + 1); + + const coveredSlots = availabilities.filter((a) => + hasOverlap(day, nextDay, a.startDate, a.endDate), + ); + + if (coveredSlots.length === 0) { + const defaultAllowed = isManager || ceAccess || isPublicAllowedByDefaultPolicy(day); + if (defaultAllowed) { + continue; + } + + return NextResponse.json( + { + error: `Créneau non réservable le ${day.toISOString().slice(0, 10)} (week-end réservé CE).`, + }, + { status: 409 }, + ); + } + + const allowedSlot = coveredSlots.find((slot) => { + if (!slot.isAvailable) { + return false; + } + + if (slot.scope === AvailabilityScope.CE_ONLY && !ceAccess && !isManager) { + return false; + } + + return true; + }); + + if (!allowedSlot) { + return NextResponse.json( + { + error: `Créneau non réservable le ${day.toISOString().slice(0, 10)} (restriction CE ou blocage).`, + }, + { status: 409 }, + ); + } + } + + const booking = await prisma.booking.create({ + data: { + carbetId: carbet.id, + tenantId: session.user.id, + startDate, + endDate, + guestCount, + status: BookingStatus.PENDING, + paymentStatus: PaymentStatus.PENDING, + amount, + currency: currency.toUpperCase(), + }, + select: { + id: true, + amount: true, + currency: true, + startDate: true, + endDate: true, + }, + }); + + const appUrl = process.env.APP_URL; + if (!appUrl) { + return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 }); + } + + const stripe = getStripeClient(); + const checkoutSession = await stripe.checkout.sessions.create({ + mode: "payment", + success_url: `${appUrl}/reservations/${booking.id}?payment=success`, + cancel_url: `${appUrl}/reservations/${booking.id}?payment=cancel`, + customer_email: session.user.email ?? undefined, + line_items: [ + { + quantity: 1, + price_data: { + currency, + unit_amount: toStripeAmountCents(amount), + product_data: { + name: `Réservation carbet: ${carbet.title}`, + description: `${booking.startDate.toISOString().slice(0, 10)} au ${booking.endDate.toISOString().slice(0, 10)}`, + }, + }, + }, + ], + metadata: { + bookingId: booking.id, + type: "booking", + }, + }); + + return NextResponse.json( + { + bookingId: booking.id, + checkoutSessionId: checkoutSession.id, + checkoutUrl: checkoutSession.url, + }, + { status: 201 }, + ); +} diff --git a/src/app/api/stripe/checkout/subscription/route.ts b/src/app/api/stripe/checkout/subscription/route.ts new file mode 100644 index 0000000..617d449 --- /dev/null +++ b/src/app/api/stripe/checkout/subscription/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { getStripeClient } from "@/lib/stripe"; + +export const runtime = "nodejs"; + +type SubscriptionCheckoutBody = { + carbetId?: string; +}; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié." }, { status: 401 }); + } + + let body: SubscriptionCheckoutBody; + try { + body = (await request.json()) as SubscriptionCheckoutBody; + } catch { + return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 }); + } + + if (!body.carbetId) { + return NextResponse.json({ error: "carbetId requis." }, { status: 400 }); + } + + const carbet = await prisma.carbet.findUnique({ + where: { id: body.carbetId }, + select: { id: true, ownerId: true, title: true }, + }); + + if (!carbet) { + return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 }); + } + + const canManage = + session.user.role === UserRole.ADMIN || session.user.id === carbet.ownerId; + + if (!canManage) { + return NextResponse.json({ error: "Accès refusé." }, { status: 403 }); + } + + const priceId = process.env.STRIPE_OWNER_SUBSCRIPTION_PRICE_ID; + const appUrl = process.env.APP_URL; + if (!priceId || !appUrl) { + return NextResponse.json( + { error: "Configuration Stripe abonnement incomplète." }, + { status: 500 }, + ); + } + + const stripe = getStripeClient(); + const checkoutSession = await stripe.checkout.sessions.create({ + mode: "subscription", + success_url: `${appUrl}/espace-hote/carbets/${carbet.id}?subscription=success`, + cancel_url: `${appUrl}/espace-hote/carbets/${carbet.id}?subscription=cancel`, + customer_email: session.user.email ?? undefined, + line_items: [{ price: priceId, quantity: 1 }], + metadata: { + ownerId: carbet.ownerId, + carbetId: carbet.id, + type: "owner_subscription", + }, + }); + + return NextResponse.json( + { + checkoutSessionId: checkoutSession.id, + checkoutUrl: checkoutSession.url, + }, + { status: 201 }, + ); +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..d3bc17a --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,149 @@ +import { NextResponse } from "next/server"; +import Stripe from "stripe"; + +import { + BookingStatus, + PaymentStatus, + SubscriptionStatus, +} from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe"; + +export const runtime = "nodejs"; + +function mapStripeSubscriptionStatus(status: Stripe.Subscription.Status): SubscriptionStatus { + switch (status) { + case "trialing": + return SubscriptionStatus.TRIAL; + case "active": + return SubscriptionStatus.ACTIVE; + case "past_due": + case "unpaid": + case "paused": + return SubscriptionStatus.PAST_DUE; + case "canceled": + case "incomplete_expired": + return SubscriptionStatus.CANCELED; + case "incomplete": + return SubscriptionStatus.TRIAL; + default: + return SubscriptionStatus.TRIAL; + } +} + +async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { + const bookingId = session.metadata?.bookingId; + const type = session.metadata?.type; + + if (type === "booking" && bookingId) { + await prisma.booking.update({ + where: { id: bookingId }, + data: { + paymentStatus: PaymentStatus.SUCCEEDED, + status: BookingStatus.CONFIRMED, + }, + }); + return; + } + + if (type === "owner_subscription") { + const ownerId = session.metadata?.ownerId; + const carbetId = session.metadata?.carbetId; + const providerSubId = typeof session.subscription === "string" ? session.subscription : null; + + if (!ownerId || !carbetId || !providerSubId) { + return; + } + + await prisma.subscription.upsert({ + where: { providerSubId }, + update: { + status: SubscriptionStatus.ACTIVE, + renewedAt: new Date(), + }, + create: { + ownerId, + carbetId, + provider: "stripe", + providerSubId, + status: SubscriptionStatus.ACTIVE, + }, + }); + } +} + +async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) { + const bookingId = paymentIntent.metadata?.bookingId; + if (!bookingId) { + return; + } + + await prisma.booking.update({ + where: { id: bookingId }, + data: { + paymentStatus: PaymentStatus.FAILED, + status: BookingStatus.CANCELLED, + }, + }); +} + +async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { + await prisma.subscription.upsert({ + where: { providerSubId: subscription.id }, + update: { + status: mapStripeSubscriptionStatus(subscription.status), + renewedAt: fromStripeTimestamp(subscription.current_period_end), + canceledAt: fromStripeTimestamp(subscription.canceled_at), + }, + create: { + ownerId: subscription.metadata.ownerId, + carbetId: subscription.metadata.carbetId, + provider: "stripe", + providerSubId: subscription.id, + status: mapStripeSubscriptionStatus(subscription.status), + startedAt: fromStripeTimestamp(subscription.start_date) ?? new Date(), + renewedAt: fromStripeTimestamp(subscription.current_period_end), + canceledAt: fromStripeTimestamp(subscription.canceled_at), + }, + }); +} + +export async function POST(request: Request) { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) { + return NextResponse.json({ error: "STRIPE_WEBHOOK_SECRET manquante." }, { status: 500 }); + } + + const stripe = getStripeClient(); + const signature = request.headers.get("stripe-signature"); + if (!signature) { + return NextResponse.json({ error: "Signature Stripe absente." }, { status: 400 }); + } + + const payload = await request.text(); + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(payload, signature, secret); + } catch { + return NextResponse.json({ error: "Signature Stripe invalide." }, { status: 400 }); + } + + switch (event.type) { + case "checkout.session.completed": + await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session); + break; + case "payment_intent.payment_failed": + await handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent); + break; + case "customer.subscription.created": + case "customer.subscription.updated": + case "customer.subscription.deleted": + await handleSubscriptionUpdated(event.data.object as Stripe.Subscription); + break; + default: + break; + } + + return NextResponse.json({ received: true }); +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..adda277 --- /dev/null +++ b/src/lib/stripe.ts @@ -0,0 +1,33 @@ +import Stripe from "stripe"; + +let stripeClient: Stripe | null = null; + +export function getStripeClient(): Stripe { + if (stripeClient) { + return stripeClient; + } + + const secretKey = process.env.STRIPE_SECRET_KEY; + if (!secretKey) { + throw new Error("STRIPE_SECRET_KEY manquante."); + } + + stripeClient = new Stripe(secretKey); + return stripeClient; +} + +export function toStripeAmountCents(amount: number): number { + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error("Montant invalide."); + } + + return Math.round(amount * 100); +} + +export function fromStripeTimestamp(ts: number | null | undefined): Date | null { + if (!ts) { + return null; + } + + return new Date(ts * 1000); +} From 75159813363ecb9b825ab4d45f1832dc02ef13f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karb=C3=A9=20Frontend?= Date: Sat, 30 May 2026 15:08:55 +0000 Subject: [PATCH 03/79] feat(reviews): avis & notes carbet (SYS-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/bookings/[bookingId]/review/route.ts | 112 +++++++++++++++ .../api/carbets/[carbetId]/reviews/route.ts | 39 +++++ .../api/reviews/[reviewId]/response/route.ts | 85 +++++++++++ src/app/carbets/[slug]/page.tsx | 34 ++++- src/app/carbets/_components/carbet-card.tsx | 12 ++ .../_components/host-response-form.tsx | 69 +++++++++ src/app/carbets/_components/review-form.tsx | 111 ++++++++++++++ .../carbets/_components/reviews-section.tsx | 104 ++++++++++++++ src/app/carbets/_components/star-rating.tsx | 36 +++++ src/app/mes-reservations/page.tsx | 135 ++++++++++++++++++ src/lib/carbet-public.ts | 17 +++ src/lib/carbet-search.ts | 37 +++-- src/lib/reviews-server.ts | 88 ++++++++++++ src/lib/reviews.ts | 38 +++++ 14 files changed, 903 insertions(+), 14 deletions(-) create mode 100644 src/app/api/bookings/[bookingId]/review/route.ts create mode 100644 src/app/api/carbets/[carbetId]/reviews/route.ts create mode 100644 src/app/api/reviews/[reviewId]/response/route.ts create mode 100644 src/app/carbets/_components/host-response-form.tsx create mode 100644 src/app/carbets/_components/review-form.tsx create mode 100644 src/app/carbets/_components/reviews-section.tsx create mode 100644 src/app/carbets/_components/star-rating.tsx create mode 100644 src/app/mes-reservations/page.tsx create mode 100644 src/lib/reviews-server.ts create mode 100644 src/lib/reviews.ts 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 ( +
+