Reciprok Docs
Données

Schema de base de donnees

Schemas Drizzle PostgreSQL detailles

Source de vérité : packages/db/src/schema/. Cette page reste un résumé, quand un doute persiste, le code prime.

Conventions

  • IDs : uuid générés côté DB (gen_random_uuid())
  • Timestamps : timestamp avec defaultNow() et $onUpdate pour updatedAt
  • Soft delete : seulement sur member (deletedAt), RGPD
  • Enums : Postgres natifs via pgEnum (perf + intégrité)
  • Vector : extension pgvector via custom type Drizzle (schema/pgvector.ts)
  • JSONB : pour les champs structurés mais flexibles (payloads timeline, propriétés rooms, extra fields pool, transport, pricing extras)
  • Indexes : sur toutes les FK et les colonnes de filtre fréquent
  • legacyId (text) sur les entités importées de Reciprok V1, clé de dédup stable entre re-syncs

Extensions Postgres requises

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS vector;

Découpage des fichiers

packages/db/src/schema/
├── enums.ts                  pgEnums centralisés
├── pgvector.ts               custom type Drizzle pour vector(N)
├── auth.ts                   Better Auth + user_role enum (dev / admin / user)
├── members.ts                pool, group, member, member_photo, tag_category, tag, member_tag
├── rooms.ts                  room, room_capacity
├── organizers.ts             organizer
├── requests.ts               request, request_date, request_result, catalog,
│                              request_availability_check, request_positioning_check,
│                              request_requalification
├── request-labels.ts         request_label, request_label_assignment (N:M)
├── attributes.ts             attribute_type, attribute_option, member_attribute, request_attribute
├── timeline.ts               timeline_event
├── ai.ts                     ai_conversation, ai_message, knowledge_entry, training_session
├── ai-usage.ts               ai_usage (logs tokens + coûts EUR par user/jour)
├── communications.ts         email_thread, email_message
├── commissions.ts            commission, commission_rule
├── notifications.ts          notification, push_subscription
├── whatsapp.ts               whatsapp_message
├── magic-tokens.ts           magic_token (member / organizer access)
├── member-info-requests.ts   member_info_request (fiche-membre via magic link)
├── travel-estimates.ts       travel_estimate_cache (IA travel times, sha256-keyed)
├── tickets.ts                ticket, ticket_comment, ticket_attachment
├── partners.ts               partner, partner_photo
└── index.ts                  re-export *

Enums centralisés

packages/db/src/schema/enums.ts
memberStatusEnum         "active" | "inactive" | "configuring"
roomConfigurationEnum    "meeting" | "u_shape" | "theater" | "cabaret" | "classroom" | "banquet" | "cocktail"
organizerTypeEnum        "company" | "individual" | "agency" | "traiteur" | "prestataire"
requestStatusEnum        "new" | "qualifying" | "searching" | "catalog_sent" | "negotiating" | "won" | "commission" | "completed" | "lost"
requestSourceEnum        "manual" | "email" | "voice" | "ai_agent"
requestFormatEnum        "cocktail" | "seated" | "conference"
pipelineStageEnum        "search_result" | "catalog_recommended" | "catalog_compatible" | "sent" | "negotiating" | "won" | "lost"
addedByEnum              "ai" | "user"
updatedByEnum            "member" | "team" | "ai"
actorEnum                "user" | "ai" | "system" | "member" | "organizer"
knowledgeTypeEnum        "general_description" | "room_description" | "pricing" | "ambiance" | "availability_rule" | "internal_note" | "audit_note"
knowledgeSourceEnum      "voice_transcript" | "manual" | "audit" | "ai_structured"
aiMessageRoleEnum        "user" | "assistant" | "tool"
commissionStatusEnum     "pending" | "invoiced" | "paid" | "cancelled"
commissionScopeEnum      "default" | "member" | "request"
notificationTypeEnum     "new_request" | "qualification_response" | "availability_response" | "event_passed" | "inactivity_alert" | "ai_suggestion"
magicTokenScopeEnum      "member" | "organizer"
ticketStatusEnum         "todo" | "in_progress" | "awaiting_validation" | "closed"
ticketPriorityEnum       "low" | "medium" | "high" | "critical"
userRoleEnum             "dev" | "admin" | "user"               (defini dans auth.ts)
attributeKindEnum        "enum" | "multi_enum" | "boolean" | "number_range"  (defini dans attributes.ts)

