Entités
Modèle de données, entités principales et relations
Vue d'ensemble des entités qui composent le domaine. Les schémas Drizzle détaillés sont dans la page suivante (database-schema).
Entités structurantes
Pool
Catégorie métier. Chaque membre et chaque demande appartient obligatoirement à un pool. Le formulaire de création de demande s'adapte dynamiquement au pool choisi via extraFields.
Champs clés :
id(uuid)name(unique, ex: "Hotels", "Restaurants", "Traiteurs")slug(unique)description,iconNameextraFields(JSONB, champs supplémentaires du formulaire de demande, ex: Hotels →{ roomCount: { type: "number", label: "Nombre de chambres" } })position(int, ordre d'affichage)parentPoolId(FK optional → Pool, un seul niveau de hiérarchie)createdAt,updatedAt
Sub-pools : un pool peut être un sous-ensemble d'un autre (ex: Restaurant → Salon privé). Le filtrage par un pool racine inclut automatiquement les items de ses descendants. La hiérarchie n'a qu'un seul niveau.
Group
Groupe d'entreprise (chaîne, holding, multi-établissements). Les indépendants appartiennent au group built-in "INDEPENDANT".
Champs clés : id, name, description, logoUrl, legacyId?.
Pool vs Group : Pool = catégorie métier (Hotels), Group = entreprise (Groupe Alcazar = 7 restaurants). Un membre a obligatoirement les deux.
Entités centrales
Member
Établissement ou prestataire. Entité unifiée : pas de séparation Restaurant/Lieu/Prestataire, le profil métier est porté par les tags, les attributes polymorphes et le pool.
Champs clés :
id,code(string unique court, ex:#102)name,descriptionaddress,city,postalCode,country,lat,lngemail,phone,contactName,websitepoolId(FK obligatoire → Pool),groupId(FK obligatoire → Group)- Capacités venue-level (privatisation totale, indépendantes des rooms) :
maxCapacityOverall,maxCapacityCocktail,maxCapacityBanquet,maxCapacityTheater budgetLow,budgetHigh,minBookingAmounttransport(JSONB,Array<{ type, details }>, ex: Métro ligne 1, Bus 21, Parking Vinci)pricingDetails(JSONB, deposit, min-booking par période, dry-hire, price-by-day-of-week)legacyExtras(JSONB,{ chefName?, menu?, info? }issus de V1)memo(text, bloc-notes équipe, visible par l'IA comme contexte)status(enum: active / inactive / configuring)openingHours(JSONB structuré par jour/créneau)embedding(vector pgvector, recherche sémantique)isFavorite(bool, boost le ranking dans les suggestions de catalogue)legacyId,legacySource,deletedAt(soft delete RGPD)
Les capacités précises par configuration sont stockées dans
RoomCapacity(lié aux rooms). Les capacités venue-level servent au mode "Établissement entier" / privatisation totale.
Relations : N..1 Pool · N..1 Group · 1..N Room · 1..N MemberPhoto · N..M Tag · 1..N KnowledgeEntry · N..M Attribute · 1..N RequestResult · 1..N Commission.
Room
Espace/salle au sein d'un membre. Tarifs et horaires propres.
Champs clés : id, memberId, name, description, imageUrl, minPriceLunch, minPriceDinner, privatizationFee, openingHoursLunch, openingHoursDinner, properties (JSONB).
RoomCapacity
Capacité d'une salle pour une configuration événementielle Kactus (7 standards). Table dédiée car une salle ne supporte pas forcément les 7 configs, et l'indexation (configuration, max_capacity) accélère les filtres SQL.
Champs : id, roomId, configuration (enum), maxCapacity, notes. Unique sur (roomId, configuration).
Organizer
Le client qui cherche un lieu.
Champs clés : id, companyName, type (enum: company / individual / agency / traiteur / prestataire), contactName, position, address, postalCode, email (unique), phone, isFavorite, memo (bloc-notes équipe inclus dans le contexte IA), firstContactAt, referredByMemberId?.
Request
L'entité centrale. Une demande d'événement.
Champs clés :
id,code(ex:REQ-2026-0142)organizerId,poolId(FK obligatoire, détermine le formulaire)description,participantsCount,budgetformats(array enum)flexibleDates,postalCode,areacomplementaryInforeferredByMemberId?,isFirstRequest(impact commission)source(enum: manual / email / voice / ai_agent),rawSource(JSONB)poolExtraData(JSONB, données pool-spécifiques, ex:{ roomCount: 30 })tagIds(text array, tags portés par la demande pour le matching)memo(bloc-notes équipe, inclus dans le contexte IA)status(enum),version(int, locks optimistes)externalVenueName,externalVenueCity(organisateur a réservé hors-réseau, pas de commission)lostReasonlegacyId?,importedFromV1(V1 = read-only, exclu du workflow actif)embedding(vector, feed le toolfind_similar_requests)createdAt,updatedAt,closedAt
RequestDate
Plusieurs dates candidates par demande (l'organisateur hésite entre 2-3 options). priority pour l'ordre de préférence, isFlexible pour ajustement.
RequestResult
Liaison demande ↔ membre dans le pipeline.
Champs clés :
id,requestId,memberIdroomId?(pin une salle précise quand la recherche matche une salle plutôt que le membre entier)pipelineStage(enum: search_result / catalog_recommended / catalog_compatible / sent / negotiating / won / lost)position(int, ordre dans le catalogue)aiScore(0..1),aiExplanationisHidden(bool, default true, caché du catalogue public par défaut)isShortlisted,shortlistedAt(pre-sélection orga côté public, avant le choix final)catalogSentAt(dernier envoi du lien catalogue à ce membre)addedBy(ai / user),addedAt,updatedAt
Un même membre peut apparaître plusieurs fois dans une demande (une entrée par salle distincte du membre).
Catalog
La sélection finale envoyée à l'organisateur (recommandés + compatibles).
Champs : id, requestId, sentAt, viewedAt, accessTokenHash, publicUrl (chemin persistant /public/catalogs/:token).
Pipeline public, checks via magic link
RequestAvailabilityCheck
Magic link envoyé au membre : "Dispo le 12 mai ?". Le membre coche les dates qu'il accepte parmi celles de la demande. Une seule check active par requestResult.
Champs : id, resultId (unique), tokenHash, sentAt, respondedAt, acceptedDateIds (sous-set de RequestDate.id), respondedBy ("member" via magic link / "admin" override manuel).
RequestPositioningCheck
"Souhaitez-vous vous positionner sur cet événement ?". Réponse binaire avec un null possible (en attente).
Champs : id, resultId (unique), tokenHash, sentAt, respondedAt, accepted (bool?), respondedBy.
RequestRequalification
Token public envoyé à l'organisateur pour qu'il complète les champs manquants identifiés par l'IA (budget, capacité, format…). Historique préservé (une row par envoi).
Champs : id, requestId, tokenHash, publicUrl, missingFields (JSONB array), personalMessage, sentAt, viewedAt, submittedAt, submittedPayload, appliedAt, appliedBy.
Attributs polymorphes
Système de matching avancé. Quatre tables qui définissent les dimensions métier (Typologie, Capacité assise/debout, Budget par pax, Min spend, Privatisation, Formats, Prestations, Ambiance…) avec un scoring pondéré.
AttributeType
Définit la dimension. kind détermine la forme de la valeur.
Champs : id, name (unique), slug, kind (enum: enum / multi_enum / boolean / number_range), description, unit ("pax" / "EUR" / null), isHard (bool, violation élimine), weight (poids dans le score), rangePresets (JSONB, presets sliders pour number_range).
AttributeOption
Option d'un AttributeType (pour les kinds enum / multi_enum).
Champs : id, attributeTypeId, label, slug, description, position.
MemberAttribute / RequestAttribute
Valeur portée par un membre (ou une demande). Une seule colonne est remplie selon le kind : optionId (enum/multi_enum), numberMin+numberMax (number_range), boolValue (boolean).
Champs partagés :
id,(memberId | requestId),attributeTypeIdoptionId?,numberMin?,numberMax?,boolValue?confidence(0..1, 1.0 si humain, inférieur à 1 si extraction IA)source("user"/"ai"/"import")RequestAttributepeut surchargerisHardau cas par cas
Pipeline matching : hard filters SQL → soft scoring pondéré → ranking avec explication par critère (
modules/attributes/matching.engine.ts).
Tags
TagCategory
Catégorie de tags (Type, Ambiance, Service, Cuisine, Autre, …). Table dédiée pour permettre l'ajout de catégories sans migration de schéma.
Champs : id, name (unique), slug, description, iconName, position.
Tag
Tag classifié sous une catégorie. Unique sur (name, categoryId), deux catégories peuvent porter un tag du même libellé.
Champs : id, name, categoryId.
RequestLabel
Label coloré attaché aux demandes (étiquette de tri/visualisation interne). N..M via RequestLabelAssignment.
Champs : id, name, color, legacyId?.
Timeline, communications, IA
TimelineEvent
Append-only log de tout ce qui se passe sur une demande. Codes typés via @reciprok/domain/event-codes.
Champs : id, requestId, type (text), payload (JSONB), actor (enum), actorId.
EmailThread / EmailMessage
Toutes les communications email liées à une demande. EmailMessage.providerId = id Resend, sert au reconciliation des webhooks status (delivered / opened / bounced).
Routing inbound : webhook Resend matche le request.code dans le subject ou le body pour rattacher au bon thread. Sans match → message orphelin (queue inbox).
WhatsAppMessage
Messages WhatsApp via Meta Cloud API. Indépendants des threads email.
Champs : id, requestId?, direction ("inbound" / "outbound"), phoneFrom, phoneTo, body, wamid (Meta message id), status (pending / sent / delivered / read / failed / received), sentAt, deliveredAt, readAt, metadata.
AiConversation / AiMessage
Chat IA lié à une demande. Une conversation par demande, persistante. AiMessage.content est JSONB (texte, tool calls, tool results).
KnowledgeEntry
Entrée dans la base de connaissances d'un membre. Sourcée et indexée (pgvector).
Champs : id, memberId, type (general_description / room_description / pricing / ambiance / availability_rule / internal_note / audit_note), content, source (voice_transcript / manual / audit / ai_structured), sourceRef (JSONB), embedding.
TrainingSession
Corrections de l'utilisateur sur des suggestions IA (catalogues, recommendations). Sert au training continu.
Champs : id, userId, requestId?, originalSuggestion (JSONB), correction (JSONB), notes.
AiUsage
Log agrégé par (userId, date) des tokens consommés et du coût en EUR. Alimenté à chaque appel Claude/OpenAI, lu par la page /settings/usage.
Champs : id, userId, date, provider, model, inputTokens, outputTokens, cachedTokens, costEur, context.
Commissions
Commission
Champs : id, requestId, memberId, amount, currency (default EUR), rate, rateReason (explique le taux résolu : "default" / "new_client" / "recurring_referral" / "member_override" / "request_override"), isFirstClient, referredByMemberId?, invoicedAt, paidAt, status (pending / invoiced / paid / cancelled).
CommissionRule
Règles de calcul. Un default + overrides par membre ou par demande.
Champs : id, scope (default / member / request), scopeId (memberId ou requestId selon scope, null pour default), defaultRate, newClientRate?, recurringClientRate?, firstReferralRate?, recurringReferralRate?, effectiveFrom, effectiveTo?.
Résolution : request override → member override → default. Première règle effective qui matche gagne.
Magic tokens, member info requests, travel cache
MagicToken
Accès public par token pour les membres (dashboard) ou organisateurs (catalogue, requalification). Stocké hashé.
Champs : id, scope (member / organizer), scopeId, tokenHash, expiresAt, usedAt, revokedAt.
MemberInfoRequest
Magic-link envoyé à un membre pour qu'il remplisse sa fiche (description, salles, capacités, formats, photos…). Auto-extraction IA via parseMemberBrief puis review humaine avant écriture.
Champs : id, memberId, tokenHash, initiatedBy ("team" / "member"), personalMessage, createdByUserId, sentAt, viewedAt, submittedAt, cancelledAt, submittedPayload (JSONB, texte + transcripts vocaux), aiStructured (JSONB, diff extrait), aiParsedAt, appliedAt, appliedBy, appliedFields (string[], audit des champs réécrits).
Index unique partiel : une seule demande active par membre (
submittedAt IS NULL AND appliedAt IS NULL AND cancelledAt IS NULL).
TravelEstimateCache
Cache des temps de trajet IA-générés affichés sur le catalogue public (4 modes × N membres). Clé sha256 sur (catalogToken, roundedOrigin@100m, sortedMemberIds).
Champs : cacheKey (PK), data (JSONB), createdAt. TTL 24h enforced en code.
Utilisateurs internes & ticketing
User
Géré par Better Auth (user, session, account, verification). Ajoute un enum userRoleEnum (dev / admin / user) :
devvoit tout (Sync, Crons, RGPD, /showcase, V1 toggle)adminvoit l'app métier mais pas les ops dev-onlyuserest le défaut pour tout nouveau compte (pas d'accès admin)
Notification / PushSubscription
Notif interne + abonnement Web Push pour le browser. Notification.type enuméré.
Ticket / TicketComment / TicketAttachment
Suivi interne des bugs et features. Workflow : todo → in_progress → awaiting_validation → admin valide → closed.
Partenaires
Partner / PartnerPhoto
Partenaires commerciaux/techniques (audio/vidéo, production, etc.) exposés dans les catalogues à côté des membres.
Champs Partner : id, name, description, contactEmail, contactPhone, websiteUrl, categoryName, isFeatured, featuredPosition, legacyId?.
Relations principales
Vue d'ensemble, Request au centre
Le membre et son écosystème
Les règles de commission
Logique de résolution : request override → member override → default. La première règle qui matche est appliquée.