Skip to content

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 const

Relations

  • Contact → Company : N:1, optionnel. Suppression Company → companyId = null sur 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éthodeRouteDescription
GET/contactsListe paginée + recherche + filtres
GET/contacts/tagsTags distincts accessibles à l'utilisateur
GET/contacts/:idFiche complète (échanges, rappels, entreprise)
POST/contactsCréer un contact (201)
POST/contacts/importImporter jusqu'à 500 contacts en bulk (201)
POST/contacts/bulk-deleteSupprimer plusieurs contacts (200, max 100 IDs)
PATCH/contacts/:idModifier un contact (200)
DELETE/contacts/:idSupprimer un contact (204)

Paramètres GET /contacts

ParamètreTypeDescription
qstringRecherche sur prénom, nom, email, rôle, entreprise
statusCONTACT_STATUSESFiltre exact par statut
tagsstring (csv)Filtre hasEvery — tous les tags doivent matcher
companyIdstringFiltre par entreprise
ownerUserIdstringFiltre par propriétaire (admin/super-admin seulement)
hasEmailbooleantrue = avec email, false = sans
lastExchangeDirectionINCOMING | OUTGOINGDirection du dernier échange
activityPeriodtoday | 7 | 30 | 90Activité dans les N derniers jours
reminderStatusoverdue,upcoming,none (csv)Filtre par état des rappels (combinable)
sortByupdatedAt | createdAt | lastName | statusChamp de tri (défaut : updatedAt)
sortDirasc | descDirection du tri (défaut : desc)
pagenumberPage courante (défaut : 1)
limitnumberRésultats par page (défaut : 25)

