Appearance
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
findOnecontact — 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.Exchangen'a pas d'enterpriseIddirect. 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éthode | Route | Description | Auth |
|---|---|---|---|
POST | /exchanges | Créer un échange (201) | ✅ |
PATCH | /exchanges/:id | Modifier un échange (200) | ✅ |
DELETE | /exchanges/:id | Supprimer un échange (204) | ✅ |
Il n'y a pas de
GET /exchangesni deGET /exchanges/:id. Les échanges sont lus viaGET /contacts/:id(inclus dans la réponse, limités à 20).
Endpoint entreprise (module Companies)
| Méthode | Route | Description |
|---|---|---|
GET | /companies/:id/exchanges | Timeline 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ètre | Type | Description |
|---|---|---|
channel | Channel enum | Filtre par canal — 400 si valeur invalide |
contactId | string | Filtre par contact de l'entreprise |
Codes d'erreur
| Code | Situation |
|---|---|
400 | Champ manquant ou invalide (content vide, channel hors enum) |
401 | Token absent ou invalide |
403 | Contact 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)
assertContactAccess(dto.contactId)— vérifie que le contact existe dans le scope tenant courant (viaprisma.contact.findUnique+ middleware tenant). LèveForbiddenExceptionsi null.prisma.exchange.create()— crée l'échange.exchangedAtpar défaut ànew Date()si non fourni.
update(id, dto)
assertExists(id)— récupère l'échange. LèveNotFoundExceptionsi introuvable.assertContactAccess(exchange.contactId)— vérifie l'accès au contact parent.prisma.exchange.update()avec spread conditionnel sur chaque champ : un champ absent du dto n'est pas inclus dans ledata, ce qui évite d'écraser une valeur existante avecundefined.
remove(id)
assertExists(id)— récupère l'échange. LèveNotFoundExceptionsi introuvable.assertContactAccess(exchange.contactId)— vérifie l'accès.prisma.exchange.delete().
Méthodes privées
| Méthode | Rô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ève404si hors scope. - Valide
filters.channelcontre l'enum PrismaChannel— lève400si invalide. - Retourne les échanges via
prisma.exchange.findMany({ where: { contact: { companyId } }, include: { contact } }).
DTOs
| DTO | Fichier | Champs requis |
|---|---|---|
CreateExchangeDto | exchanges/dto/create-exchange.dto.ts | contactId, channel, direction, content |
UpdateExchangeDto | exchanges/dto/update-exchange.dto.ts | aucun (tous optionnels) |
Schémas Zod miroirs dans packages/shared/src/schemas/exchange.schema.ts.
5. Frontend — Architecture
Composants
| Composant | Rôle | User story |
|---|---|---|
components/exchange/ExchangeQuickAdd.vue | Zone de saisie rapide en haut de la timeline, Cmd+V global | US-4.1 |
components/exchange/ExchangeTimeline.vue | Timeline verticale avec onglets de canal et menu kebab | US-4.2 |
components/exchange/ExchangeEditModal.vue | Modale modification (contenu, canal, direction, date) | US-4.3 |
components/contact/ContactExchangesPanel.vue | Orchestrateur : QuickAdd + Timeline + modales edit/delete | US-4.1–4.4 |
ExchangeQuickAdd
- Réagit à
Cmd+V/Ctrl+Vn'importe où dans la page via un listenerkeydownglobal — 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
editetdeletevers le parent. - Slot
#contact-linkpermettant 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
| Page | Intégration E4 |
|---|---|
pages/contacts/[id].vue | Inclut <ContactExchangesPanel> avec la liste d'échanges du contact |
pages/companies/[id].vue | Onglet "É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,exchangedAtdu dto, fallbacknow(),ForbiddenExceptionsi contact hors scopeupdate()— mise à jour réussie, dto vide n'écrase rien, channel seul,NotFoundExceptionéchange inconnu,ForbiddenExceptioncontact hors scoperemove()— suppression réussie,NotFoundExceptionéchange inconnu,ForbiddenExceptioncontact hors scope, pas dedeletesiassertExistslè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
| US | Critère d'acceptation | Implémentation | Statut |
|---|---|---|---|
| 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)
