Timeline et event sourcing
Append-only log de tout ce qui se passe sur une demande
Le memo texte actuel est remplacé par un event log structuré. Chaque action sur une demande est enregistrée comme un événement immuable. C'est la mémoire vivante du système.
Pourquoi event sourcing
- Traçabilité totale, qui a fait quoi, quand, comment ?
- Rejouabilité, on peut reconstruire l'état de la demande à n'importe quel instant
- Audit, pour le fondateur, pour les membres référents, pour les organisateurs
- Source de l'IA, la timeline est aussi un input pour l'IA (apprendre des actions passées)
- Pas de "memo perdu", chaque email envoyé, chaque modification, chaque suggestion IA est tracée
Modèle
{
id: uuid,
requestId: uuid, // toujours lié à une demande
type: enum, // catégorie de l'événement
payload: jsonb, // contenu spécifique au type
actor: enum, // qui a déclenché : user / ai / system / member / organizer
actorId: text, // pointeur vers l'acteur (nullable)
createdAt: timestamp, // append-only
}Pas d'updatedAt. Pas de deletedAt. Append-only.
Les 18 types d'événements
Cycle de vie de la demande
| Type | Payload | Quand |
|---|---|---|
request_created | { source, organizerId } | Création de la demande |
status_changed | { from, to } | Changement de statut |
manual_note | { text } | Note ajoutée par l'utilisateur |
Communications sortantes
| Type | Payload | Quand |
|---|---|---|
email_sent | { threadId, to, subject, bodyPreview, kind } | Email envoyé (tout type) |
qualification_sent | { to, missingFields } | Email de requalification |
catalog_sent | { to, recommendedCount, compatibleCount, accessUrl } | Catalogue envoyé |
availability_requested | { memberId, dates } | Demande de dispo envoyée |
recommendation_sent | { memberId } | "Vous avez été recommandé" |
commission_sent | { memberId, amount } | Email de facturation |
whatsapp_sent | { to, type, message } | WhatsApp jumelé |
Communications entrantes
| Type | Payload | Quand |
|---|---|---|
email_received | { threadId, from, subject, bodyPreview } | Réponse email |
qualification_received | { source, fieldsUpdated } | Réponse à requalification |
availability_response | { memberId, status, notes } | Réponse de dispo |
voice_message_received | { from, audioUrl, transcript } | Audio entrant |
Actions sur les résultats
| Type | Payload | Quand |
|---|---|---|
member_added | { memberId, addedBy, stage } | Membre ajouté aux résultats |
member_removed | { memberId, reason? } | Membre retiré |
winner_selected | { memberId } | Sélection du gagnant |
IA
| Type | Payload | Quand |
|---|---|---|
ai_suggestion | { kind, content, accepted? } | Suggestion de l'IA (avec acceptation utilisateur) |
Acteurs
L'actor indique qui a déclenché l'événement :
| Actor | Quand |
|---|---|
user | Action de l'utilisateur interne |
ai | Action initiée par l'IA (avec ou sans confirmation utilisateur) |
system | Action automatique du système (cron, webhook, trigger) |
member | Action d'un membre (depuis son dashboard ou un lien magic) |
organizer | Action d'un organisateur (réponse, consultation de catalogue) |
actorId pointe vers l'entité concrète :
user.id(UUID Better Auth)member.idorganizer.idnullpouraietsystem
Append-only & idempotence
Chaque write est un INSERT. Jamais de UPDATE, jamais de DELETE.
Pour l'idempotence (en cas de retry sur webhook), on peut ajouter un champ optionnel idempotencyKey dans le payload, l'application vérifie qu'il n'existe pas déjà avant d'insérer.
Helper d'insertion
import { db } from "@reciprok/db";
import { timelineEvent } from "@reciprok/db/schema/timeline";
type LogParams = {
requestId: string;
type: TimelineEventType;
payload: Record<string, unknown>;
actor: TimelineActor;
actorId?: string | null;
};
export async function logTimelineEvent(params: LogParams) {
return db.insert(timelineEvent).values({
requestId: params.requestId,
type: params.type,
payload: params.payload,
actor: params.actor,
actorId: params.actorId ?? null,
}).returning();
}Toutes les routes qui font des actions sur une demande appellent ce helper. C'est centralisé pour ne JAMAIS oublier de logger.
Lecture de la timeline
api.timeline.events.byRequest({ requestId })Retourne une liste ordonnée par createdAt ASC, avec les events typés. Le frontend les rend dans un composant chronologique avec :
- Icône par type
- Couleur par actor (équipe / IA / système / membre / organisateur)
- Détail expansible
- Filtres (par type, par actor)
Visualisation côté UI
Le composant timeline est inspiré des UI Linear / GitHub :
○ 14h32 Demande créée par toi (source: email)
│
├─ 14h35 IA a analysé l'email et pré-rempli 8 champs
│
├─ 14h40 Toi : modification du budget (8000 → 12000)
│
├─ 14h42 Email de requalification envoyé à contact@xyz.com
│ "Pouvez-vous préciser le format souhaité ?"
│
├─ 16h18 Réponse reçue de contact@xyz.com
│ Champs mis à jour : formats, participantsCount
│
├─ 16h20 IA a relancé la recherche → 24 candidats
│
├─ 16h22 IA a suggéré : ajouter #102 et #205 au catalogue
│ ✓ Tu as accepté
│
├─ 17h05 Catalogue envoyé à contact@xyz.com
│ 4 recommandés, 4 compatibles
│
○ Demain 09h12 Réponse organisateur : intéressé par #102Accès externe
Le membre référent (celui qui a référé l'organisateur) peut accéder à une vue filtrée de la timeline via un lien magic. Il voit :
- Création de la demande
- Changements de statut
- Sélection du gagnant
- Commission
Il ne voit pas : les emails, les notes internes, les suggestions IA, les noms des autres membres candidats.
Filtrage côté API selon le contexte d'auth.
Performance
Pour une demande active, on peut avoir 50-200 events. Pas de souci de perf.
Pour les listings (toutes les demandes), on ne charge JAMAIS les events. Ils sont chargés à la demande quand on ouvre une demande spécifique.
Index :
CREATE INDEX timeline_request_idx ON timeline_event (request_id);
CREATE INDEX timeline_request_created_idx ON timeline_event (request_id, created_at);Snapshots et reconstitution
L'état actuel d'une demande (statut, organizer, etc.) est stocké dans la table request directement, pas reconstruit depuis les events à chaque lecture. Les events sont complémentaires, ils ne remplacent pas l'état canonique.
C'est un compromis entre event sourcing pur (où tout est dans les events) et état mutable (où on perd l'historique). On a les deux : request est la source de vérité de l'état courant, timeline_event est la source de vérité de l'historique.
Contraintes
- Aucun email ne part sans event, c'est une demande explicite du fondateur. Le helper d'envoi d'email appelle systématiquement
logTimelineEventaprès unINSERTdansemail_message. - Aucun changement de statut sans event, même chose, c'est centralisé dans une fonction
transitionRequestStatus. - Aucune action IA sans event, toute action de Claude est logguée avec
actor: "ai", qu'elle ait été confirmée ou non par l'utilisateur (pour les actions confirmées, on log l'acceptation).