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.
This commit is contained in:
Claude Integration 2026-05-30 14:42:29 +00:00
parent 9f3eda8bb4
commit 0de034022a
3 changed files with 406 additions and 0 deletions

View file

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

View file

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

50
src/lib/booking.ts Normal file
View file

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