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.
211 lines
5 KiB
TypeScript
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 });
|
|
}
|