Skip to content

Module Entreprises — Documentation technique

Epic E3 — Gestion des entreprises
User stories : US-3.1, US-3.2, US-3.3, US-3.4


1. Responsabilités du module

Le module companies permet à François de structurer ses contacts par compte client/prospect. Une entreprise est un conteneur multi-contacts avec agrégation automatique de l'activité (échanges) de tous ses contacts.

Ce que le module fait :

  • CRUD complet sur les entreprises (créer, lire, modifier, supprimer)
  • Liste paginée avec recherche texte et filtres secteur/taille
  • Fiche détaillée avec contacts liés, rappels, et timeline d'échanges agrégée
  • Prévention de suppression si des contacts sont encore rattachés

Ce que le module ne fait pas :

  • Enrichissement automatique (Pappers/INSEE) — saisie 100 % manuelle en v1
  • Import CSV
  • Partage inter-tenants

2. Schéma de données

prisma
model Company {
  id           String    @id @default(cuid())
  enterpriseId Int                        // isolation multi-tenant
  ownerUserId  String                     // propriétaire de la fiche
  name         String
  domain       String?
  industry     String?
  size         String?                    // valeurs : COMPANY_SIZES (apps/web/utils/company.ts)
  notes        String?

  // Champs réservés à l'enrichissement futur (non exposés en v1)
  siren            String?   @unique
  siret            String?
  legalForm        String?
  headquartersCity String?
  enrichedAt       DateTime?
  enrichmentSource String?

  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  contacts  Contact[]
  reminders Reminder[]

  @@index([enterpriseId])
}

Relations

  • Company → Contact : 1:N, ON DELETE SET NULL (détacher, pas supprimer les contacts)
  • Company → Reminder : 1:N, ON DELETE SET NULL

Tailles canoniques

Défini dans apps/web/utils/company.ts — source de vérité unique pour le formulaire et les filtres :

ts
export const COMPANY_SIZES = ['1-10', '11-50', '51-200', '201-1000', '1000+'] as const

3. API REST

Base URL : /api/companies

MéthodeRouteDescription
GET/companiesListe paginée + recherche + filtres
GET/companies/:idFiche complète (contacts, rappels, stats)
GET/companies/:id/exchangesTimeline d'échanges agrégée
POST/companiesCréer une entreprise (201)
POST/companies/bulk-deleteSupprimer plusieurs entreprises (200, max 100 IDs)
PATCH/companies/:idModifier une entreprise (200)
DELETE/companies/:idSupprimer une entreprise (204)

Paramètres GET /companies

ParamètreTypeDescription
qstringRecherche sur name et domain
industrystringFiltre exact sur le secteur
sizestringFiltre exact sur la taille
pagenumberPage courante (défaut : 1)
limitnumberRésultats par page (défaut : 25)

Paramètres GET /companies/:id/exchanges

ParamètreTypeDescription
channelChannel enumFiltre par canal (LINKEDIN, GMAIL…)
contactIdstringFiltre par contact de l'entreprise

POST /companies/bulk-delete

Body :

json
{
  "ids": ["cmp-1", "cmp-2"],
  "deleteMode": "DETACH"
}

ids : tableau de 1 à 100 strings.
deleteMode (optionnel) : DETACH ou CASCADE — voir comportement ci-dessous.

