npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

  1. Cas d'usage couverts
  2. Concepts
  3. Architecture
  4. Quick start — how to use
  5. API détaillée
  6. Implémentation — how to impl
  7. Exemples par domaine
  8. Politiques (BookingPolicy)
  9. Hooks (étendre sans modifier le module)
  10. Time zones & règles récurrentes
  11. Tests
  12. Roadmap & limites
  13. 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/repository

Cas 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)Resource
  • getResource(id)Resource | null
  • listResources(query?)Resource[]
  • updateResource(id, patch)Resource
  • deleteResource(id)

AvailabilityRule :

  • setAvailability(resourceId, rules[]) — remplace toutes les règles
  • listRules(resourceId)AvailabilityRule[]
  • deleteRule(ruleId)

Slot :

  • listPersistedSlots(query) — slots déjà en DB
  • getAvailableSlots({ ...query, from, to }) — slots persistés + expand des règles dans la fenêtre, exclu conflicts
  • createSlot(input) — création manuelle
  • cancelSlot(slotId, reason?) — annule + cascade sur reservations actives

Reservation :

  • reserve(input)Reservation (status pending ou confirmed selon autoConfirmAfterMs)
  • confirmReservation(id) — bascule pending → confirmed, incrémente reservedCount
  • cancelReservation(id, reason?) — annule + libère capacité si confirmed
  • attendReservation(id) — check-in (status → attended)
  • markNoShow(id) — pour les confirmed non-attendus
  • listReservations(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 startAt

Utile 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 1

7.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 :

  1. Reçoit window: { from, to } en Unix ms UTC
  2. Pour chaque jour de la fenêtre, vérifie si daysOfWeek matche dans la TZ de la règle (pas UTC)
  3. Génère les slots à startTime/endTime wall-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.DateTimeFormat natif 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 tickPast natif 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


📊 É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]