@mostajs/booking
v0.1.0
Published
Generic booking + reservation module for @mostajs/* — appointment scheduling, restaurant/room reservation, event ticketing, live SFU session booking. Resource + Slot + Reservation abstraction inspired by Cal.com / OpenTable / Doctolib / Eventbrite.
Readme
@mostajs/booking
Auteur : Dr Hamid MADANI [email protected] License : AGPL-3.0-or-later Version : 0.1.0
Module de réservation générique pour l'écosystème @mostajs/*. Abstraction unifiée (Resource + Slot + Reservation + AvailabilityRule + Policy) inspirée par l'état de l'art de Cal.com, Calendly, OpenTable, Airbnb, Doctolib, Eventbrite — couvre 6 cas d'usage validés en une seule API : appointment scheduling, restaurant booking, meeting room, beauty salon, event ticketing, live SFU sessions.
📊 Étude état de l'art détaillée → docs/STATE-OF-THE-ART.md
Table des matières
- Cas d'usage couverts
- Concepts
- Architecture
- Quick start — how to use
- API détaillée
- Implémentation — how to impl
- Exemples par domaine
- Politiques (BookingPolicy)
- Hooks (étendre sans modifier le module)
- Time zones & règles récurrentes
- Tests
- Roadmap & limites
- Modules liés
1. Cas d'usage couverts
| Domaine | Exemple | Resource.kind | Resource.capacity | Slot type |
|---|---|---|---|---|
| 🎓 Live class | Cours en direct via @mostajs/media-sfu | live-session | null (∞) ou 30 (cohort) | manual + recurring |
| 🩺 Médical | RDV chez le médecin / kiné | doctor-visit | 1 | recurring 30min slots |
| 🍽️ Restaurant | Réservation de table | restaurant-table | 8 (covers) | manual fenêtres 1h |
| 💼 Salle de réunion | Bureau, équipement | meeting-room | 12 (occupants) | recurring 30min |
| 💇 Coiffeur | Prestation prog. | hairdresser-cut | 1 | recurring + buffer cleaning |
| 🎫 Event ticketing | Conférence, concert | event | 500 (places) | 1 slot unique |
Architecture agnostique du domaine : tu déclares un Resource avec un kind libre + une capacity, et le manager gère le reste.
2. Concepts
Resource
"Ce qu'on réserve."
Une consultation, une table, une salle, un cours, un événement. Chaque resource a une capacity (1 = exclusive, N = partagée, null = infinie) et appartient à un ownerUserId.
AvailabilityRule
"Quand la resource est disponible."
3 kinds :
recurring: "tous les lundis 9h-12h, 14h-18h, créneaux de 30 min"one-off: "exceptionnellement le 25 décembre 14h-15h"blackout: "indispo du 15 au 22 août (vacances)"
Slot
"Un créneau concret réservable."
Généré automatiquement depuis les AvailabilityRule OU créé manuellement. A un compteur reservedCount qui suit la capacity du Resource.
Reservation
"Un seat occupé dans un slot par un user."
partySize: combien de seats consommés (1 par défaut, 4 pour une table à 4 couverts)status: pending → confirmed → attended (state machine)joinToken: HMAC signé pour vérifier l'accès au moment de l'arrivée
Policy
"Les règles métier appliquées au reserve/cancel."
- min notice (24h avant)
- max horizon (90j max à l'avance)
- max per user (1 réservation en cours)
- buffer time (cleaning)
- cancellation window
3. Architecture
┌────────────────────────────────────────────────────────┐
│ Code applicatif (Next.js handler, etc.) │
│ await manager.reserve({ slotId, holderUserId }) │
└──────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ BookingManager (state machine + policy + hooks) │
│ - applyPolicy(resource, slot, input) │
│ - hooks.beforeReserve / afterReserve / etc. │
│ - state transitions pending→confirmed→attended │
└──────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ Repositories (4 × @mostajs/repository) │
│ - resourceRepo, slotRepo, reservationRepo, ruleRepo │
│ - composable avec withTenantScope + withAuditTrail │
└──────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ @mostajs/data-plug → Mongo / Postgres / REST │
└────────────────────────────────────────────────────────┘Le moteur d'availability expansion est in-memory, pure (pas d'effet de bord) :
AvailabilityRule[] + window [from, to] → Slot[] (via expandRules())→ Permet de générer dynamiquement les slots affichables au front sans les pré-persister tous (gain DB).
4. Quick start — how to use
Installation
npm install @mostajs/booking @mostajs/repositoryCas le plus simple — coiffure
import { createBookingManager, type Resource } from '@mostajs/booking'
import { createMemoryRepository } from '@mostajs/repository'
const manager = createBookingManager({
resourceRepo: createMemoryRepository({ collection: 'resources' }),
slotRepo: createMemoryRepository({ collection: 'slots' }),
reservationRepo: createMemoryRepository({ collection: 'reservations' }),
ruleRepo: createMemoryRepository({ collection: 'availability_rules' }),
joinTokenSecret: process.env.BOOKING_SECRET!,
})
// 1. Créer une resource (un coiffeur)
const sarah = await manager.createResource({
kind: 'hairdresser-cut',
name: 'Sarah — Coiffure femme',
capacity: 1,
ownerUserId: 'user-sarah',
policy: {
minNoticeMs: 4 * 3600_000, // 4h avant minimum
maxHorizonMs: 60 * 86400_000, // 60 jours max
bufferAfterMs: 15 * 60_000, // 15min entre 2 rendez-vous
autoConfirmAfterMs: 0, // confirmation immédiate (pas de paiement requis)
},
})
// 2. Définir la disponibilité (mar-sam, 10h-19h, créneaux 30 min)
await manager.setAvailability(sarah.id, [{
resourceId: sarah.id,
kind: 'recurring',
recurring: {
daysOfWeek: [2, 3, 4, 5, 6], // mar-sam
startTime: '10:00',
endTime: '19:00',
slotDurationMin: 30,
timezone: 'Europe/Paris',
},
}])
// 3. Lister les créneaux dispos pour la semaine prochaine
const slots = await manager.getAvailableSlots({
resourceId: sarah.id,
from: Date.now(),
to: Date.now() + 7 * 86400_000,
})
// 4. Réserver un slot
const reservation = await manager.reserve({
slotId: slots[0].id,
holderUserId: 'user-amina',
})
// → { status: 'confirmed', joinToken: '...', ... }Cas avec paiement (resto)
const tableLeBistro = await manager.createResource({
kind: 'restaurant-table',
name: 'Le Bistro — Table 8 couverts',
capacity: 8,
ownerUserId: 'restaurant-le-bistro',
policy: {
cancellationWindow: { freeUntilMs: 2 * 3600_000, lateAction: 'forfeit-payment' },
},
})
const manager2 = createBookingManager({
// ...
hooks: {
beforeReserve: async (input, slot) => {
// Capture le deposit avant de réserver
const result = await stripe.paymentIntents.create({ amount: 1000, currency: 'eur' })
if (!result.succeeded) return { reject: 'Payment failed' }
},
afterReserve: async (reservation) => {
await mailer.send('reservation_confirmed', { email: reservation.metadata?.email })
},
},
})Cas live session (SFU)
const liveClass = await manager.createResource({
kind: 'live-session',
name: 'Cours d\'arabe niveau 2',
capacity: 30, // 30 élèves max
ownerUserId: 'teacher-fatima',
})
await manager.setAvailability(liveClass.id, [{
resourceId: liveClass.id,
kind: 'recurring',
recurring: {
daysOfWeek: [3], // mercredis uniquement
startTime: '14:00', endTime: '15:30',
slotDurationMin: 90,
timezone: 'Africa/Algiers',
},
}])
// L'élève réserve, reçoit joinToken signé
const reservation = await manager.reserve({
slotId: nextWedSlot.id,
holderUserId: 'student-ahmed',
})
// Au moment du WHEP côté @mostajs/media-sfu :
const valid = await manager.verifyJoinToken(reservation.id, tokenFromUrl)
if (!valid) return new Response('Forbidden', { status: 403 })5. API détaillée
createBookingManager(opts) → BookingManager
interface BookingManagerOptions {
resourceRepo: Repository<Resource>
slotRepo: Repository<Slot>
reservationRepo: Repository<Reservation>
ruleRepo: Repository<AvailabilityRule>
/** Secret HMAC pour signer joinTokens. À ne PAS exposer côté client. */
joinTokenSecret: string
hooks?: BookingHooks
onEvent?: (event: BookingEvent) => void | Promise<void>
}BookingManager
Sections (toutes async) :
Resource :
createResource(input)→ResourcegetResource(id)→Resource | nulllistResources(query?)→Resource[]updateResource(id, patch)→ResourcedeleteResource(id)
AvailabilityRule :
setAvailability(resourceId, rules[])— remplace toutes les règleslistRules(resourceId)→AvailabilityRule[]deleteRule(ruleId)
Slot :
listPersistedSlots(query)— slots déjà en DBgetAvailableSlots({ ...query, from, to })— slots persistés + expand des règles dans la fenêtre, exclu conflictscreateSlot(input)— création manuellecancelSlot(slotId, reason?)— annule + cascade sur reservations actives
Reservation :
reserve(input)→Reservation(statuspendingouconfirmedselonautoConfirmAfterMs)confirmReservation(id)— bascule pending → confirmed, incrémente reservedCountcancelReservation(id, reason?)— annule + libère capacité si confirmedattendReservation(id)— check-in (status → attended)markNoShow(id)— pour les confirmed non-attenduslistReservations(query)
Tokens :
verifyJoinToken(reservationId, token)— vérifie HMAC + status
expandRules(rules, window) — Helper pur
import { expandRules } from '@mostajs/booking'
const slots = expandRules(rules, { from: Date.now(), to: Date.now() + 7 * 86400_000 })
// → Slot[] générés (non persistés), triés par startAtUtile pour afficher au front sans toucher la DB.
6. Implémentation — how to impl
Pattern 1 — Module @mostajs/* qui consomme booking
// @mostajs/media-sfu/src/booking-integration.ts
import { createBookingManager, type BookingManager } from '@mostajs/booking'
import { createRepository, withTenantScope, withAuditTrail } from '@mostajs/repository'
import { getCurrentTenantId, getCurrentTenant } from '@mostajs/multitenancy'
export function createSessionBooking(opts: { joinTokenSecret: string }): BookingManager {
const wrap = <T>(coll: string) => withAuditTrail(
withTenantScope(
createRepository({ collection: coll }),
{ getTenantId: getCurrentTenantId },
),
{ getActor: () => getCurrentTenant()?.userId ?? 'system' },
)
return createBookingManager({
resourceRepo: wrap<any>('media_sfu_resources'),
slotRepo: wrap<any>('media_sfu_slots'),
reservationRepo: wrap<any>('media_sfu_reservations'),
ruleRepo: wrap<any>('media_sfu_rules'),
joinTokenSecret: opts.joinTokenSecret,
})
}Pattern 2 — Routes Next.js App Router
// app/api/booking/resources/route.ts
import { getBookingManager } from '@/lib/booking'
export async function POST(req: Request) {
const session = await getServerSession()
if (!session?.user.canManageResources) return new Response('Forbidden', { status: 403 })
const body = await req.json()
const manager = await getBookingManager()
const resource = await manager.createResource({ ...body, ownerUserId: session.user.id })
return Response.json(resource)
}
// app/api/booking/slots/route.ts
export async function GET(req: Request) {
const url = new URL(req.url)
const manager = await getBookingManager()
const slots = await manager.getAvailableSlots({
resourceId: url.searchParams.get('resourceId') ?? undefined,
from: Number(url.searchParams.get('from') ?? Date.now()),
to: Number(url.searchParams.get('to') ?? Date.now() + 7 * 86400_000),
})
return Response.json({ slots })
}
// app/api/booking/slots/[slotId]/reserve/route.ts
export async function POST(req: Request, { params }: { params: Promise<{ slotId: string }> }) {
const session = await getServerSession()
if (!session) return new Response('Unauthorized', { status: 401 })
const { slotId } = await params
const body = await req.json().catch(() => ({}))
const manager = await getBookingManager()
try {
const reservation = await manager.reserve({
slotId,
holderUserId: session.user.id,
partySize: body.partySize ?? 1,
metadata: body.metadata,
})
return Response.json(reservation)
} catch (e) {
return new Response((e as Error).message, { status: 400 })
}
}Pattern 3 — Permission check côté @mostajs/media-sfu
import { createSfuApiHandlers } from '@mostajs/media-sfu'
import { getBookingManager } from '@/lib/booking'
const handlers = createSfuApiHandlers({
sfu,
permissionChecker: async (req, ctx) => {
const url = new URL(req.url)
const reservationId = url.searchParams.get('reservationId')
const token = url.searchParams.get('token')
if (!reservationId || !token) return { forbidden: new Response('Missing credentials', { status: 401 }) }
const manager = await getBookingManager()
const ok = await manager.verifyJoinToken(reservationId, token)
if (!ok) return { forbidden: new Response('Invalid reservation token', { status: 403 }) }
const reservations = await manager.listReservations({ holderUserId: undefined })
const r = reservations.find(x => x.id === reservationId)
// Check-in
if (r) await manager.attendReservation(r.id).catch(() => {})
return { user: r?.holderUserId ?? 'anonymous' }
},
})Pattern 4 — Cron pour passer past status
Les slots dont endAt < now doivent passer en past (état terminal). Cron simple :
import { getBookingManager } from '@/lib/booking'
setInterval(async () => {
const manager = await getBookingManager()
// Implémentation via repo direct ou méthode manager (à venir v0.2 : tickPast)
const past = await manager.listPersistedSlots({
status: ['open'],
to: Date.now(),
})
// for each : update status 'past'
}, 60_000)7. Exemples par domaine
7.1 Médical (Doctolib-like)
const drMartin = await manager.createResource({
kind: 'doctor-visit-15min',
name: 'Dr Martin — Médecin généraliste',
capacity: 1,
ownerUserId: 'user-dr-martin',
policy: { minNoticeMs: 86400_000, maxHorizonMs: 30 * 86400_000, autoConfirmAfterMs: 0 },
})
await manager.setAvailability(drMartin.id, [
{ resourceId: drMartin.id, kind: 'recurring', recurring: {
daysOfWeek: [1, 2, 4, 5], startTime: '09:00', endTime: '12:30', slotDurationMin: 15, timezone: 'Europe/Paris',
}},
{ resourceId: drMartin.id, kind: 'recurring', recurring: {
daysOfWeek: [1, 2, 4, 5], startTime: '14:00', endTime: '18:00', slotDurationMin: 15, timezone: 'Europe/Paris',
}},
{ resourceId: drMartin.id, kind: 'blackout', blackout: {
startAt: Date.parse('2026-08-15'), endAt: Date.parse('2026-08-22'), reason: 'Vacances',
}},
])7.2 Restaurant (OpenTable-like)
const tableBistro = await manager.createResource({
kind: 'restaurant-table',
name: 'Le Bistro — Table principale',
capacity: 8,
ownerUserId: 'restaurant-le-bistro',
policy: { cancellationWindow: { freeUntilMs: 2 * 3600_000, lateAction: 'forfeit-payment' } },
})
// Slots manuels par service (12h-14h, 19h-23h)
for (let day = 0; day < 30; day++) {
const dayStart = Date.now() + day * 86400_000
await manager.createSlot({ resourceId: tableBistro.id, startAt: dayStart + 12*3600_000, endAt: dayStart + 14*3600_000 })
await manager.createSlot({ resourceId: tableBistro.id, startAt: dayStart + 19*3600_000, endAt: dayStart + 23*3600_000 })
}
// Réservation pour 4 personnes
const r = await manager.reserve({ slotId: tonightDinner.id, holderUserId: 'user-1', partySize: 4 })7.3 Salle de réunion
const meetingRoom = await manager.createResource({
kind: 'meeting-room',
name: 'Salle Mont-Blanc',
capacity: 12,
ownerUserId: 'admin-office',
policy: { bufferAfterMs: 15 * 60_000, maxPerUser: 3 },
})
await manager.setAvailability(meetingRoom.id, [{
resourceId: meetingRoom.id, kind: 'recurring', recurring: {
daysOfWeek: [1,2,3,4,5], startTime: '08:00', endTime: '20:00', slotDurationMin: 60, timezone: 'Europe/Paris',
},
}])7.4 Event ticketing
const concertDeJune = await manager.createResource({
kind: 'event',
name: 'Concert de juin — Sala Bolivar',
capacity: 250,
ownerUserId: 'organizer-1',
})
// Un seul slot : l'événement entier
await manager.createSlot({
resourceId: concertDeJune.id,
startAt: Date.parse('2026-06-15T20:00:00+02:00'),
endAt: Date.parse('2026-06-15T23:00:00+02:00'),
})
// 250 reservations en parallèle, partySize 17.5 Beauty salon (Planty-like)
const sarahHaircut = await manager.createResource({
kind: 'hairdresser-cut-30min',
name: 'Sarah — Coupe femme',
capacity: 1,
ownerUserId: 'user-sarah',
policy: {
minNoticeMs: 4 * 3600_000,
bufferAfterMs: 15 * 60_000, // 15 min cleaning
autoConfirmAfterMs: 0,
},
})7.6 Live SFU session (notre cas)
cf. quick start §4 dernier exemple.
8. Politiques (BookingPolicy)
interface BookingPolicy {
minNoticeMs?: number // ex. 86400_000 = 24h
maxHorizonMs?: number // ex. 90 * 86400_000 = 90 jours
maxPerUser?: number // ex. 1 = un seul booking actif à la fois
bufferBeforeMs?: number // ex. 5 * 60_000 = 5 min
bufferAfterMs?: number // ex. 15 * 60_000 = 15 min
cancellationWindow?: {
freeUntilMs: number // 2 * 3600_000 = 2h
lateAction?: 'forfeit-payment' | 'log-warning' | 'block-future'
}
onHostConflict?: 'reject' | 'allow-overlap' | 'warn'
autoConfirmAfterMs?: number // 0 = confirm immédiat (pas de paiement)
}| Policy | Cas d'usage typique |
|---|---|
| minNoticeMs: 24h | médical, beauté (préparation) |
| minNoticeMs: 0 | live session (peut rejoindre last minute) |
| maxHorizonMs: 30 jours | médical (planning court) |
| maxHorizonMs: 365 jours | événements long terme |
| maxPerUser: 1 | coaching, sport (anti-spam booking) |
| bufferAfterMs: 15min | beauté (cleaning), médical (admin) |
| cancellationWindow free 2h | restaurant (forfeit deposit après) |
| autoConfirmAfterMs: 0 | médical, sport (pas de paiement) |
| autoConfirmAfterMs: undefined | restaurant, événement (paiement requis pour confirm) |
9. Hooks (étendre sans modifier le module)
interface BookingHooks {
beforeReserve?: (input, slot) => HookResult
afterReserve?: (reservation, slot) => void
beforeConfirm?: (reservation, slot) => HookResult
afterConfirm?: (reservation, slot) => void
beforeCancel?: (reservation, slot) => HookResult
afterCancel?: (reservation, slot) => void
beforeAttend?: (reservation, slot) => HookResult
afterAttend?: (reservation, slot) => void
}
type HookResult = void | { reject: string }Usage : Stripe deposit
hooks: {
beforeReserve: async (input, slot) => {
const result = await stripe.paymentIntents.create({ amount: 1000, currency: 'eur', customer: input.holderUserId })
if (result.status !== 'succeeded') return { reject: 'Payment failed' }
},
afterCancel: async (reservation, slot) => {
// Refund partiel si dans la fenêtre, sinon forfeit
const minutesBeforeStart = (slot.startAt - Date.now()) / 60_000
if (minutesBeforeStart > 120) await stripe.refunds.create({ payment_intent: reservation.metadata?.stripePaymentId })
},
}Usage : Email confirmation via @mostajs/mailer
hooks: {
afterReserve: async (reservation, slot) => {
await mailer.send('booking_confirmed', {
to: reservation.metadata?.email,
data: {
reservationId: reservation.id,
joinToken: reservation.joinToken,
startAt: new Date(slot.startAt).toLocaleString('fr-FR'),
},
})
},
beforeCancel: async (reservation, slot) => {
// Empêche annulation si < 1h avant
if (slot.startAt - Date.now() < 3600_000) return { reject: 'Too late to cancel' }
},
}10. Time zones & règles récurrentes
Les AvailabilityRule.recurring ont un champ timezone obligatoire (IANA format : Europe/Paris, Africa/Algiers, America/New_York).
Le moteur expandRules :
- Reçoit
window: { from, to }en Unix ms UTC - Pour chaque jour de la fenêtre, vérifie si
daysOfWeekmatche dans la TZ de la règle (pas UTC) - Génère les slots à
startTime/endTimewall-clock dans la TZ de la règle, convertis en Unix ms UTC
Exemple :
- Règle :
daysOfWeek: [1],startTime: '09:00',endTime: '12:00',timezone: 'Europe/Paris' - En été (DST) : lundi 09:00 Paris = 07:00 UTC
- En hiver : lundi 09:00 Paris = 08:00 UTC
- Le moteur gère automatiquement les DST transitions via
Intl.DateTimeFormatnatif Node (zero dep)
Pour des cas plus complexes (RRULE iCalendar : "every 2nd Tuesday", "monthly on last Friday"...) → utiliser luxon ou rrule.js côté consumer + appeler createSlot(input) programmatiquement.
11. Tests
import { describe, it, expect } from 'vitest'
import { createBookingManager } from '@mostajs/booking'
import { createMemoryRepository } from '@mostajs/repository'
describe('BookingManager', () => {
function setup() {
return createBookingManager({
resourceRepo: createMemoryRepository({ collection: 'r' }),
slotRepo: createMemoryRepository({ collection: 's' }),
reservationRepo: createMemoryRepository({ collection: 'rv' }),
ruleRepo: createMemoryRepository({ collection: 'ar' }),
joinTokenSecret: 'test-secret',
})
}
it('réserve un slot et incremente reservedCount', async () => {
const m = setup()
const res = await m.createResource({ kind: 'test', name: 'X', capacity: 2, ownerUserId: 'u-1', policy: { autoConfirmAfterMs: 0 } })
const slot = await m.createSlot({ resourceId: res.id, startAt: Date.now() + 86400_000, endAt: Date.now() + 90000_000 })
const r1 = await m.reserve({ slotId: slot.id, holderUserId: 'a' })
expect(r1.status).toBe('confirmed')
const r2 = await m.reserve({ slotId: slot.id, holderUserId: 'b' })
expect(r2.status).toBe('confirmed')
// Capacity atteinte
await expect(m.reserve({ slotId: slot.id, holderUserId: 'c' }))
.rejects.toThrow(/Slot not bookable/)
})
it('joinToken signé et vérifiable', async () => {
const m = setup()
const res = await m.createResource({ kind: 't', name: 'X', capacity: 10, ownerUserId: 'u', policy: { autoConfirmAfterMs: 0 } })
const slot = await m.createSlot({ resourceId: res.id, startAt: Date.now() + 1000, endAt: Date.now() + 100000 })
const r = await m.reserve({ slotId: slot.id, holderUserId: 'user-x' })
expect(await m.verifyJoinToken(r.id, r.joinToken)).toBe(true)
expect(await m.verifyJoinToken(r.id, 'forged-token')).toBe(false)
})
})12. Roadmap & limites
V0.1 (LIVRÉ)
- ✅ Resource + Slot + Reservation + AvailabilityRule + Policy + Hooks
- ✅ State machine reservation (pending → confirmed → attended/no-show/cancelled)
- ✅ HMAC joinToken signé
- ✅ Recurring availability avec TZ
- ✅ Blackout periods
- ✅ Capacity multi-seat (partySize)
V0.2 (prévu)
- 🔄 Waitlist (module séparé
@mostajs/booking-waitlist) - 🔄 Recurring reservations (pattern : un user réserve N slots récurrents en une commande)
- 🔄 Cron
tickPastnatif pour transitionner les slots passés - 🔄 RRULE iCalendar pour règles complexes (monthly, yearly, by-month-day)
V0.3
- 🔄 Round-robin assignment (module séparé
@mostajs/booking-round-robin) - 🔄 Resource groups (réserver "n'importe quel médecin spécialité X")
V0.4
- 🔄 Continuous slots (Airbnb-style : start/end arbitraire, sans grille fixe)
- 🔄 Pricing engine (variable per slot, saisonnalité)
V1.0
- 🔄 API freeze, tests CI cross-app, publish npm
Limites v0.1
- Pas de waitlist (slot full = error)
- Pas de pricing (utiliser hooks pour intégrer Stripe/Chargily)
- Pas de notifications natives (utiliser hooks +
@mostajs/mailer) - Pas de RRULE complexe (juste daysOfWeek hebdomadaire)
- Pas de recurring reservations en bulk
13. Modules liés
@mostajs/repository— peer dep (4 repositories : resource, slot, reservation, rule)@mostajs/multitenancy— tenant scoping automatique viawithTenantScope@mostajs/url— peer dep optionnelle (helpers URL si on veut composer des URL signées au-dessus du joinToken HMAC)@mostajs/media-sfu— consumer naturel (livre sessions = bookings)@mostajs/mailer— notifications via hooks@mostajs/auth+@mostajs/rbac— permission checks dans les routes booking
📊 Étude état de l'art complète → docs/STATE-OF-THE-ART.md
License : AGPL-3.0-or-later Auteur : Dr Hamid MADANI [email protected]