Workflow en deux temps (DETACH/CASCADE) :

  1. Première requête sans deleteMode : si au moins une entreprise sélectionnée a des contacts rattachés, l'API retourne 409 Conflict avec la liste des entreprises bloquantes :

    json
    {
      "message": "Mode requis : 2 entreprise(s) ont des contacts rattachés.",
      "companiesWithContacts": [
        { "id": "cmp-1", "name": "Acme", "contactCount": 3 },
        { "id": "cmp-4", "name": "Globex", "contactCount": 1 }
      ]
    }

    L'UI peut alors présenter le choix à l'utilisateur (cf. workflow remove() unitaire).

  2. Seconde requête avec deleteMode :

    • DETACH : contact.companyId mis à null sur les contacts liés (les contacts sont préservés)
    • CASCADE : contact.deleteMany sur les contacts liés (les contacts sont supprimés avec l'entreprise)

    Puis suppression en bloc des entreprises possédées. Le tout dans une $transaction (atomicité).

Si aucune entreprise n'a de contacts : deleteMode est facultatif, la suppression est directe.

Scoping : les entreprises hors scope tenant ou non possédées (pour les non-admins) sont ignorées silencieusement (deleted < ids.length).

Réponse 200 :

json
{ "deleted": 2 }

Codes d'erreur

CodeSituation
404Entreprise introuvable (hors scope tenant)
403L'utilisateur ne possède pas l'entreprise
409Suppression bloquée : contacts encore rattachés (unitaire ou bulk)
400Valeur channel invalide dans /exchanges, ou bulk ids hors bornes

4. Backend — Architecture

CompaniesService

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

list(query)

  • Recherche texte avec OR [name, domain] en mode insensible à la casse
  • Agrège exchangeCount et lastActivity via une requête SQL brute (une seule requête pour toutes les entreprises retournées)
  • Optimisation : ne lance pas le $queryRaw si la liste est vide

findOne(id)

  • Inclut les 5 derniers contacts (updatedAt DESC) et les 5 prochains rappels non complétés (dueAt ASC)
  • exchangeCount et lastActivity calculés en SQL brut

getExchanges(companyId, filters)

  • Valide filters.channel contre l'enum Prisma Channel — lève BadRequestException si invalide
  • Retourne les échanges ordonnés par exchangedAt DESC avec le contact lié

remove(id, ctx)

  • Un seul findFirst incluant _count.contacts — vérifie ownership et présence de contacts dans la même passe (pas de double lookup)
  • Lève ConflictException si _count.contacts > 0 avec le nombre exact dans le message

bulkRemove(ids, ctx, deleteMode?)

  1. findMany avec _count.contacts pour évaluer le bloquant en une passe.
  2. Filtre par ownership (non-admins voient uniquement leurs entreprises).
  3. Si au moins une entreprise a des contacts ET que deleteMode est absent → ConflictException avec companiesWithContacts détaillé pour permettre à l'UI de réafficher le choix DETACH/CASCADE.
  4. $transaction atomique :
    • DETACH : contact.updateManycompanyId = null sur les contacts liés
    • CASCADE : contact.deleteMany sur les contacts liés
    • puis company.deleteMany sur les IDs possédés

Cap à 100 IDs côté DTO (@ArrayMaxSize(100)) — limite la charge de la requête findMany initiale et de la transaction.

assertOwnership(id, ctx) (privée)

  • Utilisée uniquement par update() — les admins et super-admins byprassent le check

DTOs

DTOFichierChamps requis
CreateCompanyDtodto/create-company.dto.tsname
UpdateCompanyDtodto/update-company.dto.tsaucun (PATCH)
CompanyQueryDtodto/company-query.dto.tsaucun
DeleteCompanyDtodto/delete-company.dto.tsdeleteMode? (DETACH/CASCADE)
BulkDeleteCompaniesDtodto/bulk-delete.dto.tsids (1 à 100), deleteMode?

5. Frontend — Architecture

Pages

PageRouteUser story
pages/companies/index.vue/companiesUS-3.3
pages/companies/[id].vue/companies/:idUS-3.2

Composants

ComposantRôle
components/company/CompanyFormModal.vueModale create/update/delete (US-3.1, US-3.4)
components/company/CompanyDeleteModal.vueModale dédiée DETACH/CASCADE — unitaire et bulk
components/CompanyCombobox.vueSélecteur d'entreprise dans le formulaire contact
components/contact/ContactCompanyCard.vueMini-fiche entreprise dans la fiche contact
components/BulkActionBar.vueBarre d'actions flottante (sélection multi-lignes)

CompanyFormModal

Props :

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

Comportement clé :

  • Création : après POST /companies, émet saved puis redirige vers /companies/{id} (US-3.1)
  • Modification : émet saved, la page parente ferme la modale et invalide le cache
  • Suppression : confirmation obligatoire, affiche le message d'erreur serveur si 409

Composable useCompanies

Fichier : apps/web/composables/useCompanies.ts

ts
useCompanyList(filters)       // GET /companies?...
useCompany(id)                // GET /companies/:id
useCreateCompany()            // POST /companies
useUpdateCompany(id)          // PATCH /companies/:id
useDeleteCompany()            // DELETE /companies/:id?deleteMode=...
useDeleteCompaniesBulk()      // POST /companies/bulk-delete

Invalidations de cache TanStack Query :

  • create → invalide ['companies']
  • update → invalide ['companies'] et ['company', id]
  • delete → invalide ['companies']
  • bulkDelete → invalide ['companies'] et ['contacts'] (les contacts détachés/cascade modifient la liste contacts)

Sélection multi-lignes et bulk actions

Même mécanique que pour les contacts : useRowSelection + BulkActionBar (voir CONTACTS.md §5).

Spécificité côté entreprises : quand l'API retourne 409 avec companiesWithContacts, la page ouvre CompanyDeleteModal pré-rempli pour récupérer le choix DETACH/CASCADE puis rejoue useDeleteCompaniesBulk() avec le mode choisi.


6. Multi-tenancy

L'isolation multi-tenant est entièrement gérée par PrismaService via AsyncLocalStorage. Les services ne filtrent jamais manuellement par enterpriseId.

Comportement par rôle :

RôleAccès
memberVoit uniquement ses entreprises (ownerUserId)
adminVoit toutes les entreprises de son enterpriseId
super-adminBypass complet des filtres tenant

Pour modifier ou supprimer une entreprise, un member doit en être le propriétaire. Un admin n'a pas cette restriction.


7. Tests

Tests unitaires

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

Couverture :

  • list() — pagination, filtres q/industry/size, stats SQL, liste vide
  • findOne() — stats d'échange, NotFoundException hors scope
  • getExchanges() — filtres channel/contactId, BadRequestException channel invalide, NotFoundException
  • create() — injection enterpriseId/ownerUserId, champs optionnels
  • update() — mise à jour, ForbiddenException, bypass admin/super-admin, NotFoundException
  • remove() — suppression simple, ConflictException avec contacts, ForbiddenException, bypass admin, appel unique à findFirst
  • bulkRemove() — liste vide, sans contacts (pas de mode requis), ConflictException 409 avec companiesWithContacts si bloquant et pas de mode, DETACH (contact.updateMany), CASCADE (contact.deleteMany), scoping ownership non-admin, bypass admin, IDs fantômes
bash
yarn workspace @heriade/api test:unit --testPathPattern="companies.service"

Tests E2E Playwright

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

Couverture :

  • US-3.1 — création minimale et complète, redirection vers la fiche, validation nom requis, toast
  • US-3.2 — fiche complète (header, stats, onglets, boutons), état vide contacts, perf < 2s
  • US-3.3 — liste (colonnes, compteur, navigation, recherche, filtre secteur, état vide)
  • US-3.4 — modification (formulaire pré-rempli, mise à jour en place, toast, annulation), suppression (redirection, toast, annulation, blocage avec contacts)
  • Bulk delete — compteur sur cocher, suppression directe sans mode si pas de contacts rattachés, ouverture modale DETACH/CASCADE avec liste des bloquantes, DETACH (entreprise supprimée + contact préservé avec companyId = null), CASCADE (entreprise + contact supprimés)
bash
# Nécessite api :3001 et web :3000 démarrés
yarn test:e2e --grep "US-3"

8. User stories couvertes

StoryTitrePrioritéStatut
US-3.1Créer une entrepriseCritique MVP✅ Done
US-3.2Voir une fiche entrepriseCritique MVP✅ Done
US-3.3Lister les entreprisesCritique MVP✅ Done
US-3.4Modifier / supprimerImportant✅ Done

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