State machines
Transitions de statut pour check-in, room, bookings, tickets, reservation PMS — validées côté DB et API
Règle (ADR-05) : chaque état métier vit dans un
pgEnumcôté DB et dans une fonction pure côté API. Toute mutation passe parassertTransitionXxx()avant d'écrire.
Liste des machines
| Domaine | Fichier | États | Transitions |
|---|---|---|---|
| Check-in guest | domain/check-in.machine.ts | 5 | 6 |
| Room status | domain/room-status.machine.ts | 5 | graphe libre |
| Room service order | domain/room-service.machine.ts | 5 | 5 |
| Laundry order | domain/laundry.machine.ts | 5 | 5 |
| Restaurant booking | domain/restaurant.machine.ts | 6 | 7 |
| Spa booking | domain/spa.machine.ts | 5 | 6 |
| Reservation PMS (miroir) | domain/reservation.machine.ts | 5 | 6 |
| Ticket | domain/ticket.machine.ts | 5 | 7 |
Toutes testées exhaustivement (matrice complète autorisées/interdites) — voir convention testing.
1. Check-in guest
Le cœur du flow Bell. Piloté à la fois par les actions staff et guest.
pending ──────► invited ──────► completed ──────► arrived ──────► checked_out
│ │ │
│ └─ (retour possible si annulation) ─────┘
│
│ initial state quand guest crééÉtats
| État | Qui le déclenche | Ce que ça signifie |
|---|---|---|
pending | staff crée le guest | pas d'email envoyé, pas de pré-check-in |
invited | staff clique "Send check-in email" | email envoyé, URL unique générée, Reacher OK |
completed | guest a fini le flow digital (signup → upsells → payment/skip) | prêt physiquement à l'hôtel, peut être attendu à la réception |
arrived | staff confirme l'arrivée physique dans le cardex | PWA guest débloquée, bridge PMS startReservation(), room passe à occupied |
checked_out | staff déclenche le checkout | PWA verrouillée, bridge PMS processReservation(), room passe à cleaning |
Table de transitions
export const checkInTransitions = {
pending: ["invited"],
invited: ["completed", "pending"], // cancel invite possible
completed: ["arrived"],
arrived: ["checked_out"],
checked_out: [], // terminal
} as const;2. Room status
Ne suit pas un cycle linéaire — c'est un graphe libre piloté par les actions guest + staff + PMS.
┌──────────────► occupied ─┐
│ │
reserved ▼
▲ cleaning
│ │
available ◄───────────────────┘
▲
│
maintenanceÉtats
| État | Quand |
|---|---|
available | chambre prête à recevoir un guest |
reserved | chambre bloquée pour une réservation future (pas encore arrivée) |
occupied | guest à l'intérieur (check-in confirmé) |
cleaning | housekeeping en cours (après checkout ou stayover) |
maintenance | hors service (travaux, panne) |
Transitions autorisées
export const roomStatusTransitions = {
available: ["reserved", "occupied", "maintenance"],
reserved: ["occupied", "available", "maintenance"],
occupied: ["cleaning", "maintenance"],
cleaning: ["available", "maintenance"],
maintenance: ["available", "cleaning"],
} as const;Pas de transition occupied → available directe : il faut passer par cleaning. Pas de transition maintenance → occupied directe : on nettoie d'abord.
3. Room service order
pending ──► preparing ──► delivering ──► delivered
│ │
└──► cancelled ◄──┘
│
(cancelled accessible depuis pending et preparing uniquement)États
| État | Qui déclenche |
|---|---|
pending | guest passe commande |
preparing | staff cuisine accepte (toast dashboard) |
delivering | runner part livrer la chambre |
delivered | runner confirme la livraison (signature ou photo) |
cancelled | guest ou staff annule (rembourse le PaymentIntent) |
Table
export const roomServiceTransitions = {
pending: ["preparing", "cancelled"],
preparing: ["delivering", "cancelled"],
delivering: ["delivered"],
delivered: [],
cancelled: [],
} as const;Volontairement : pas de delivering → cancelled. Si le runner est parti, le guest paie.
4. Laundry order
pending ──► collected ──► washing ──► ready ──► delivered
│ │ │ │
└──► cancelled ◄──────────────┘ │
│
(ready → cancelled
pas autorisé,
tout est lavé)| État | Quand |
|---|---|
pending | guest a commandé depuis la PWA |
collected | housekeeping a pris le linge en chambre |
washing | en cours de lavage |
ready | lavé, prêt à livrer |
delivered | livré en chambre |
cancelled | avant collection uniquement |
5. Restaurant booking
requested ──► confirmed ──► seated ──► done
│ │ │
└──► cancelled ◄────────────┘
│
└──► no_show (heure dépassée + pas séjour)| État | Quand |
|---|---|
requested | guest demande via PWA, staff n'a pas encore confirmé |
confirmed | staff ou IA confirme la dispo |
seated | guest présent à sa table (staff clique) |
done | repas terminé, facture payée (room charge ou Stripe) |
cancelled | annulé avant seated |
no_show | heure + 30 min dépassée, pas présent |
6. Spa booking
Similaire à restaurant, mais avec in_progress (soin en cours) :
requested ──► confirmed ──► in_progress ──► done
│ │ │
└──► cancelled ◄──────────────┘7. Reservation PMS (miroir)
Quand une réservation vient du PMS via sync, on la mappe dans Bell :
inquiry ──► confirmed ──► checked_in ──► checked_out
│ │
└─► canceled ◄┘États normalisés
| État normalisé | Mews (exemple) | Signifie |
|---|---|---|
inquiry | (rare) | demande non confirmée côté PMS |
confirmed | Confirmed | réservation payée/garantie, pas encore arrivé |
checked_in | Started | arrivé à l'hôtel |
checked_out | Processed | parti |
canceled | Canceled / Optional | annulé |
Important : cette machine est miroir du PMS. On ne fait pas nous-même une transition confirmed → checked_in — c'est le PMS (via webhook ou sync) qui nous dit. Nos own transitions se font sur guest.check_in_status (cf. machine #1).
8. Ticket
open ──► in_progress ──► waiting ──► resolved ──► closed
│ │ │ │
└──► cancelled ◄──────────────┴─────────────┘| État | Quand |
|---|---|
open | staff crée le ticket depuis un chat |
in_progress | un staff se l'assigne |
waiting | bloqué (attend info guest, fournisseur…) |
resolved | solution apportée, guest notifié |
closed | fermeture définitive, plus de modif |
cancelled | créé par erreur |
Pattern d'implémentation (référence)
Chaque machine suit cette structure dans packages/api/src/domain/<domain>.machine.ts :
import { checkInStatusEnum } from "@bell/db/schema/enums";
export type CheckInStatus = (typeof checkInStatusEnum.enumValues)[number];
export const checkInTransitions = {
pending: ["invited"],
invited: ["completed", "pending"],
completed: ["arrived"],
arrived: ["checked_out"],
checked_out: [],
} as const satisfies Record<CheckInStatus, readonly CheckInStatus[]>;
export function canTransitionCheckIn(from: CheckInStatus, to: CheckInStatus): boolean {
return checkInTransitions[from].includes(to as never);
}
export class InvalidCheckInTransitionError extends Error {
readonly code = "INVALID_CHECK_IN_TRANSITION";
constructor(public from: CheckInStatus, public to: CheckInStatus) {
super(`Invalid check-in transition: ${from} → ${to}`);
this.name = "InvalidCheckInTransitionError";
}
}
export function assertTransitionCheckIn(from: CheckInStatus, to: CheckInStatus): void {
if (!canTransitionCheckIn(from, to)) {
throw new InvalidCheckInTransitionError(from, to);
}
}Et le test file :
import { describe, test, expect } from "bun:test";
import { checkInStatusEnum } from "@bell/db/schema/enums";
import {
canTransitionCheckIn,
assertTransitionCheckIn,
InvalidCheckInTransitionError,
checkInTransitions,
} from "./check-in.machine";
describe("check-in state machine", () => {
// Matrice exhaustive
for (const from of checkInStatusEnum.enumValues) {
for (const to of checkInStatusEnum.enumValues) {
const allowed = checkInTransitions[from].includes(to as never);
test(`${allowed ? "allows" : "forbids"} ${from} → ${to}`, () => {
expect(canTransitionCheckIn(from, to)).toBe(allowed);
});
}
}
test("assert throws InvalidCheckInTransitionError on forbidden transition", () => {
expect(() => assertTransitionCheckIn("arrived", "pending"))
.toThrow(InvalidCheckInTransitionError);
});
test("every checkInStatus value is a valid machine key", () => {
for (const status of checkInStatusEnum.enumValues) {
expect(checkInTransitions[status]).toBeDefined();
}
});
});Usage dans les services
import { assertTransitionCheckIn } from "../../domain/check-in.machine";
export async function confirmGuestArrival(opts: {
organizationId: string;
guestId: string;
}) {
return await db.transaction(async (tx) => {
const current = await repo.getGuestById(tx, opts);
if (!current) throw new NotFoundError("guest");
// Invalide la transition si elle n'est pas autorisée
assertTransitionCheckIn(current.checkInStatus, "arrived");
await repo.setCheckInStatus(tx, opts.guestId, "arrived");
// ... suite
});
}Génération automatique (future)
À terme, on peut générer un diagramme Mermaid depuis les tables de transition pour le publier dans Fumadocs. Script dans scripts/generate-state-diagrams.ts qui lit chaque .machine.ts et génère un .mdx avec mermaid. Pas MVP, mais envisagé si on a + de 10 machines.
Lien avec les autres pages
- ADR-05 state machines — pourquoi ce pattern et pas XState
- Entities — les concepts métier que ces machines pilotent
- Database schema — les
pgEnumcorrespondants - Conventions testing — règle : 100 % couverture des machines