Modèle de données
Les onze tables lrvo_booking_*, leurs contraintes, et les deux garanties contre la double réservation.
PostgreSQL. Onze tables, toutes préfixées lrvo_booking_. Créées par lrvo-db init, faites évoluer par migration.
Quatre conventions traversent le schéma :
- un instant est
TIMESTAMPTZ, une date de calendrier estDATE, une heure murale est un entier de minutes depuis minuit local ; - les contraintes
CHECKreflètent exactement les énumérations du code ; - ce qu'un rendez-vous passé veut dire est figé dessus : durée, tampons, lieu, nom du service et nom de l'intervenant sont copiés à la réservation ;
status <> 'cancelled'est l'ensemble qui occupe un créneau. La formulation est négative exprès : un statut ajouté plus tard bloque par défaut plutôt que de fuir.
Les tables
| Table | Contenu |
|---|---|
lrvo_booking_job_lock | Verrou des tâches planifiées, avec durée de vie. |
lrvo_booking_staff | Intervenants. user_id relie facultativement à lrvo_auth_users. |
lrvo_booking_service | Types de rendez-vous, questions supplémentaires comprises en JSONB. |
lrvo_booking_service_staff | Quels intervenants peuvent délivrer quel type. |
lrvo_booking_hours | Horaire hebdomadaire. staff_id nul = l'organisation. |
lrvo_booking_exception | Fermetures et ouvertures exceptionnelles, à une date. |
lrvo_booking_link | Liens de réservation publics. |
lrvo_booking_link_service | Types offerts par un lien. Vide = tous. |
lrvo_booking_link_staff | Intervenants offerts par un lien. Vide = tous. |
lrvo_booking_appointment | Les rendez-vous. |
lrvo_booking_notification | Registre des courriels envoyés. |
Deux jointures, deux sens opposés
lrvo_booking_service_staff est une autorisation : zéro ligne veut dire que le type n'est réservable par personne. L'admin refuse d'activer un type dans cet état.
lrvo_booking_link_service et lrvo_booking_link_staff sont des filtres : zéro ligne veut dire aucun filtre. L'asymétrie est volontaire, et elle est ce qui permet à un lien vide d'offrir tout le catalogue.
Horaires et exceptions
Une plage de lrvo_booking_hours ne traverse pas minuit : end_minute monte à 1440, jamais au-delà. Cela garde chaque plage dans une seule journée civile et un seul régime d'heure avancée.
Deux index uniques partiels, et non un seul index à trois colonnes : NULL n'est jamais égal à NULL, donc l'horaire de l'organisation pourrait autrement accumuler des doublons.
Le chevauchement entre deux plages du même jour est refusé par le code, pas par un CHECK : un CHECK ne voit pas les lignes voisines.
Le rendez-vous
Trois colonnes méritent une explication.
end_at est stocké, pas calculé. timestamptz + interval est marqué STABLE et non IMMUTABLE par PostgreSQL : ni colonne générée, ni CHECK reliant end_at à duration_minutes ne sont possibles. C'est un invariant applicatif, écrit à un seul endroit.
local_date est une dénormalisation assumée. Sans elle, « les rendez-vous d'aujourd'hui » s'écrit start_at AT TIME ZONE '...', ce qui n'utilise aucun index et grave le fuseau dans chaque requête. Le prix à payer : changer LRVO_TIMEZONE après coup rend cette colonne fausse pour les rendez-vous déjà pris, et demande une migration de recalcul.
seat_index est le numéro de place dans un créneau de groupe, attribué sous verrou. Il transforme la capacité en garantie d'index unique au lieu d'une convention de code.
Absence de double réservation
Deux garanties superposées.
Un verrou consultatif de transaction sur l'intervenant, pris en première instruction de la transaction de réservation. Il sérialise les réservations concurrentes de la même personne, et il tombe au commit comme au rollback : rien à libérer à la main.
Un index unique partiel sur (staff_id, start_at, seat_index) WHERE status <> 'cancelled'. Même un chemin d'écriture qui contournerait la transaction ne peut pas produire deux fois la place 0 d'un même créneau.
La capacité n'autorise pas N rendez-vous qui se chevauchent, elle autorise N participants dans le même créneau de groupe, identifié par le type, le début et la durée. Tout autre chevauchement est un conflit : une personne ne peut pas être à deux endroits.
Ce qui a été écarté, et pourquoi :
EXCLUDE USING gistdemande l'extensionbtree_gist, que le module refuse d'exiger, et ne sait pas exprimer « jusqu'à N chevauchements ».SELECT ... FOR UPDATEsur les rendez-vous ne sert à rien : la ligne en conflit n'existe pas encore. Deux transactions qui réservent le premier créneau d'une journée vide lisent zéro ligne chacune et insèrent toutes les deux.
Deux pièges de maintenance
Les bornes de la requête de conflit sont dérivées des CHECK. Relever la durée maximale ou un tampon sans relever ces deux constantes fait rater des collisions, silencieusement, et seulement pour les rendez-vous longs. Elles vivent côte à côte dans constants.js, avec le commentaire qui explique le lien.
Le schéma déjà déployé ne change que par migration. createTables() saute une table existante : modifier un CREATE TABLE n'atteint que les bases neuves. Et dans une migration, utiliser ctx.tableExists et ctx.columnExists, jamais les fonctions importées de @lrvo/core/database, qui passent par le pool et ne voient pas la transaction en cours. Voir Écrire une migration.