All checks were successful
CI / test (pull_request) Successful in 2m45s
Public badge sur fiche carbet :
- carbet-public.ts charge les OrganizationCarbetMembership (org
approuvée uniquement) + expose `organizations: {id,name,slug}[]`.
- /carbets/[slug] affiche « Géré par le CE <name> » sous le header
si au moins 1 org liée.
Invites CE_MEMBER :
- Migration 20260603300000_org_invite_token : OrgInviteToken
(tokenHash, organizationId, email?, createdByUserId, expiresAt,
usedAt). Cascade sur Organization. Index expiresAt + organizationId.
- src/lib/ce-invites.ts : createOrgInviteToken (TTL 14j),
listOrgInviteTokens, getOrgInviteByToken (validité + expiry),
markOrgInviteConsumed, revokeOrgInviteToken. Token = 24 bytes
base64url, hash sha256.
- /espace-ce/membres : liste membres (CE_MANAGER + CE_MEMBER actifs)
+ form de génération de lien (email optionnel = lock email côté
signup) + liste des invitations avec statut actif/consommé/expiré +
bouton révoquer.
- /espace-ce/membres/actions.ts : createInviteAction +
revokeInviteAction. Audit log scope=ce.invite.
- API /api/signup étendue : zod accepte inviteToken, branche dédiée
qui crée User CE_MEMBER + organizationId du token + marquage
usedAt. Vérif email match si email fourni dans le token.
- /inscription?invite=TOKEN : récupère l'invite, pré-affiche org name,
lock email si fourni, masque les fieldsets type de compte (forcé
CE_MEMBER).
CTA marketing :
- /pour-comites-entreprise : section CTA « Créer mon espace CE » sous
le rendu content-pages, conditionnée par plugin ce-management.
Tests vitest (tests/lib/ce-access.test.ts) :
- canManageCarbet : admin always, owner direct, CE_MANAGER via org
match, refus si autre org / pas d'org / TOURIST / pas de membership.
- 9 tests, mocks next-auth + @/auth + @/lib/authorization pour éviter
next/server (incompatible vitest sans setup).
- Total tests projet : 62/62 ✓.
Dashboard /espace-ce : lien vers /espace-ce/membres en bas.
Migration prod appliquée.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
596 lines
16 KiB
Text
596 lines
16 KiB
Text
generator client {
|
|
provider = "prisma-client"
|
|
output = "../src/generated/prisma"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
}
|
|
|
|
enum UserRole {
|
|
OWNER
|
|
CE_MANAGER
|
|
CE_MEMBER
|
|
TOURIST
|
|
ADMIN
|
|
RENTAL_PROVIDER
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
enum AccessType {
|
|
ROAD_AND_RIVER
|
|
RIVER_ONLY
|
|
}
|
|
|
|
enum TransportMode {
|
|
OWNER_PROVIDES
|
|
SELF_ARRANGE
|
|
PARTNER_PROVIDER
|
|
}
|
|
|
|
model Organization {
|
|
id String @id @default(cuid())
|
|
name String
|
|
slug String @unique
|
|
description String?
|
|
contactEmail String?
|
|
approved Boolean @default(false)
|
|
approvedAt DateTime?
|
|
approvedBy String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
members User[]
|
|
carbetMemberships OrganizationCarbetMembership[]
|
|
rentalProviders RentalProvider[]
|
|
invites OrgInviteToken[]
|
|
|
|
@@index([name])
|
|
@@index([approved])
|
|
}
|
|
|
|
/// Token d'invitation pour rejoindre une organisation comme CE_MEMBER.
|
|
/// Le CE_MANAGER génère un lien, le destinataire s'inscrit via /inscription?invite=TOKEN.
|
|
/// Pas de unique sur email pour permettre plusieurs invites pendants par destinataire.
|
|
model OrgInviteToken {
|
|
tokenHash String @id
|
|
organizationId String
|
|
email String?
|
|
createdByUserId String?
|
|
expiresAt DateTime
|
|
usedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
|
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([organizationId])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
/// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial),
|
|
/// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet
|
|
/// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE :
|
|
/// 1 membership pour l'org du créateur. Plusieurs orgs possibles si co-publication.
|
|
model OrganizationCarbetMembership {
|
|
organizationId String
|
|
carbetId String
|
|
addedByUserId String?
|
|
addedAt DateTime @default(now())
|
|
|
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([organizationId, carbetId])
|
|
@@index([carbetId])
|
|
}
|
|
|
|
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[]
|
|
rentalProviders RentalProvider[]
|
|
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
|
|
|
|
@@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
|
|
// Pirogue : obligatoire pour RIVER_ONLY, optionnelle pour ROAD_AND_RIVER
|
|
// (estimation pour ceux qui veulent quand même venir en pirogue).
|
|
pirogueDurationMin Int?
|
|
accessType AccessType @default(ROAD_AND_RIVER)
|
|
// Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste).
|
|
roadAccessNote String?
|
|
capacity Int
|
|
// 4 critères opérationnels dealbreakers (dispo en filtres + badges UI)
|
|
roadAccess RoadAccess?
|
|
electricity Electricity?
|
|
gsmAtCarbet Boolean @default(false)
|
|
gsmExitDistanceKm Decimal? @db.Decimal(4, 2)
|
|
// Prix par nuit pour le carbet entier (toute capacité). En euros.
|
|
nightlyPrice Decimal @db.Decimal(10, 2) @default(0)
|
|
// Contraintes séjour (plugin min-stay). null = pas de contrainte.
|
|
minStayNights Int?
|
|
maxStayNights Int?
|
|
minCapacity Int?
|
|
// Contraintes saisonnières (plugin seasonality). JSON libre, schéma type :
|
|
// { closedInLowWater: bool, closedSeasons: ["WET"|"DRY"|"LOW_WATER"][], note: string }
|
|
seasonalConstraints Json?
|
|
// Plugin pirogue-providers : qui organise le transport ?
|
|
transportMode TransportMode?
|
|
pirogueProviderId String?
|
|
status CarbetStatus @default(DRAFT)
|
|
lastBookedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
|
|
pirogueProvider PirogueProvider? @relation(fields: [pirogueProviderId], references: [id], onDelete: SetNull)
|
|
amenities CarbetAmenity[]
|
|
media Media[]
|
|
availabilities Availability[]
|
|
bookings Booking[]
|
|
reviews Review[]
|
|
subscriptions Subscription[]
|
|
organizations OrganizationCarbetMembership[]
|
|
|
|
@@index([ownerId])
|
|
@@index([status])
|
|
@@index([river])
|
|
@@index([accessType])
|
|
@@index([pirogueProviderId])
|
|
}
|
|
|
|
model PirogueProvider {
|
|
id String @id @default(cuid())
|
|
name String
|
|
contactEmail String?
|
|
contactPhone String?
|
|
rivers String[] @default([])
|
|
pricingNote String?
|
|
description String?
|
|
active Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
carbets Carbet[]
|
|
|
|
@@index([active])
|
|
}
|
|
|
|
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?
|
|
rentalBookings RentalBooking[]
|
|
|
|
@@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])
|
|
}
|
|
|
|
model Plugin {
|
|
key String @id
|
|
name String
|
|
description String
|
|
category String
|
|
version String @default("0.1.0")
|
|
enabled Boolean @default(false)
|
|
config Json @default("{}")
|
|
migrationsApplied String[] @default([])
|
|
installedAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
lastEnabledAt DateTime?
|
|
lastDisabledAt DateTime?
|
|
|
|
@@index([category])
|
|
@@index([enabled])
|
|
}
|
|
|
|
model ContentPage {
|
|
slug String
|
|
lang String @default("fr")
|
|
title String
|
|
body String
|
|
// 'general' (about, faq, ...) ou 'legal' (cgv, mentions, ...)
|
|
category String @default("general")
|
|
published Boolean @default(true)
|
|
lastEditedBy String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@id([slug, lang])
|
|
@@index([slug])
|
|
@@index([category])
|
|
@@index([published])
|
|
}
|
|
|
|
model AuditLog {
|
|
id String @id @default(cuid())
|
|
scope String
|
|
event String
|
|
target String?
|
|
actorEmail String?
|
|
details Json @default("{}")
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([scope])
|
|
@@index([event])
|
|
@@index([actorEmail])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
model Setting {
|
|
key String @id
|
|
value Json @default("{}")
|
|
updatedAt DateTime @updatedAt
|
|
updatedBy String?
|
|
}
|
|
|
|
model Translation {
|
|
key String
|
|
lang String
|
|
value String
|
|
updatedAt DateTime @updatedAt
|
|
updatedBy String?
|
|
|
|
@@id([key, lang])
|
|
@@index([lang])
|
|
}
|
|
|
|
model PasswordResetToken {
|
|
tokenHash String @id
|
|
userId String
|
|
expiresAt DateTime
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
model Favorite {
|
|
userId String
|
|
carbetId String
|
|
createdAt DateTime @default(now())
|
|
|
|
@@id([userId, carbetId])
|
|
@@index([userId])
|
|
@@index([carbetId])
|
|
}
|
|
|
|
enum RoadAccess {
|
|
NONE
|
|
DRY_SEASON_ONLY
|
|
ALL_YEAR
|
|
}
|
|
|
|
enum Electricity {
|
|
NONE
|
|
SOLAR
|
|
GENERATOR_READY
|
|
EDF
|
|
}
|
|
|
|
enum RentalCategory {
|
|
SLEEP
|
|
NAVIGATION
|
|
FISHING
|
|
COOKING
|
|
SAFETY
|
|
}
|
|
|
|
enum RentalBookingStatus {
|
|
PENDING
|
|
CONFIRMED
|
|
HANDED_OVER
|
|
RETURNED
|
|
CANCELLED
|
|
}
|
|
|
|
model RentalProvider {
|
|
id String @id @default(cuid())
|
|
name String
|
|
isSystemD Boolean @default(false)
|
|
managedByUserId String?
|
|
/// Si renseigné, le provider appartient à une organisation (CE) ; tout CE_MANAGER
|
|
/// membre de l'org peut gérer items et réservations en plus du manager nominal.
|
|
organizationId String?
|
|
contactEmail String?
|
|
contactPhone String?
|
|
rivers String[] @default([])
|
|
description String?
|
|
commissionPct Decimal @db.Decimal(5, 2) @default(0)
|
|
active Boolean @default(true)
|
|
approved Boolean @default(false)
|
|
approvedAt DateTime?
|
|
approvedBy String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
|
|
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
|
items RentalItem[]
|
|
rentalBookings RentalBooking[]
|
|
|
|
@@index([active, approved])
|
|
@@index([managedByUserId])
|
|
@@index([organizationId])
|
|
}
|
|
|
|
model RentalItem {
|
|
id String @id @default(cuid())
|
|
providerId String
|
|
category RentalCategory
|
|
name String
|
|
description String?
|
|
imageUrl String?
|
|
pricePerDay Decimal @db.Decimal(8, 2)
|
|
pricePerWeek Decimal? @db.Decimal(8, 2)
|
|
deposit Decimal @db.Decimal(8, 2) @default(0)
|
|
totalQty Int @default(1)
|
|
withMotor Boolean @default(false)
|
|
fuelIncluded Boolean @default(false)
|
|
requiresLicense Boolean @default(false)
|
|
active Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
|
availabilities RentalItemAvailability[]
|
|
lines RentalLine[]
|
|
media RentalItemMedia[]
|
|
|
|
@@index([providerId])
|
|
@@index([category, active])
|
|
}
|
|
|
|
model RentalItemMedia {
|
|
id String @id @default(cuid())
|
|
itemId String
|
|
type MediaType
|
|
s3Key String
|
|
s3Url String
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
|
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([itemId, sortOrder])
|
|
}
|
|
|
|
model RentalItemAvailability {
|
|
id String @id @default(cuid())
|
|
itemId String
|
|
startDate DateTime
|
|
endDate DateTime
|
|
qty Int
|
|
reason String
|
|
rentalBookingId String?
|
|
createdAt DateTime @default(now())
|
|
|
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([itemId, startDate, endDate])
|
|
@@index([rentalBookingId])
|
|
}
|
|
|
|
model RentalBooking {
|
|
id String @id @default(cuid())
|
|
bookingId String?
|
|
tenantId String
|
|
providerId String
|
|
startDate DateTime
|
|
endDate DateTime
|
|
status RentalBookingStatus @default(PENDING)
|
|
paymentStatus PaymentStatus @default(PENDING)
|
|
itemsTotal Decimal @db.Decimal(10, 2)
|
|
depositTotal Decimal @db.Decimal(10, 2)
|
|
commissionAmount Decimal @db.Decimal(10, 2) @default(0)
|
|
amount Decimal @db.Decimal(10, 2)
|
|
currency String @default("EUR")
|
|
stripeSessionId String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
|
tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
|
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
|
lines RentalLine[]
|
|
|
|
@@index([tenantId, status])
|
|
@@index([providerId, status])
|
|
@@index([bookingId])
|
|
@@index([startDate, endDate])
|
|
}
|
|
|
|
model RentalLine {
|
|
id String @id @default(cuid())
|
|
rentalBookingId String
|
|
itemId String
|
|
qty Int
|
|
pricePerDay Decimal @db.Decimal(8, 2)
|
|
deposit Decimal @db.Decimal(8, 2) @default(0)
|
|
lineTotal Decimal @db.Decimal(10, 2)
|
|
|
|
rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade)
|
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
|
|
|
|
@@index([rentalBookingId])
|
|
}
|