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:
parent
9f3eda8bb4
commit
0de034022a
3 changed files with 406 additions and 0 deletions
211
src/app/api/bookings/route.ts
Normal file
211
src/app/api/bookings/route.ts
Normal 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 });
|
||||
}
|
||||
145
src/app/api/carbets/[carbetId]/availability/route.ts
Normal file
145
src/app/api/carbets/[carbetId]/availability/route.ts
Normal 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
50
src/lib/booking.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue