diff --git a/prisma/migrations/20260530163000_add_last_booked_at_to_carbet/migration.sql b/prisma/migrations/20260530163000_add_last_booked_at_to_carbet/migration.sql new file mode 100644 index 0000000..8df5aef --- /dev/null +++ b/prisma/migrations/20260530163000_add_last_booked_at_to_carbet/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Carbet" +ADD COLUMN "lastBookedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 44c1455..ee7a327 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,8 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Get a free hosted Postgres database in seconds: `npx create-db` - generator client { provider = "prisma-client" output = "../src/generated/prisma" @@ -12,4 +7,240 @@ datasource db { provider = "postgresql" } -// Les modèles Karbé (carbets, réservations, utilisateurs…) seront ajoutés ici. +enum UserRole { + OWNER + CE_MANAGER + CE_MEMBER + TOURIST + ADMIN +} + +enum CarbetStatus { + DRAFT + PUBLISHED + ARCHIVED +} + +enum MediaType { + PHOTO + VIDEO +} + +enum AvailabilityScope { + PUBLIC + CE_ONLY +} + +enum AvailabilityBlockReason { + NONE + CE_BLOCKED + WEEKEND_BLOCKED +} + +enum BookingStatus { + PENDING + CONFIRMED + CANCELLED + COMPLETED +} + +enum PaymentStatus { + PENDING + AUTHORIZED + SUCCEEDED + FAILED + REFUNDED +} + +enum SubscriptionStatus { + TRIAL + ACTIVE + PAST_DUE + CANCELED +} + +model Organization { + id String @id @default(cuid()) + name String + slug String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + members User[] + + @@index([name]) +} + +model User { + id String @id @default(cuid()) + email String @unique + passwordHash String + firstName String + lastName String + phone String? + role UserRole + organizationId String? + avatarUrl String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) + carbets Carbet[] @relation("CarbetOwner") + bookings Booking[] @relation("BookingTenant") + reviews Review[] @relation("ReviewAuthor") + subscriptions Subscription[] + + @@index([organizationId]) + @@index([role]) +} + +model Carbet { + id String @id @default(cuid()) + ownerId String + title String + slug String @unique + description String + river String + latitude Decimal @db.Decimal(9, 6) + longitude Decimal @db.Decimal(9, 6) + embarkPoint String + pirogueDurationMin Int + capacity Int + status CarbetStatus @default(DRAFT) + lastBookedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict) + amenities CarbetAmenity[] + media Media[] + availabilities Availability[] + bookings Booking[] + reviews Review[] + subscriptions Subscription[] + + @@index([ownerId]) + @@index([status]) + @@index([river]) +} + +model Amenity { + id String @id @default(cuid()) + key String @unique + label String + description String? + createdAt DateTime @default(now()) + + carbets CarbetAmenity[] +} + +model CarbetAmenity { + carbetId String + amenityId String + + carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade) + amenity Amenity @relation(fields: [amenityId], references: [id], onDelete: Cascade) + + @@id([carbetId, amenityId]) + @@index([amenityId]) +} + +model Media { + id String @id @default(cuid()) + carbetId String + type MediaType + s3Key String + s3Url String + sortOrder Int @default(0) + createdAt DateTime @default(now()) + + carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade) + + @@index([carbetId, sortOrder]) +} + +model Availability { + id String @id @default(cuid()) + carbetId String + startDate DateTime + endDate DateTime + scope AvailabilityScope @default(PUBLIC) + blockReason AvailabilityBlockReason @default(NONE) + isAvailable Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade) + + @@index([carbetId]) + @@index([scope, blockReason]) + @@index([startDate, endDate]) +} + +model Booking { + id String @id @default(cuid()) + carbetId String + tenantId String + startDate DateTime + endDate DateTime + guestCount Int + status BookingStatus @default(PENDING) + amount Decimal @db.Decimal(10, 2) + currency String @default("EUR") + paymentStatus PaymentStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict) + tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict) + + review Review? + + @@index([carbetId]) + @@index([tenantId]) + @@index([status, paymentStatus]) + @@index([startDate, endDate]) +} + +model Subscription { + id String @id @default(cuid()) + ownerId String + carbetId String + provider String + providerSubId String? @unique + status SubscriptionStatus @default(TRIAL) + startedAt DateTime @default(now()) + renewedAt DateTime? + canceledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict) + carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade) + + @@index([ownerId]) + @@index([carbetId]) + @@index([status]) +} + +model Review { + id String @id @default(cuid()) + bookingId String @unique + carbetId String + authorId String + rating Int + comment String? + hostResponse String? + hostRespondedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) + carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict) + author User @relation("ReviewAuthor", fields: [authorId], references: [id], onDelete: Restrict) + + @@index([carbetId]) + @@index([authorId]) +} diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts new file mode 100644 index 0000000..9212ae2 --- /dev/null +++ b/src/app/api/bookings/route.ts @@ -0,0 +1,220 @@ +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.$transaction(async (tx) => { + const created = await tx.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, + }, + }); + + await tx.carbet.update({ + where: { id: carbet.id }, + data: { lastBookedAt: created.createdAt }, + }); + + return created; + }); + + return NextResponse.json({ booking }, { status: 201 }); +} diff --git a/src/app/api/stripe/checkout/booking/route.ts b/src/app/api/stripe/checkout/booking/route.ts new file mode 100644 index 0000000..4ac61b1 --- /dev/null +++ b/src/app/api/stripe/checkout/booking/route.ts @@ -0,0 +1,261 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { + AvailabilityScope, + BookingStatus, + CarbetStatus, + PaymentStatus, + UserRole, +} from "@/generated/prisma/enums"; +import { + enumerateUtcDays, + hasOverlap, + isCeUserRole, + isPublicAllowedByDefaultPolicy, + normalizeUtcDayStart, + parseIsoDate, +} from "@/lib/booking"; +import { prisma } from "@/lib/prisma"; +import { getStripeClient, toStripeAmountCents } from "@/lib/stripe"; + +export const runtime = "nodejs"; + +type BookingCheckoutBody = { + carbetId?: string; + startDate?: string; + endDate?: string; + guestCount?: number; + amount?: number; + currency?: string; +}; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié." }, { status: 401 }); + } + + let body: BookingCheckoutBody; + try { + body = (await request.json()) as BookingCheckoutBody; + } 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); + const amount = Number(body.amount); + const currency = (body.currency ?? "EUR").toLowerCase(); + + 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 }, + ); + } + + if (!Number.isFinite(amount) || amount <= 0) { + return NextResponse.json({ error: "amount doit être > 0." }, { status: 400 }); + } + + const carbet = await prisma.carbet.findUnique({ + where: { id: body.carbetId }, + select: { + id: true, + ownerId: true, + title: 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.$transaction(async (tx) => { + const created = await tx.booking.create({ + data: { + carbetId: carbet.id, + tenantId: session.user.id, + startDate, + endDate, + guestCount, + status: BookingStatus.PENDING, + paymentStatus: PaymentStatus.PENDING, + amount, + currency: currency.toUpperCase(), + }, + select: { + id: true, + amount: true, + currency: true, + startDate: true, + endDate: true, + }, + }); + + await tx.carbet.update({ + where: { id: carbet.id }, + data: { lastBookedAt: new Date() }, + }); + + return created; + }); + + const appUrl = process.env.APP_URL; + if (!appUrl) { + return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 }); + } + + const stripe = getStripeClient(); + const checkoutSession = await stripe.checkout.sessions.create({ + mode: "payment", + success_url: `${appUrl}/reservations/${booking.id}?payment=success`, + cancel_url: `${appUrl}/reservations/${booking.id}?payment=cancel`, + customer_email: session.user.email ?? undefined, + line_items: [ + { + quantity: 1, + price_data: { + currency, + unit_amount: toStripeAmountCents(amount), + product_data: { + name: `Réservation carbet: ${carbet.title}`, + description: `${booking.startDate.toISOString().slice(0, 10)} au ${booking.endDate.toISOString().slice(0, 10)}`, + }, + }, + }, + ], + metadata: { + bookingId: booking.id, + type: "booking", + }, + }); + + return NextResponse.json( + { + bookingId: booking.id, + checkoutSessionId: checkoutSession.id, + checkoutUrl: checkoutSession.url, + }, + { status: 201 }, + ); +}