Skip to content

Module Échanges — Documentation technique

Epic E4 — Échanges & historique
User stories : US-4.1, US-4.2, US-4.3, US-4.4, US-4.5


1. Responsabilités du module

Le module exchanges permet à François de centraliser tout l'historique de ses conversations avec un contact, quel que soit le canal (LinkedIn, Gmail, Lemlist, autre). L'objectif est de répondre à « où en suis-je avec Sophie ? » sans rouvrir trois applications.

Ce que le module fait :

  • Enregistrement manuel d'un échange (canal, direction, contenu, date)
  • Timeline chronologique par contact avec filtrage par canal
  • Modification et suppression d'un échange existant
  • Vue agrégée de tous les échanges d'une entreprise (tous contacts confondus), avec filtrage par canal et par contact

Ce que le module ne fait pas :

  • Connecteurs réels (LinkedIn, Gmail, Lemlist) — saisie 100 % manuelle en v1
  • Import automatique depuis une boîte mail ou un CRM
  • Historique de versions d'un échange modifié
  • Pagination des échanges au-delà de 20 (limite actuelle de findOne contact — voir §8 Écarts)

2. Schéma de données

prisma
model Exchange {
  id          String    @id @default(cuid())
  contactId   String
  channel     Channel               // LINKEDIN | GMAIL | LEMLIST | OTHER
  direction   Direction             // OUTGOING | INCOMING
  content     String    @db.Text
  exchangedAt DateTime

  createdAt   DateTime  @default(now())

  contact     Contact   @relation(fields: [contactId], references: [id], onDelete: Cascade)

  @@index([contactId, exchangedAt])
}

Enums

Définis dans packages/shared/src/constants.ts — source de vérité unique pour le frontend et le backend :

ts
export const CHANNELS   = ['LINKEDIN', 'GMAIL', 'LEMLIST', 'OTHER'] as const
export type Channel     = (typeof CHANNELS)[number]

export const DIRECTIONS = ['INCOMING', 'OUTGOING'] as const
export type Direction   = (typeof DIRECTIONS)[number]

Relations

  • Exchange → Contact : N:1, ON DELETE CASCADE — supprimer un contact efface tous ses échanges.
  • Exchange n'a pas d'enterpriseId direct. L'isolation tenant est assurée indirectement via le contact parent (voir §6 Multi-tenancy).

3. API REST

Module Exchanges

Base URL : /api/exchanges

MéthodeRouteDescriptionAuth
POST/exchangesCréer un échange (201)
PATCH/exchanges/:idModifier un échange (200)
DELETE/exchanges/:idSupprimer un échange (204)

Il n'y a pas de GET /exchanges ni de GET /exchanges/:id. Les échanges sont lus via GET /contacts/:id (inclus dans la réponse, limités à 20).

Endpoint entreprise (module Companies)

MéthodeRouteDescription
GET/companies/:id/exchangesTimeline agrégée de l'entreprise

Corps POST /exchanges

json
{
  "contactId":   "cjld2cjxh0000qzrmn831i7rn",
  "channel":     "LINKEDIN",
  "direction":   "OUTGOING",
  "content":     "Bonjour, je souhaitais vous contacter suite à...",
  "exchangedAt": "2024-03-15T10:00:00Z"   // optionnel — défaut : now()
}

Corps PATCH /exchanges/:id

Tous les champs sont optionnels — seuls les champs fournis sont mis à jour (pas de remplacement total).

json
{
  "channel":     "GMAIL",
  "direction":   "INCOMING",
  "content":     "Contenu corrigé.",
  "exchangedAt": "2024-03-15T14:30:00Z"
}

Paramètres GET /companies/:id/exchanges

ParamètreTypeDescription
channelChannel enumFiltre par canal — 400 si valeur invalide
contactIdstringFiltre par contact de l'entreprise

Codes d'erreur

