Appearance
Module Contacts — Documentation technique
Epic E2 — Gestion des contacts
User stories : US-2.1, US-2.2, US-2.3, US-2.4, US-2.5, US-2.6
1. Responsabilités du module
Le module contacts est le cœur de l'application. Il permet à François de créer, enrichir et consulter ses fiches de prospection, d'y accéder rapidement par recherche ou filtres, et de gérer ses tags.
Ce que le module fait :
- CRUD complet sur les contacts
- Liste paginée avec recherche texte, filtres multiples et tri
- Fiche détaillée incluant échanges (20 derniers), rappels actifs, entreprise liée et date de premier contact
- Gestion des tags par contact (multi-valeurs, scoped à l'enterprise)
- Endpoint de recherche des tags distincts accessibles à l'utilisateur
Ce que le module ne fait pas :
- Export CSV
- Enrichissement automatique des contacts
- Fusion de contacts
- Partage de contacts entre utilisateurs
2. Schéma de données
prisma
model Contact {
id String @id @default(cuid())
enterpriseId Int // isolation multi-tenant
ownerUserId String // propriétaire de la fiche
companyId String? // entreprise rattachée (optionnel)
firstName String
lastName String
email String?
linkedinUrl String?
phone String?
role String? // ex: "Directeur commercial"
status String? // valeurs : CONTACT_STATUSES
tags String[] // tableau libre, scoped à l'enterprise
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company? @relation(fields: [companyId], references: [id])
exchanges Exchange[]
reminders Reminder[]
@@index([enterpriseId, ownerUserId])
@@index([enterpriseId, companyId])
}Statuts canoniques
Définis dans packages/shared/src/constants.ts :
ts
export const CONTACT_STATUSES = ['actif', 'inactif', 'prospect', 'client', 'perdu'] as constRelations
Contact → Company: N:1, optionnel. Suppression Company →companyId = nullsur les contacts.Contact → Exchange: 1:N,ON DELETE CASCADE.Contact → Reminder: 1:N,ON DELETE SET NULL(les rappels deviennent autonomes).
3. API REST
Base URL : /api/contacts
| Méthode | Route | Description |
|---|---|---|
GET | /contacts | Liste paginée + recherche + filtres |
GET | /contacts/tags | Tags distincts accessibles à l'utilisateur |
GET | /contacts/:id | Fiche complète (échanges, rappels, entreprise) |
POST | /contacts | Créer un contact (201) |
POST | /contacts/import | Importer jusqu'à 500 contacts en bulk (201) |
POST | /contacts/bulk-delete | Supprimer plusieurs contacts (200, max 100 IDs) |
PATCH | /contacts/:id | Modifier un contact (200) |
DELETE | /contacts/:id | Supprimer un contact (204) |
Paramètres GET /contacts
| Paramètre | Type | Description |
|---|---|---|
q | string | Recherche sur prénom, nom, email, rôle, entreprise |
status | CONTACT_STATUSES | Filtre exact par statut |
tags | string (csv) | Filtre hasEvery — tous les tags doivent matcher |
companyId | string | Filtre par entreprise |
ownerUserId | string | Filtre par propriétaire (admin/super-admin seulement) |
hasEmail | boolean | true = avec email, false = sans |
lastExchangeDirection | INCOMING | OUTGOING | Direction du dernier échange |
activityPeriod | today | 7 | 30 | 90 | Activité dans les N derniers jours |
reminderStatus | overdue,upcoming,none (csv) | Filtre par état des rappels (combinable) |
sortBy | updatedAt | createdAt | lastName | status | Champ de tri (défaut : updatedAt) |
sortDir | asc | desc | Direction du tri (défaut : desc) |
page | number | Page courante (défaut : 1) |
limit | number | Résultats par page (défaut : 25) |
sortByest validé contre une whitelist côté service — toute valeur inconnue retombe silencieusement surupdatedAt(protection contre l'injection de champs arbitraires).
Réponse GET /contacts
json
{
"contacts": [...],
"total": 142,
"page": 1,
"limit": 25
}Chaque contact inclut : company (id + name), exchanges (dernier échange : channel + direction + date), reminders (3 prochains non complétés).
Réponse GET /contacts/:id
Inclut en plus : entreprise complète, 20 derniers échanges complets, tous les rappels actifs, et firstContactAt (date du plus ancien échange, ou createdAt si aucun échange).
POST /contacts/bulk-delete
Body :
json
{ "ids": ["cid-1", "cid-2", "cid-3"] }Contraintes : ids est un tableau de 1 à 100 strings (validé par class-validator). Au-delà de 100, la requête est rejetée (400) pour limiter la charge.
Réponse 200 :
json
{ "deleted": 3 }deleted peut être inférieur à ids.length : les IDs hors scope tenant sont ignorés silencieusement (pas de 404 partiel). Les rappels rattachés aux contacts supprimés sont orphanisés (contactId mis à null), comme pour la suppression unitaire.
Codes d'erreur
| Code | Situation |
|---|---|
400 | Champ manquant ou invalide (firstName/lastName vides) |
401 | Token absent ou invalide |
404 | Contact introuvable ou hors scope tenant |
POST /contacts/import
Body :
json
{
"contacts": [
{ "firstName": "Marie", "lastName": "Dupont", "email": "[email protected]", "phone": "0612345678", "role": "DRH" },
{ "firstName": "Thomas", "lastName": "Leroy" }
]
}Contraintes : contacts est un tableau de 1 à 500 contacts. Chaque item suit le même schéma que CreateContactDto (firstName et lastName requis).
Réponse 201 :
json
{
"imported": [
{ "id": "cid-1", "firstName": "Marie", "lastName": "Dupont", "email": "[email protected]" }
],
"errors": []
}Les items sont créés individuellement (pas de transaction globale) — une erreur sur un contact n'annule pas les autres. Les contacts en erreur apparaissent dans errors[] avec leur index dans le tableau source et un message descriptif. Le middleware Prisma injecte automatiquement enterpriseId et ownerUserId depuis le contexte de la requête.
4. Backend — Architecture
ContactsService
Fichier : apps/api/src/contacts/contacts.service.ts
list(query, ctx)
- Construit un
wherePrisma dynamique selon les filtres actifs. - Recherche texte (
q) :ORsur 5 champs en mode insensible à la casse. reminderStatussupporte plusieurs valeurs simultanées (ex:overdue,upcoming) viaORinterne.ownerUserIdignoré si l'utilisateur n'est pas admin (le middleware Prisma garantit déjà la vue réduite).sortByvalidé contreSORTABLE_FIELDS = ['updatedAt', 'createdAt', 'lastName', 'status'].- Exécute
findManyetcounten parallèle (Promise.all).
findOne(id)
- Utilise
findFirst(pasfindUnique) pour que le middleware tenant puisse injecter les filtres. - Inclut les 20 derniers échanges (
take: 20,orderBy exchangedAt desc). - Calcule
firstContactAtviaexchange.aggregate._min.exchangedAt— fallback surcontact.createdAtsi aucun échange.
create(dto, ctx)
- Injecte
enterpriseIdetownerUserIddepuis leUserContext. - Initialise
tagsà[]si non fournis.
update(id, dto)
- Utilise
prisma.contact.update(scopé par le middleware — un contact hors scope retourneP2025). - Mappe l'erreur Prisma
P2025enNotFoundException.
remove(id)
findFirstpour vérifier que le contact est dans le scope tenant.reminder.updateMany({ contactId: id }, { contactId: null })— orphanise les rappels.contact.delete— efface le contact et ses échanges en cascade.
importContacts(dto, ctx)
Import bulk jusqu'à 500 contacts. Traitement par chunks de 50 pour ne pas saturer le pool de connexions Prisma.
- Chaque contact est créé via
prisma.contact.createde façon individuelle — les erreurs sont capturées et accumulées danserrors[]sans interrompre les items suivants. enterpriseIdetownerUserIdsont injectés depuis leUserContext(même logique quecreate()).- Retourne
{ imported: [...], errors: [...] }—importedcontient les sélectionsid/firstName/lastName/emailpour chaque contact créé.
bulkRemove(ids)
findManysur les IDs reçus avecselect: { id: true }— le middleware tenant restreint au scope, donc les IDs hors scope sont éliminés silencieusement.- Si aucun ID visible : retourne
{ deleted: 0 }sans toucher à la DB. reminder.updateMany: orphanise les rappels rattachés (même règle queremove()).contact.deleteMany: suppression en bloc, retourne{ deleted: count }.
Pas de transaction explicite : l'orphanisation puis le delete restent corrects en cas d'échec partiel (les rappels orphelins sont un état tolérable).
listTags(ctx)
- Utilise
$queryRaw(SELECT DISTINCT unnest(tags)) car Prisma n'expose pas cette opération. $queryRawbypasse le middleware tenant — le filtreenterpriseIdest appliqué manuellement.- Les super-admins voient tous les tags (sans filtre
enterpriseId).
DTOs
| DTO | Fichier | Champs requis |
|---|---|---|
CreateContactDto | dto/create-contact.dto.ts | firstName, lastName |
UpdateContactDto | dto/update-contact.dto.ts | aucun (PATCH) |
ContactQueryDto | dto/contact-query.dto.ts | aucun |
BulkDeleteContactsDto | dto/bulk-delete.dto.ts | ids (1 à 100 strings) |
ImportContactsDto | dto/import-contacts.dto.ts | contacts (1 à 500 items) |
5. Frontend — Architecture
Pages
| Page | Route | User story |
|---|---|---|
pages/contacts/index.vue | /contacts | US-2.4, US-2.5, US-2.6, US-6.1, US-6.2 |
pages/contacts/[id].vue | /contacts/:id | US-2.2, US-2.3, US-2.4 |
Composants
| Composant | Rôle |
|---|---|
components/contact/ContactFormModal.vue | Modale create/update — champs complets + création entreprise à la volée |
components/contact/ContactImportModal.vue | Wizard d'import en 5 étapes (source → mapping → doublons → confirm → résultats) |
components/contact/import/StepSource.vue | Étape 1 : dropzone CSV/vCard + bouton Contact Picker API (mobile) |
components/contact/import/StepMapping.vue | Étape 2 : association colonnes CSV → champs contact (auto-détection) |
components/contact/import/StepDuplicates.vue | Étape 3 : résolution doublon par doublon (ignorer / importer quand même) |
components/contact/import/StepConfirm.vue | Étape 4 : résumé avant import (N à importer, M ignorés) |
components/contact/import/StepResults.vue | Étape 5 : résultats (importés, erreurs détaillées) |
components/contact/ContactDetailHeader.vue | En-tête de la fiche (avatar, nom, statut, tags, actions) |
components/contact/ContactFiltersBar.vue | Barre de filtres inline (statut, tags, canal, entreprise) |
components/contact/ContactAdvancedFilters.vue | Panneau latéral "Plus de filtres" (activité, rappels, propriétaire) |
components/contact/ContactActiveChips.vue | Chips des filtres actifs avec croix de suppression |
components/contact/ContactCompanyCard.vue | Mini-fiche entreprise dans la fiche contact |
components/contact/ContactRemindersPanel.vue | Panneau rappels actifs sur la fiche contact |
components/contact/ContactExchangesPanel.vue | Panneau échanges (voir EXCHANGES.md) |
components/BulkActionBar.vue | Barre d'actions flottante quand des lignes sont sélectionnées |
ContactFormModal
Props :
modelValue: boolean— ouverture/fermeturecontactId?: string | null— mode édition si fourni
Comportement clé :
- Création : après
POST /contacts, émetsaved, redirige vers/contacts/{id}. - Modification : émet
saved, la page parente invalide le cache TanStack Query. - Création d'entreprise à la volée depuis le champ
companyIdviaCompanyCombobox.
Composable useContacts
Fichier : apps/web/composables/useContacts.ts
ts
useContactList(filters) // GET /contacts?...
useContact(id) // GET /contacts/:id
useContactTags() // GET /contacts/tags
useCreateContact() // POST /contacts
useUpdateContact(id) // PATCH /contacts/:id
useDeleteContact() // DELETE /contacts/:id
useDeleteContactsBulk() // POST /contacts/bulk-deleteComposable useImportContacts
Fichier : apps/web/composables/useImportContacts.ts
ts
parseCSV(file) // papaparse → { headers, rows }
autoDetectMapping(headers) // headers → mapping auto (FR+EN)
applyCSVMapping(rows, mapping) // rows + mapping → ParsedContact[]
parseVCard(file) // parsing vCard (RFC 6350) → ParsedContact[]
readNativeContacts() // Contact Picker API → ParsedContact[]
checkDuplicatesBatch(contacts) // appels parallèles /check-duplicate
importContactsAsync(contacts) // POST /contacts/import + invalidation cache
isContactPickerSupported // computed — true si Contact Picker API dispoLe parsing CSV et vCard se fait entièrement côté client — le serveur ne reçoit jamais de fichier brut, seulement le tableau de contacts structurés.
Contact Picker API (mobile uniquement) : disponible sur Chrome Android et Safari iOS ≥ 14.5. Le bouton n'est rendu que si 'contacts' in navigator. Sur desktop, seule la dropzone fichier est proposée.
Invalidations de cache TanStack Query :
create→ invalide['contacts']update→ invalide['contacts']et['contact', id]delete→ invalide['contacts']bulkDelete→ invalide['contacts']
Sélection multi-lignes et bulk actions
Composable générique composables/useRowSelection.ts :
ts
const selection = useRowSelection<Contact>(itemsRef)
// → selected, selectedIds, isSelected, toggle, toggleAll, clear, count, allSelected, someSelectedSémantique :
- Le périmètre de
toggleAllest la page courante (pas tout le résultat filtré). - La sélection persiste à travers les changements de page.
- C'est à la page de
clear()la sélection quand les filtres changent (unwatchsur l'objet filtres).
Le composant BulkActionBar s'affiche en bas d'écran quand count > 0, affiche le compteur et l'action de suppression bulk. La page liste écoute son événement delete et appelle useDeleteContactsBulk() avec selectedIds.value.
Composable useContactFilters
Fichier : apps/web/composables/useContactFilters.ts
Gère l'état réactif de tous les filtres actifs sur la page liste. Expose :
| Élément | Rôle |
|---|---|
filters | Objet réactif (ContactFiltersState) — partagé entre composants |
hasActiveFilters | true dès qu'au moins un filtre est actif |
hasAdvancedFilters | true si au moins un filtre du tiroir "Plus de filtres" est actif |
advancedFilterCount | Nombre de filtres avancés actifs (badge sur le bouton) |
clearFilters() | Réinitialise tous les filtres |
applyView(view) | Applique une vue enregistrée (écrase les filtres courants) |
toSavedViewFilters() | Sérialise l'état courant en SavedView['filters'] |
Filtres de base (visibles dans ContactFiltersBar) : q, status, companyId, tags.
Filtres avancés (dans ContactAdvancedFilters) : hasEmail, lastExchangeDirection, activityPeriod, reminderStatus.
Le compteur advancedFilterCount ne compte que les filtres avancés — companyId est un filtre de base.
6. Multi-tenancy
L'isolation est entièrement gérée par PrismaService via AsyncLocalStorage. Les services ne filtrent jamais manuellement par enterpriseId (exception : listTags avec $queryRaw).
| Rôle | Visibilité contacts |
|---|---|
member | Uniquement les siens (ownerUserId = userId) |
administrateur | Tous les contacts de son enterpriseId |
super-admin | Bypass complet des filtres tenant |
Seuls les admins peuvent filtrer par ownerUserId dans list() — pour un membre, ce paramètre est ignoré (le middleware l'impose déjà).
7. Tests
Tests unitaires
Fichier : apps/api/src/contacts/contacts.service.spec.ts
Couverture :
list()— pagination, filtresq/status/companyId/tags/hasEmail/reminderStatus/activityPeriod, tri whitelisté,ownerUserIdignoré pour les membresfindOne()—firstContactAtdepuis échange, fallbackcreatedAt,NotFoundExceptionhors scopecreate()— injectionenterpriseId/ownerUserId, tags initialisés à[]update()— mise à jour,NotFoundExceptionsur P2025, re-lève les autres erreurs Prismaremove()— orphanisation rappels puis suppression,NotFoundExceptionhors scopebulkRemove()— liste vide →{ deleted: 0 }, scoping tenant (IDs hors scope ignorés), orphanisation rappels avant deleteManylistTags()— filtreenterpriseIdpour membres, bypass pour super-adminsimportContacts()— import multiple, injection ctx, tags[]par défaut, isolation erreurs par item, traitement en chunks de 50
bash
yarn workspace @heriade/api test:unit --testPathPattern="contacts.service"Tests E2E Playwright
Fichier : apps/e2e/tests/contacts.spec.ts
Couverture (CRUD) :
- US-2.1 — création minimale et complète, validation requise, toast, redirection fiche
- US-2.2 — fiche complète (header, email, rôle, métadonnées, perf < 2s)
- US-2.3 — modification (formulaire pré-rempli, mise à jour en place, toast, annulation)
- US-2.4 — suppression depuis la fiche (confirmation, redirection, toast, annulation) et depuis la liste (modale avec nom, toast, annulation)
- Bulk delete — BulkActionBar masquée sans sélection, compteur sur cocher, "Tout sélectionner" sur page, "Tout désélectionner", suppression effective + toast, reset sélection sur changement de filtre
- US-2.5 — liste (colonnes, compteur, navigation, tri)
- US-2.6 — tags (affichage pastilles, liste, formulaire d'édition)
- US-6.1 — recherche textuelle : filtre la liste, effacer réinitialise
- US-6.2 — filtre entreprise : champ visible, sélection → chip avec nom, filtrage liste, suppression chip
- US-6.3 — filtres avancés : ouverture tiroir, badge compteur, fermeture sans effet
- US-6.4 — vues enregistrées : vues par défaut accessibles, appliquer une vue active le filtre
- US-6.5 — chips actifs : chip sur statut, "Tout effacer" nettoie tout
- API — GET 200/401/404, POST 201/400, PATCH 200, DELETE 204, GET /tags, GET ?companyId, GET ?q
bash
yarn test:e2e --grep "US-2"
yarn test:e2e --grep "US-6"
yarn test:e2e --grep "API Contacts"Import de contacts
Fichier : apps/e2e/tests/contact-import.spec.ts
Couverture :
- Bouton import — visibilité, ouverture modale
- Import CSV — upload fichier, auto-détection colonnes, mapping, parcours complet, format invalide rejeté
- Import vCard — upload .vcf, parcours sans étape mapping
- Doublons — étape de résolution affichée, ignorer → 0 importé
- API —
POST /contacts/import: 201 ok, 400 tableau vide, 400 email invalide, 401 sans token
La Contact Picker API (sélection depuis les contacts natifs mobile) n'est pas couverte en E2E Playwright — elle nécessite un contexte navigateur mobile avec permissions système. À tester manuellement sur Chrome Android ou Safari iOS.
bash
yarn test:e2e --grep "Import"
yarn test:e2e --grep "API POST /contacts/import"8. User stories couvertes
| Story | Titre | Priorité | Statut |
|---|---|---|---|
| US-2.1 | Créer un contact rapidement | Critique MVP | ✅ Done |
| US-2.2 | Voir une fiche contact complète | Critique MVP | ✅ Done |
| US-2.3 | Modifier un contact | Critique MVP | ✅ Done |
| US-2.4 | Supprimer un contact | Important | ✅ Done |
| US-2.5 | Voir la liste de mes contacts | Critique MVP | ✅ Done |
| US-2.6 | Gérer les tags d'un contact | Important | ✅ Done |
| US-6.1 | Recherche textuelle multi-champs | Critique MVP | ✅ Done |
| US-6.2 | Filtres avancés sur la liste | Important | ✅ Done (voir §9 — filtre canal documenté comme écart) |
| US-6.3 | Filtres avancés dans le tiroir | Important | ✅ Done |
| US-6.4 | Vues enregistrées | Nice-to-have | ✅ Done |
| US-6.5 | Chips des filtres actifs | Nice-to-have | ✅ Done |
9. Écarts avec les user stories (E6)
| US | Critère d'acceptation | Implémentation | Statut |
|---|---|---|---|
| US-6.2 | Filtrer par canal (LinkedIn, Gmail…) | Non implémenté en frontend (backend non concerné par ce filtre) | ⚠️ Écart volontaire v1 |
| US-6.2 | Filtrer par entreprise | EntitySearchInput mode="company" dans ContactFiltersBar | ✅ Conforme |
Canal non implémenté comme filtre de liste (US-6.2)
Le filtre "canal" dans la liste contacts n'a pas de valeur directe dans ContactsService.list() : un contact peut avoir des échanges sur plusieurs canaux simultanément, ce qui rend un filtre simple peu pertinent. La vraie valeur est dans lastExchangeDirection (filtre existant) et dans la timeline échanges par contact. Ce filtre est candidat post-MVP avec un clarification produit sur la sémantique attendue (dernier échange ? au moins un échange ?).
Document v2 · mai 2026 · à synchroniser avec les user stories (heriade-contacts-USER-STORIES.md)