Note : les anciens tagCategoryEnum, availabilitySlotEnum et availabilityStatusEnum ont été retirés (le tag-category est devenu une vraie table, la dispo passe désormais par request_availability_check au lieu d'un calendrier libre).

Pool, Group, Member, Tag

packages/db/src/schema/members.ts
pool (
  id, name, slug, description, iconName,
  extraFields jsonb,            // champs dynamiques du formulaire de demande
  position int,
  parentPoolId uuid?,           // sub-pool (1 niveau max : Restaurant → Salon privé)
  createdAt, updatedAt
)

group (
  id, name, description, logoUrl,
  legacyId text?,
  createdAt, updatedAt
)

member (
  id, code, name, description,
  address, city, postalCode, country, lat, lng,
  email, phone, contactName, website,
  poolId  → pool.id   (notNull)
  groupId → group.id  (notNull)

  // capacités venue-level (privatisation totale, indépendantes des rooms)
  maxCapacityOverall, maxCapacityCocktail, maxCapacityBanquet, maxCapacityTheater,

  budgetLow, budgetHigh, minBookingAmount,

  transport jsonb,              // Array<{ type, details }> (Métro, Bus, Parking…)
  memo text,                    // bloc-notes équipe, inclus dans le contexte IA
  pricingDetails jsonb,         // deposit, min-booking-by-period, dry-hire, price-by-day-of-week
  legacyExtras jsonb,           // { chefName?, menu?, info? } de V1

  status memberStatusEnum,
  openingHours jsonb,
  accessTokenHash text,
  embedding vector(1536), embeddingUpdatedAt,
  isFavorite bool,
  legacyId text?, legacySource text?,
  deletedAt,                    // soft delete (RGPD)
  createdAt, updatedAt
)

member_photo  (id, memberId, url, position, alt, legacyId, createdAt)

tag_category  (id, name, slug, description, iconName, position, createdAt, updatedAt)
tag           (id, name, categoryId → tag_category.id, createdAt)
                                // unique (name, categoryId), le tag est porté par sa catégorie
member_tag    (memberId, tagId, createdAt)

Indexes manuels (hors Drizzle, à appliquer en SQL) :

CREATE INDEX member_embedding_idx ON "member"
  USING ivfflat ((embedding::vector(1536)) vector_cosine_ops);

Rooms

packages/db/src/schema/rooms.ts
room (
  id, memberId → member.id, name, description, imageUrl,
  minPriceLunch, minPriceDinner, privatizationFee,
  openingHoursLunch jsonb, openingHoursDinner jsonb,
  properties jsonb,
  createdAt, updatedAt
)

room_capacity (
  id, roomId → room.id,
  configuration roomConfigurationEnum,   // 7 standards Kactus
  maxCapacity int, notes,
  unique (roomId, configuration)
)

Organizers

packages/db/src/schema/organizers.ts
organizer (
  id, companyName, type organizerTypeEnum,
  contactName, position, address, postalCode,
  email (unique), phone,
  memo,                                 // bloc-notes équipe, contexte IA
  isFavorite, firstContactAt,
  referredByMemberId → member.id?,
  createdAt, updatedAt
)

Requests + pipeline public

packages/db/src/schema/requests.ts
request (
  id, code (unique), organizerId → organizer.id, poolId → pool.id,
  description, participantsCount, budget,
  formats requestFormatEnum[],
  flexibleDates, postalCode, area, complementaryInfo,
  referredByMemberId → member.id?, isFirstRequest,
  source requestSourceEnum, rawSource jsonb,
  poolExtraData jsonb,                  // ex. Hotels → { roomCount: 30 }
  tagIds text[],
  memo text,                            // bloc-notes équipe, contexte IA
  status requestStatusEnum, version int,
  externalVenueName, externalVenueCity, // organisateur a choisi un lieu hors-réseau
  lostReason,
  legacyId text?, importedFromV1 bool,  // V1 = read-only, hors workflow actif
  embedding vector(1536), embeddingUpdatedAt,
  createdAt, updatedAt, closedAt
)

request_date (id, requestId, date, startTime, endTime, priority, isFlexible)

request_result (
  id, requestId, memberId,
  roomId → room.id?,                    // pin un Salon spécifique chez le membre
  pipelineStage pipelineStageEnum,
  position, aiScore, aiExplanation,
  isHidden bool default true,           // caché du catalogue public par défaut
  isShortlisted bool, shortlistedAt,    // pre-sélection orga avant choix final
  catalogSentAt,                        // dernier envoi du lien catalogue à ce membre
  addedBy addedByEnum, addedAt, updatedAt,
  legacyId text?
)

// Envoyé via magic link au membre : "Dispo le 12 mai ?"
// 1 row max par request_result (unique)
request_availability_check (
  id, resultId (unique), tokenHash,
  sentAt, respondedAt,
  acceptedDateIds text[],               // sous-set de request_date.id
  respondedBy text                      // "member" | "admin"
)

// "Souhaitez-vous vous positionner ?"
request_positioning_check (
  id, resultId (unique), tokenHash,
  sentAt, respondedAt, accepted bool?,
  respondedBy
)

// Demande publique à l'organisateur de compléter les champs manquants
request_requalification (
  id, requestId, tokenHash, publicUrl,
  missingFields jsonb (string[]),
  personalMessage,
  sentAt, viewedAt, submittedAt,
  submittedPayload jsonb,
  appliedAt, appliedBy
)

catalog (
  id, requestId (unique),
  sentAt, viewedAt,
  accessTokenHash, publicUrl,           // /public/catalogs/:token persistant
  createdAt
)

Request labels

packages/db/src/schema/request-labels.ts
request_label             (id, name, color, legacyId?, createdAt, updatedAt)
request_label_assignment  (requestId, labelId, createdAt, PK composite)

Attributs polymorphes (matching engine)

Quatre tables qui décrivent les dimensions métier (Typologie, Capacité assise, Budget par pax, Min spend, Privatisation, Formats, Prestations, Ambiance…). Le kind détermine comment la valeur est stockée et matchée.

packages/db/src/schema/attributes.ts
attribute_kind = "enum" | "multi_enum" | "boolean" | "number_range"

attribute_type (
  id, name (unique), slug (unique),
  kind attribute_kind, description, unit,    // "pax" | "EUR" | null
  position,
  isHard bool,                                // si true : violation élimine
  weight numeric,                             // pondération du score (def 1.00)
  rangePresets jsonb,                         // presets sliders pour number_range
  createdAt, updatedAt
)

attribute_option (
  id, attributeTypeId, label, slug,
  description, position, createdAt
)

member_attribute (
  id, memberId, attributeTypeId,
  optionId?, numberMin?, numberMax?, boolValue?,
  confidence numeric,                         // 1.0 si humain, < 1 si extraction IA
  source text,                                // "user" | "ai" | "import"
  notes, createdAt, updatedAt
)

request_attribute (
  id, requestId, attributeTypeId,
  optionId?, numberMin?, numberMax?, boolValue?,
  isHard bool?,                               // override de type.isHard pour cette demande
  confidence, source, createdAt
)

Pipeline matching : hard filters SQL (éliminent les candidats) → soft scoring pondéré → ranking avec explication par critère. Implémentation : modules/attributes/matching.engine.ts.

Timeline

packages/db/src/schema/timeline.ts
timeline_event (
  id, requestId,
  type text,                            // event code typé via @reciprok/domain
  payload jsonb, actor actorEnum, actorId,
  createdAt
)

AI

packages/db/src/schema/ai.ts
ai_conversation  (id, requestId (unique), userId, createdAt)
ai_message       (id, conversationId, role aiMessageRoleEnum, content jsonb, createdAt)

knowledge_entry (
  id, memberId, type knowledgeTypeEnum, content,
  source knowledgeSourceEnum, sourceRef jsonb,
  embedding vector(1536), createdAt, updatedAt
)

training_session (
  id, userId, requestId?,
  originalSuggestion jsonb, correction jsonb, notes,
  createdAt
)
packages/db/src/schema/ai-usage.ts
ai_usage (
  id, userId, date,
  provider, model,
  inputTokens, outputTokens, cachedTokens,
  costEur numeric,                       // calculé via tarif modèle + ratio cache
  context text                           // tag libre : "chat" | "parse" | "draft" | ...
)

ai_usage est aggrégé par (userId, date) pour la page coûts. Le détail token-par-message reste sur ai_message.content.

Communications

packages/db/src/schema/communications.ts
email_thread (id, requestId?, subject, createdAt, updatedAt)

email_message (
  id, threadId, direction text,        // "inbound" | "outbound"
  from, to, cc, subject,
  bodyHtml, bodyText,
  providerId text,                     // Resend message id (resp. wamid côté WhatsApp)
  status text default "pending",       // pending | sent | delivered | opened | bounced | failed
  openedAt, clickedAt, bouncedAt,
  metadata jsonb,
  sentAt, createdAt
)

Routing inbound : webhook Resend → communications.routes.ts matche le request.code dans l'objet ou le body et accroche le message au bon thread. Si pas de match, le message reste orphelin (queue inbox).

WhatsApp

packages/db/src/schema/whatsapp.ts
whatsapp_message (
  id, requestId?,
  direction text,                      // "inbound" | "outbound"
  phoneFrom, phoneTo, body,
  wamid text,                          // Meta message id (équivalent providerId Resend)
  status text default "pending",       // pending | sent | delivered | read | failed | received
  sentAt, deliveredAt, readAt,
  metadata jsonb, createdAt
)

Commissions

packages/db/src/schema/commissions.ts
commission_rule (
  id, scope commissionScopeEnum, scopeId uuid?,  // null = default, sinon member.id / request.id
  defaultRate, newClientRate?, recurringClientRate?,
  firstReferralRate?, recurringReferralRate?,
  effectiveFrom, effectiveTo?,
  createdAt, updatedAt
)

commission (
  id, requestId, memberId,
  amount, currency default "EUR",
  rate, rateReason text,               // explique d'où vient le taux résolu
  isFirstClient, referredByMemberId?,
  invoicedAt, paidAt,
  status commissionStatusEnum,
  createdAt, updatedAt
)

Résolution : request overridemember overridedefault. Première règle effective qui matche gagne.

Tickets

packages/db/src/schema/tickets.ts
ticket (
  id, title, description,
  priority ticketPriorityEnum, status ticketStatusEnum,
  section text, affectedPage text,
  createdById, assignedToId?,
  adminValidated, validatedById?, validatedAt,
  closedAt, createdAt, updatedAt
)
ticket_comment    (id, ticketId, userId, content, createdAt)
ticket_attachment (id, ticketId, commentId?, url, filename, uploadedById, createdAt)

Workflow : todoin_progressawaiting_validation → admin valide → closed.

Notifications + magic tokens

notification        (id, userId, type notificationTypeEnum, payload jsonb, readAt?, createdAt)
push_subscription   (id, userId, endpoint, p256dh, auth, createdAt)
magic_token         (id, scope magicTokenScopeEnum, scopeId, tokenHash,
                     expiresAt, usedAt, revokedAt, createdAt)

Member info requests (fiche membre auto-remplie)

packages/db/src/schema/member-info-requests.ts
member_info_request (
  id, memberId,
  tokenHash text,                      // raw token seulement affiché 1× (mail/copy)
  initiatedBy "team" | "member",
  personalMessage, createdByUserId,
  sentAt, viewedAt, submittedAt, cancelledAt,
  submittedPayload jsonb,              // { text, transcripts: [{text, at}] }
  aiStructured jsonb, aiParsedAt,      // sortie parseMemberBrief
  appliedAt, appliedBy, appliedFields jsonb (string[]),
  createdAt, updatedAt,
  unique partiel (memberId WHERE submittedAt IS NULL AND appliedAt IS NULL AND cancelledAt IS NULL)
)

Seule 1 demande active par membre à un instant T, c'est le partial unique index ci-dessus qui garantit l'invariant. Après submittedAt / cancelledAt / appliedAt, le membre peut en recevoir une nouvelle.

Travel estimates cache

packages/db/src/schema/travel-estimates.ts
travel_estimate_cache (
  cacheKey text PK,                    // sha256(catalogToken + roundedOrigin@100m + sortedMemberIds)
  data jsonb,                          // résultat IA : 4 modes (walking/cycling/driving/transit) × N membres
  createdAt
)

TTL 24h enforced en code (pas de cron de vacuum pour l'instant). Hit rate élevé puisque l'origin est arrondi à 100 m.

Partners

packages/db/src/schema/partners.ts
partner       (id, name, description, contactEmail, contactPhone, websiteUrl,
               categoryName, isFeatured, featuredPosition,
               legacyId?, createdAt, updatedAt)
partner_photo (id, partnerId, url, position, isMain, legacyId?, createdAt)

pgvector custom type

packages/db/src/schema/pgvector.ts
import { customType } from "drizzle-orm/pg-core";

export const vector = customType<{
  data: number[];
  driverData: string;
  config: { dimensions: number };
}>({
  dataType(config) { return `vector(${config.dimensions})`; },
  toDriver(value)  { return `[${value.join(",")}]`; },
  fromDriver(value){ return value.slice(1, -1).split(",").map(Number); },
});

Migrations

  • Dev : bun run db:push applique le schema directement (drizzle-kit push)
  • Prod / CI : bun run db:generate crée une migration SQL versionnée → commit → bun run db:migrate l'applique
  • Migration manuelle SQL pour les index pgvector (ivfflat) et les extensions Postgres

On this page