CodeSituation
400Champ manquant ou invalide (content vide, channel hors enum)
401Token absent ou invalide
403Contact du contactId inexistant ou hors scope tenant
404Échange introuvable (PATCH/DELETE sur un ID inconnu)

4. Backend — Architecture

ExchangesService

Fichier : apps/api/src/exchanges/exchanges.service.ts

create(dto)

  1. assertContactAccess(dto.contactId) — vérifie que le contact existe dans le scope tenant courant (via prisma.contact.findUnique + middleware tenant). Lève ForbiddenException si null.
  2. prisma.exchange.create() — crée l'échange. exchangedAt par défaut à new Date() si non fourni.

update(id, dto)

  1. assertExists(id) — récupère l'échange. Lève NotFoundException si introuvable.
  2. assertContactAccess(exchange.contactId) — vérifie l'accès au contact parent.
  3. prisma.exchange.update() avec spread conditionnel sur chaque champ : un champ absent du dto n'est pas inclus dans le data, ce qui évite d'écraser une valeur existante avec undefined.

remove(id)

  1. assertExists(id) — récupère l'échange. Lève NotFoundException si introuvable.
  2. assertContactAccess(exchange.contactId) — vérifie l'accès.
  3. prisma.exchange.delete().

Méthodes privées

MéthodeRôle
assertContactAccess(contactId)findUnique sur Contact (scopé tenant). Lève 403 si null.
assertExists(id)findUnique sur Exchange (non scopé — Exchange n'a pas d'enterpriseId). Lève 404 si null.

CompaniesService.getExchanges()

Fichier : apps/api/src/companies/companies.service.ts

  • Cherche d'abord la company via findFirst (scopé tenant) — lève 404 si hors scope.
  • Valide filters.channel contre l'enum Prisma Channel — lève 400 si invalide.
  • Retourne les échanges via prisma.exchange.findMany({ where: { contact: { companyId } }, include: { contact } }).

DTOs

DTOFichierChamps requis
CreateExchangeDtoexchanges/dto/create-exchange.dto.tscontactId, channel, direction, content
UpdateExchangeDtoexchanges/dto/update-exchange.dto.tsaucun (tous optionnels)

Schémas Zod miroirs dans packages/shared/src/schemas/exchange.schema.ts.


5. Frontend — Architecture

Composants

ComposantRôleUser story
components/exchange/ExchangeQuickAdd.vueZone de saisie rapide en haut de la timeline, Cmd+V globalUS-4.1
components/exchange/ExchangeTimeline.vueTimeline verticale avec onglets de canal et menu kebabUS-4.2
components/exchange/ExchangeEditModal.vueModale modification (contenu, canal, direction, date)US-4.3
components/contact/ContactExchangesPanel.vueOrchestrateur : QuickAdd + Timeline + modales edit/deleteUS-4.1–4.4

ExchangeQuickAdd

  • Réagit à Cmd+V / Ctrl+V n'importe où dans la page via un listener keydown global — focus automatique sur le textarea si l'élément actif n'est pas déjà un champ éditable.
  • Les contrôles (canal, direction, date, boutons) n'apparaissent que si content.trim() est non vide (v-if) — le bouton "Enregistrer" ne peut pas être cliqué si le champ est vide.
  • Canal par défaut : LINKEDIN. Direction par défaut : OUTGOING.

ExchangeTimeline

  • Onglets de canal calculés dynamiquement : seuls les canaux avec au moins 1 échange sont affichés.
  • Point de direction : violet (.direction--outgoing) pour les sortants, vert (.direction--incoming) pour les entrants.
  • Date affichée en relatif (Intl.RelativeTimeFormat('fr')) avec tooltip en absolu.
  • Menu kebab par entrée : "Modifier" et "Supprimer" — émet les événements edit et delete vers le parent.
  • Slot #contact-link permettant au contexte entreprise d'afficher le lien vers le contact de chaque échange.

Composable useExchanges

Fichier : apps/web/composables/useExchanges.ts