sortBy est validé contre une whitelist côté service — toute valeur inconnue retombe silencieusement sur updatedAt (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

CodeSituation
400Champ manquant ou invalide (firstName/lastName vides)
401Token absent ou invalide
404Contact 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 where Prisma dynamique selon les filtres actifs.
  • Recherche texte (q) : OR sur 5 champs en mode insensible à la casse.
  • reminderStatus supporte plusieurs valeurs simultanées (ex: overdue,upcoming) via OR interne.
  • ownerUserId ignoré si l'utilisateur n'est pas admin (le middleware Prisma garantit déjà la vue réduite).
  • sortBy validé contre SORTABLE_FIELDS = ['updatedAt', 'createdAt', 'lastName', 'status'].
  • Exécute findMany et count en parallèle (Promise.all).

findOne(id)

  • Utilise findFirst (pas findUnique) pour que le middleware tenant puisse injecter les filtres.
  • Inclut les 20 derniers échanges (take: 20, orderBy exchangedAt desc).
  • Calcule firstContactAt via exchange.aggregate._min.exchangedAt — fallback sur contact.createdAt si aucun échange.

create(dto, ctx)

  • Injecte enterpriseId et ownerUserId depuis le UserContext.
  • Initialise tags à [] si non fournis.

update(id, dto)

  • Utilise prisma.contact.update (scopé par le middleware — un contact hors scope retourne P2025).
  • Mappe l'erreur Prisma P2025 en NotFoundException.

remove(id)

  1. findFirst pour vérifier que le contact est dans le scope tenant.
  2. reminder.updateMany({ contactId: id }, { contactId: null }) — orphanise les rappels.
  3. 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.create de façon individuelle — les erreurs sont capturées et accumulées dans errors[] sans interrompre les items suivants.
  • enterpriseId et ownerUserId sont injectés depuis le UserContext (même logique que create()).
  • Retourne { imported: [...], errors: [...] }imported contient les sélections id/firstName/lastName/email pour chaque contact créé.

bulkRemove(ids)

  1. findMany sur les IDs reçus avec select: { id: true } — le middleware tenant restreint au scope, donc les IDs hors scope sont éliminés silencieusement.
  2. Si aucun ID visible : retourne { deleted: 0 } sans toucher à la DB.
  3. reminder.updateMany : orphanise les rappels rattachés (même règle que remove()).
  4. 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.
  • $queryRaw bypasse le middleware tenant — le filtre enterpriseId est appliqué manuellement.
  • Les super-admins voient tous les tags (sans filtre enterpriseId).

DTOs

DTOFichierChamps requis
CreateContactDtodto/create-contact.dto.tsfirstName, lastName
UpdateContactDtodto/update-contact.dto.tsaucun (PATCH)
ContactQueryDtodto/contact-query.dto.tsaucun
BulkDeleteContactsDtodto/bulk-delete.dto.tsids (1 à 100 strings)
ImportContactsDtodto/import-contacts.dto.tscontacts (1 à 500 items)

5. Frontend — Architecture

Pages

PageRouteUser story
pages/contacts/index.vue/contactsUS-2.4, US-2.5, US-2.6, US-6.1, US-6.2
pages/contacts/[id].vue/contacts/:idUS-2.2, US-2.3, US-2.4

Composants

ComposantRôle
components/contact/ContactFormModal.vueModale create/update — champs complets + création entreprise à la volée
components/contact/ContactImportModal.vueWizard 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.vueEn-tête de la fiche (avatar, nom, statut, tags, actions)
components/contact/ContactFiltersBar.vueBarre de filtres inline (statut, tags, canal, entreprise)
components/contact/ContactAdvancedFilters.vuePanneau latéral "Plus de filtres" (activité, rappels, propriétaire)
components/contact/ContactActiveChips.vueChips des filtres actifs avec croix de suppression
components/contact/ContactCompanyCard.vueMini-fiche entreprise dans la fiche contact
components/contact/ContactRemindersPanel.vuePanneau rappels actifs sur la fiche contact
components/contact/ContactExchangesPanel.vuePanneau échanges (voir EXCHANGES.md)
components/BulkActionBar.vueBarre d'actions flottante quand des lignes sont sélectionnées

ContactFormModal

Props :

  • modelValue: boolean — ouverture/fermeture
  • contactId?: string | null — mode édition si fourni

Comportement clé :

  • Création : après POST /contacts, émet saved, 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 companyId via CompanyCombobox.

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-delete

Composable 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 dispo

Le 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, someSelected

Sémantique :

  • Le périmètre de toggleAll est 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 (un watch sur l'objet filtres).

Le composant BulkActionBar s'affiche en bas d'écran quand count &gt; 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émentRôle
filtersObjet réactif (ContactFiltersState) — partagé entre composants
hasActiveFilterstrue dès qu'au moins un filtre est actif
hasAdvancedFilterstrue si au moins un filtre du tiroir "Plus de filtres" est actif
advancedFilterCountNombre 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ôleVisibilité contacts
memberUniquement les siens (ownerUserId = userId)
administrateurTous les contacts de son enterpriseId
super-adminBypass 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, filtres q/status/companyId/tags/hasEmail/reminderStatus/activityPeriod, tri whitelisté, ownerUserId ignoré pour les membres
  • findOne()firstContactAt depuis échange, fallback createdAt, NotFoundException hors scope
  • create() — injection enterpriseId/ownerUserId, tags initialisés à []
  • update() — mise à jour, NotFoundException sur P2025, re-lève les autres erreurs Prisma
  • remove() — orphanisation rappels puis suppression, NotFoundException hors scope
  • bulkRemove() — liste vide → { deleted: 0 }, scoping tenant (IDs hors scope ignorés), orphanisation rappels avant deleteMany
  • listTags() — filtre enterpriseId pour membres, bypass pour super-admins
  • importContacts() — 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é
  • APIPOST /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

StoryTitrePrioritéStatut
US-2.1Créer un contact rapidementCritique MVP✅ Done
US-2.2Voir une fiche contact complèteCritique MVP✅ Done
US-2.3Modifier un contactCritique MVP✅ Done
US-2.4Supprimer un contactImportant✅ Done
US-2.5Voir la liste de mes contactsCritique MVP✅ Done
US-2.6Gérer les tags d'un contactImportant✅ Done
US-6.1Recherche textuelle multi-champsCritique MVP✅ Done
US-6.2Filtres avancés sur la listeImportant✅ Done (voir §9 — filtre canal documenté comme écart)
US-6.3Filtres avancés dans le tiroirImportant✅ Done
US-6.4Vues enregistréesNice-to-have✅ Done
US-6.5Chips des filtres actifsNice-to-have✅ Done

9. Écarts avec les user stories (E6)

USCritère d'acceptationImplémentationStatut
US-6.2Filtrer par canal (LinkedIn, Gmail…)Non implémenté en frontend (backend non concerné par ce filtre)⚠️ Écart volontaire v1
US-6.2Filtrer par entrepriseEntitySearchInput 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)