Reciprok Docs
Données

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

TypePayloadQuand
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

TypePayloadQuand
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

TypePayloadQuand
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

TypePayloadQuand
member_added{ memberId, addedBy, stage }Membre ajouté aux résultats
member_removed{ memberId, reason? }Membre retiré
winner_selected{ memberId }Sélection du gagnant

IA

TypePayloadQuand
ai_suggestion{ kind, content, accepted? }Suggestion de l'IA (avec acceptation utilisateur)

Acteurs

L'actor indique qui a déclenché l'événement :

ActorQuand
userAction de l'utilisateur interne
aiAction initiée par l'IA (avec ou sans confirmation utilisateur)
systemAction automatique du système (cron, webhook, trigger)
memberAction d'un membre (depuis son dashboard ou un lien magic)
organizerAction d'un organisateur (réponse, consultation de catalogue)

actorId pointe vers l'entité concrète :

  • user.id (UUID Better Auth)
  • member.id
  • organizer.id
  • null pour ai et system

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

packages/api/src/lib/timeline.ts
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 #102

Accè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 logTimelineEvent après un INSERT dans email_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).

On this page