karbe/src/app/api/bookings/route.ts
Claude Integration 0de034022a feat(booking): API réservation + availability + lib métier
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.
2026-05-30 14:42:29 +00:00

211 lines
5 KiB
TypeScript

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