Schéma de base de données
Schéma Drizzle PostgreSQL 18 détaillé — découpé par domaine, pgEnum centralisés, conventions strictes
Source de vérité : les fichiers
packages/db/src/schema/*.ts. Cette page documente les conventions, le découpage, et les colonnes structurantes par domaine. Pour chaque colonne précise, aller dans le fichier Drizzle correspondant.
Conventions
| Élément | Convention |
|---|---|
| IDs | uuid générés côté DB via gen_random_uuid() (extension pgcrypto active par défaut sur PG 18) |
| Timestamps | timestamp with time zone avec defaultNow() et $onUpdate(() => new Date()) pour updatedAt |
| Soft delete | `deletedAt: timestamp |
| Enums | pgEnum centralisés dans packages/db/src/schema/enums.ts |
| JSONB | pour les champs structurés flexibles (metadata, items d'une commande, credentials integration) — validé par Zod au runtime |
| FK | toutes les foreign keys ont un index (idx suffix) |
| Unique | contraintes avec suffix _uq |
| Multi-tenant | chaque table métier a organizationId indexé en premier |
| Extensions | uuid-ossp, pgcrypto, pg_trgm, vector chargées par docker/postgres/init.sql |
Découpage des fichiers
packages/db/src/schema/
├── enums.ts (tous les pgEnum centralisés)
├── auth.ts (Better Auth : user, session, account, verification)
├── organizations.ts (organization, member, invitation)
├── guests.ts (guest, guest_group)
├── rooms.ts (room)
├── bookings.ts (restaurant_booking, room_service_order, laundry_order, spa_booking)
├── conversations.ts (conversation, message)
├── tickets.ts (ticket, ticket_comment)
├── staff.ts (staff_note, note_comment)
├── menus.ts (menu_category, menu_item)
├── integrations.ts (integration, integration_sync_log)
├── analytics.ts (activity_log, interaction_log, hotel_event)
└── index.ts (re-exports explicites)Enums centralisés
Tous les pgEnum dans un seul fichier pour éviter les imports circulaires et visualiser d'un coup les statuts.
import { pgEnum } from "drizzle-orm/pg-core";
// ─── Auth / Org ──────────────────────────────────────────────
export const memberRoleEnum = pgEnum("member_role", [
"owner", "admin", "manager", "staff", "guest",
]);
// ─── Check-in ────────────────────────────────────────────────
export const checkInStatusEnum = pgEnum("check_in_status", [
"pending", "invited", "completed", "arrived", "checked_out",
]);
// ─── Room ────────────────────────────────────────────────────
export const roomStatusEnum = pgEnum("room_status", [
"available", "occupied", "cleaning", "maintenance", "reserved",
]);
export const roomTypeEnum = pgEnum("room_type", [
"single", "double", "twin", "suite", "deluxe", "penthouse",
]);
// ─── Bookings ────────────────────────────────────────────────
export const restaurantBookingStatusEnum = pgEnum("restaurant_booking_status", [
"requested", "confirmed", "seated", "done", "cancelled", "no_show",
]);
export const roomServiceOrderStatusEnum = pgEnum("room_service_order_status", [
"pending", "preparing", "delivering", "delivered", "cancelled",
]);
export const laundryOrderStatusEnum = pgEnum("laundry_order_status", [
"pending", "collected", "washing", "ready", "delivered", "cancelled",
]);
export const spaBookingStatusEnum = pgEnum("spa_booking_status", [
"requested", "confirmed", "in_progress", "done", "cancelled",
]);
// ─── Payments ────────────────────────────────────────────────
export const paymentStatusEnum = pgEnum("payment_status", [
"pending", "authorized", "captured", "refunded", "failed", "cancelled",
]);
// ─── Conversations ───────────────────────────────────────────
export const conversationAudienceEnum = pgEnum("conversation_audience", [
"guest_ai", "guest_staff", "staff_internal",
]);
export const messageRoleEnum = pgEnum("message_role", [
"user", "assistant", "staff", "system",
]);
// ─── Tickets ─────────────────────────────────────────────────
export const ticketStatusEnum = pgEnum("ticket_status", [
"open", "in_progress", "waiting", "resolved", "closed", "cancelled",
]);
export const ticketPriorityEnum = pgEnum("ticket_priority", [
"low", "medium", "high", "urgent",
]);
export const ticketCategoryEnum = pgEnum("ticket_category", [
"complaint", "special_request", "maintenance", "housekeeping",
"billing", "concierge", "other",
]);
// ─── Staff notes ─────────────────────────────────────────────
export const staffNoteStatusEnum = pgEnum("staff_note_status", [
"pending", "in_progress", "done", "cancelled",
]);
export const serviceTypeEnum = pgEnum("service_type", [
"restaurant", "spa", "room_service", "housekeeping", "laundry",
"front_office", "maintenance", "meeting", "other",
]);
// ─── Integrations ────────────────────────────────────────────
export const integrationProviderEnum = pgEnum("integration_provider", [
"mews", "opera", "cloudbeds", "protel", "apaleo", "booking", "expedia",
]);
export const integrationSyncStatusEnum = pgEnum("integration_sync_status", [
"idle", "syncing", "error",
]);
export const reservationStateEnum = pgEnum("reservation_state", [
"inquiry", "confirmed", "checked_in", "checked_out", "canceled",
]);
export const bookingChannelEnum = pgEnum("booking_channel", [
"booking", "expedia", "airbnb", "hrs", "hotelbeds", "agoda",
"direct", "mews", "other",
]);
// ─── Menus ───────────────────────────────────────────────────
export const menuCategoryTypeEnum = pgEnum("menu_category_type", [
"breakfast", "lunch", "dinner", "drinks", "room_service",
"spa", "laundry", "restaurant",
]);
// ─── Analytics ───────────────────────────────────────────────
export const activityTypeEnum = pgEnum("activity_type", [
"booking_created", "booking_updated", "booking_cancelled",
"chat_started", "chat_resolved",
"payment_received", "payment_refunded",
"guest_arrived", "guest_checked_out",
"staff_login", "staff_action",
]);
export const hotelEventTypeEnum = pgEnum("hotel_event_type", [
"hotel_event", "conference", "wedding", "meeting", "private_event",
]);Pourquoi tout centralisé : évite les imports circulaires quand plusieurs tables partagent un même enum (ex: paymentStatusEnum utilisé par 4 tables de bookings).
Auth (auth.ts)
Tables standard Better Auth 1.5.5 :
// user, session, account, verification
// → géré par @better-auth/drizzle adapter
// → on ne les modifie pas manuellementChamps notables :
user.id:text(Better Auth génère ses propres IDs, pas uuid)user.email: unique global, indexésession.token: clé qu'on retrouve côté cookiebetter-auth.session_tokensession.activeOrganizationId: ajouté par l'organization plugin
Organizations (organizations.ts)
organization:
- id (uuid)
- name
- slug (unique)
- logo_url
- metadata (JSONB : address, timezone, currency, locale, features)
- created_at, updated_at
member:
- id (uuid)
- organization_id (FK → organization, CASCADE)
- user_id (FK → user, CASCADE)
- role (memberRoleEnum)
- created_at, updated_at
UNIQUE (organization_id, user_id)
invitation:
- id (uuid)
- organization_id, email, role, inviter_id, token, expires_at, statusIndex :
member(organization_id, user_id)uniquemember(user_id)pour "mes orgs"invitation(token)unique
Guests (guests.ts)
guest:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- user_id (FK → user, nullable) # lié au signup après coup
- guest_group_id (FK → guest_group, nullable)
- room_id (FK → room, nullable)
- first_name, last_name (text)
- email (text, indexé, unique par org)
- phone (text)
- is_vip (boolean, default false)
- check_in_status (checkInStatusEnum, default "pending")
- check_in_invited_at, check_in_completed_at, check_in_arrived_at, check_out_at (timestamp)
- check_in_date, check_out_date (date)
- external_guest_id (text, indexé) # ID PMS
- external_reservation_id (text, indexé) # ID résa PMS
- external_source (text) # "mews", "opera"...
- external_channel (bookingChannelEnum) # "booking.com", etc.
- notes (text)
- deleted_at (nullable)
- created_at, updated_at
UNIQUE (organization_id, email)
UNIQUE (organization_id, external_guest_id, external_source) WHERE external_guest_id IS NOT NULL
guest_group:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- name
- contact_person, contact_email, contact_phone
- size (int)
- notes
- created_at, updated_atIndex :
guest(organization_id, check_in_status)— filtrage cardexguest(organization_id, email)uniqueguest(organization_id, external_guest_id, external_source)unique partielleguest(user_id)— lookup "mon profil guest"
Rooms (rooms.ts)
room:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- room_number (text, unique par org)
- floor (int)
- room_type (roomTypeEnum, default "double")
- status (roomStatusEnum, default "available")
- price_per_night (numeric(10, 2))
- amenities (JSONB : wifi, view, jacuzzi, balcony, ...)
- capacity (int, default 2)
- external_room_id (text, indexé)
- external_source (text)
- created_at, updated_at
UNIQUE (organization_id, room_number)Bookings (bookings.ts)
4 tables, mêmes colonnes communes + spécifiques. Pas de table commune abstraite — on duplique les colonnes pour rester lisible et permettre des types/contraintes distincts.
Colonnes communes
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- guest_id (FK → guest, INDEXED, nullable si user_id direct)
- user_id (FK → user, nullable)
- status (enum spécifique)
- payment_intent_id (text)
- payment_status (paymentStatusEnum, default "pending")
- total_amount (numeric(10, 2))
- currency (text, default "EUR")
- created_at, updated_at, deleted_atrestaurant_booking
+ people_count (int)
+ date (date)
+ time (time)
+ special_request (text)
+ confirmed_at, seated_at, done_at, cancelled_at, no_show_at (timestamp)room_service_order
+ items (JSONB : [{ menu_item_id, name, quantity, unit_price, options? }])
+ delivery_fee (numeric)
+ delivery_address (JSONB : { room, floor, building, instructions? })
+ preparing_at, delivering_at, delivered_at, cancelled_at (timestamp)laundry_order
+ items (JSONB : [{ type, quantity, unit_price, notes? }])
+ special_request (text)
+ collected_at, washing_at, ready_at, delivered_at, cancelled_at (timestamp)spa_booking
+ service_id (FK → menu_item)
+ duration_minutes (int)
+ date (date)
+ time (time)
+ therapist_id (text, nullable)
+ confirmed_at, in_progress_at, done_at, cancelled_at (timestamp)Conversations (conversations.ts)
Un seul système unifié (contrairement à l'ancien projet qui avait guest_conversation + chat_conversation).
conversation:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- audience (conversationAudienceEnum) # guest_ai | guest_staff | staff_internal
- guest_id (FK → guest, nullable)
- user_id (FK → user, nullable)
- assigned_staff_id (FK → user, nullable) # pour guest_staff escalade
- title (text) # AI-generated
- summary (text) # AI-generated, visible cardex
- priority (ticketPriorityEnum, nullable)
- is_resolved (boolean, default false)
- resolved_at (timestamp, nullable)
- metadata (JSONB : service_type, tags, AI cost cumul)
- created_at, updated_at, deleted_at
message:
- id (uuid)
- conversation_id (FK, CASCADE, INDEXED)
- role (messageRoleEnum) # user | assistant | staff | system
- sender_user_id (FK → user, nullable) # pour staff ou guest connecté
- content (text)
- metadata (JSONB : tool_calls, ai_cost, attachments)
- sent_at (timestamp, default now)
- read_at (timestamp, nullable)Index :
conversation(organization_id, is_resolved, updated_at DESC)— liste dashboardconversation(guest_id)— historique d'un guestmessage(conversation_id, sent_at)— thread view
Tickets (tickets.ts)
ticket:
- id (uuid)
- ticket_number (text, unique par org) # "TKT-00042"
- organization_id (FK, CASCADE, INDEXED)
- conversation_id (FK → conversation, nullable) # lien vers chat escaladé
- guest_id (FK → guest, nullable)
- room_id (FK → room, nullable)
- category (ticketCategoryEnum)
- priority (ticketPriorityEnum)
- status (ticketStatusEnum, default "open")
- title (text)
- description (text)
- assigned_to_id (FK → user, nullable)
- created_by_id (FK → user)
- resolved_at, closed_at (timestamp, nullable)
- deleted_at (nullable)
- created_at, updated_at
UNIQUE (organization_id, ticket_number)
ticket_comment:
- id (uuid)
- ticket_id (FK, CASCADE, INDEXED)
- user_id (FK → user)
- content (text)
- is_internal (boolean) # visible staff only
- created_atSéquence du ticket_number : trigger Postgres qui incrémente par org.
Staff notes (staff.ts)
staff_note:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- title (text)
- content (text)
- service_type (serviceTypeEnum, nullable)
- priority (ticketPriorityEnum, default "medium")
- status (staffNoteStatusEnum, default "pending")
- due_date (timestamp, nullable)
- room_id (FK, nullable)
- guest_id (FK, nullable)
- assigned_to_id (FK → user, nullable)
- created_by_id (FK → user)
- completed_at (nullable)
- deleted_at (nullable)
- created_at, updated_at
note_comment:
- id (uuid)
- note_id (FK, CASCADE)
- user_id (FK → user)
- action (text) # "status_changed", "assigned", "commented"
- previous_value, new_value (text, nullable)
- content (text, nullable)
- created_atMenus (menus.ts)
menu_category:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- category_type (menuCategoryTypeEnum)
- name (text)
- description (text)
- display_order (int)
- is_active (boolean, default true)
menu_item:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- category_id (FK → menu_category, INDEXED)
- name, description
- price (numeric(10, 2))
- currency (text)
- image_url (text)
- duration_minutes (int, nullable) # pour spa
- option_groups (JSONB : [{ name, options: [{ name, price_delta }] }])
- allergens (JSONB : string[])
- is_featured (boolean)
- is_active (boolean)Integrations (integrations.ts)
integration:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- provider (integrationProviderEnum)
- name (text) # "Mews - Oceania Paris"
- credentials (JSONB encrypted) # tokens, keys
- platform_url (text)
- external_property_id (text) # "enterpriseId" chez Mews
- config (JSONB : options spécifiques)
- is_active (boolean, default true)
- last_sync_at (timestamp, nullable)
- sync_status (integrationSyncStatusEnum, default "idle")
- sync_error (text, nullable)
- webhook_secret (text) # pour HMAC
- created_at, updated_at
UNIQUE (organization_id, provider)
integration_sync_log:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- integration_id (FK → integration, CASCADE, INDEXED)
- provider (text)
- event_type (text) # "full_sync" | "webhook" | "manual"
- external_entity_id (text, nullable)
- bell_entity_type (text, nullable) # "guest" | "room" | "reservation"
- bell_entity_id (text, nullable)
- action (text) # "created" | "updated" | "skipped" | "error"
- error_message (text, nullable)
- metadata (JSONB)
- created_atChiffrement des credentials : au niveau app (AES-256 avec une clé dans ENCRYPTION_KEY env var), pas au niveau Postgres. La colonne est juste JSONB mais son contenu est toujours chiffré avant insert.
Analytics (analytics.ts)
activity_log:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- activity_type (activityTypeEnum)
- user_id (FK → user, nullable)
- guest_id (FK → guest, nullable)
- entity_type (text, nullable)
- entity_id (text, nullable)
- description (text)
- metadata (JSONB)
- occurred_at (timestamp, INDEXED) # index brin temporel
interaction_log:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- interaction_type (text)
- user_id, conversation_id (FK)
- duration_seconds (int)
- revenue_generated (numeric)
- was_successful (boolean)
- metadata (JSONB)
- occurred_at (timestamp, INDEXED)
hotel_event:
- id (uuid)
- organization_id (FK, CASCADE, INDEXED)
- title (text)
- event_type (hotelEventTypeEnum)
- description
- start_date, end_date (timestamp)
- capacity (int, nullable)
- location (text) # ex: "Salon Versailles"
- created_at, updated_atNote : on n'utilise PAS TimescaleDB au MVP. Tables Postgres standard avec index temporel sur occurred_at. Si le volume dépasse 10M rows sur un an, on migre vers hypertables TimescaleDB.
Relations et joins fréquents
-- Cardex : liste des guests arrivés avec leur chambre
SELECT g.*, r.room_number, r.floor
FROM guest g
LEFT JOIN room r ON r.id = g.room_id
WHERE g.organization_id = $1
AND g.check_in_status = 'arrived'
AND g.deleted_at IS NULL
ORDER BY g.check_in_arrived_at DESC;
-- Chats actifs staff
SELECT c.*, g.first_name, g.last_name, r.room_number
FROM conversation c
LEFT JOIN guest g ON g.id = c.guest_id
LEFT JOIN room r ON r.id = g.room_id
WHERE c.organization_id = $1
AND c.audience IN ('guest_ai', 'guest_staff')
AND c.is_resolved = false
ORDER BY c.updated_at DESC
LIMIT 100;
-- Revenue par canal ce mois
SELECT
g.external_channel,
COUNT(*) AS bookings,
SUM(CASE
WHEN b.payment_status = 'captured' THEN b.total_amount
ELSE 0
END) AS revenue
FROM guest g
JOIN room_service_order b ON b.guest_id = g.id
WHERE g.organization_id = $1
AND b.created_at >= date_trunc('month', now())
GROUP BY g.external_channel
ORDER BY revenue DESC;Évolution du schéma
- Toute migration doit être réversible —
drizzle-kit generateproduit des fichiers SQL horodatés danspackages/db/migrations/. Ledrizzle-kit migrate:rollbackrevient en arrière. - Les breaking changes (drop colonne, rename table) passent par une double migration : d'abord ajouter la nouvelle structure, deployer, migrer les données, puis supprimer l'ancienne.
- Les index volumineux (> 100 MB attendus) sont créés avec
CONCURRENTLYen production pour ne pas verrouiller la table.
Vérifications automatiques
Script CI scripts/check-schema-integrity.ts qui vérifie :
- Chaque
pgEnumdéfini est utilisé par au moins une table - Chaque FK a un index
- Chaque table métier a
organizationIdetdeletedAtsi critique - Aucune colonne
timestampsanstimestamp with time zone
Bloque la PR si un check fail.
Lien avec les autres pages
- Entities — les concepts narrés avant les colonnes
- State machines — les
*_statusenums et leurs transitions - API design — comment le schema est exposé via Eden Treaty
- ADR-05 state machines