ts
useCreateExchange()              // POST /exchanges — invalide ['contact', id] et ['contacts']
useUpdateExchange(contactId)     // PATCH /exchanges/:id — invalide ['contact', id] et ['contacts']
useDeleteExchange(contactId)     // DELETE /exchanges/:id — invalide ['contact', id] et ['contacts']
useCompanyExchanges(companyId, filters)  // GET /companies/:id/exchanges?...

useCompanyExchanges n'est activé (enabled) que lorsque companyId est non vide, et uniquement quand l'onglet "Échanges" est actif dans pages/companies/[id].vue (les filtres sont passés vides sinon).

Pages concernées

PageIntégration E4
pages/contacts/[id].vueInclut <ContactExchangesPanel> avec la liste d'échanges du contact
pages/companies/[id].vueOnglet "Échanges" avec <ExchangeTimeline> et filtres canal/contact

6. Multi-tenancy

Exchange n'est pas dans SCOPED_MODELS de PrismaService car il ne porte pas d'enterpriseId. L'isolation est assurée par une chaîne à deux niveaux :

requête CRUD échange


assertExists(id)           ← findUnique Exchange (non scopé)


assertContactAccess(contactId) ← findUnique Contact (scopé tenant via middleware)

        └── null → ForbiddenException  ← contact hors scope = accès refusé

Un attaquant connaissant l'ID d'un échange d'une autre enterprise ne peut ni le modifier ni le supprimer : assertContactAccess trouvera le contact null (filtré par le middleware) et lèvera une ForbiddenException.


7. Tests

Tests unitaires

Fichier : apps/api/src/exchanges/exchanges.service.spec.ts

Couverture :

  • create() — création réussie, exchangedAt du dto, fallback now(), ForbiddenException si contact hors scope
  • update() — mise à jour réussie, dto vide n'écrase rien, channel seul, NotFoundException échange inconnu, ForbiddenException contact hors scope
  • remove() — suppression réussie, NotFoundException échange inconnu, ForbiddenException contact hors scope, pas de delete si assertExists lève
bash
yarn workspace @heriade/api test:unit --testPathPattern="exchanges.service"

Tests E2E Playwright

Fichier : apps/e2e/tests/exchanges.spec.ts

Couverture :

  • US-4.1 — zone visible, contrôles absents si vide, canal LinkedIn par défaut, direction sortante par défaut, enregistrement → toast + reset formulaire + entrée dans timeline, annulation
  • US-4.2 — 3 échanges affichés, ordre chronologique inverse, onglets de canal avec compteurs, filtrage Gmail, points de direction distincts
  • US-4.3 — menu kebab accessible, modale pré-remplie, modification → toast + mise à jour timeline, changement de canal, annulation, validation contenu vide
  • US-4.4 — modale de confirmation, annulation maintient l'échange, suppression → toast + échange retiré + état vide
  • US-4.5 — onglet "Échanges" sur la fiche entreprise, échanges de tous les contacts affichés, lien vers contact, filtrage par canal
  • API — POST 201/400/403/401, PATCH 200/404, DELETE 204/404, GET /companies/:id/exchanges 200 avec filtrage canal, 400 sur canal invalide
bash
# Nécessite api :3001 et web :3000 démarrés
yarn test:e2e --grep "US-4"
yarn test:e2e --grep "API Exchanges"

8. Écarts avec les user stories

USCritère d'acceptationImplémentationStatut
US-4.1"Le bouton Enregistrer est désactivé si contenu vide"Le bouton n'est pas visible si contenu vide (v-if)✅ Conforme en pratique
US-4.2"Voir tous les échanges"findOne contact limite à take: 20⚠️ Partiel — au-delà de 20 échanges, la timeline tronque silencieusement. Pagination à implémenter post-MVP.
US-4.3"Clic sur un échange ouvre une modale"Accès via le menu kebab (→ "Modifier")✅ Conforme — comportement plus sûr (pas de clic accidentel)

Document v1 · mai 2026 · à synchroniser avec les user stories (heriade-contacts-USER-STORIES.md)