feat(carbet): track lastBookedAt on booking creation

This commit is contained in:
Karbé Architect 2026-05-30 16:21:22 +00:00
parent 5b6213e764
commit 08a5321cae
4 changed files with 720 additions and 6 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE "Carbet"
ADD COLUMN "lastBookedAt" TIMESTAMP(3);

View file

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

View file

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

View file

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