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