Appearance
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 const3. API REST
Base URL : /api/companies
| Méthode | Route | Description |
|---|---|---|
GET | /companies | Liste paginée + recherche + filtres |
GET | /companies/:id | Fiche complète (contacts, rappels, stats) |
GET | /companies/:id/exchanges | Timeline d'échanges agrégée |
POST | /companies | Créer une entreprise (201) |
POST | /companies/bulk-delete | Supprimer plusieurs entreprises (200, max 100 IDs) |
PATCH | /companies/:id | Modifier une entreprise (200) |
DELETE | /companies/:id | Supprimer une entreprise (204) |
Paramètres GET /companies
| Paramètre | Type | Description |
|---|---|---|
q | string | Recherche sur name et domain |
industry | string | Filtre exact sur le secteur |
size | string | Filtre exact sur la taille |
page | number | Page courante (défaut : 1) |
limit | number | Résultats par page (défaut : 25) |
Paramètres GET /companies/:id/exchanges
| Paramètre | Type | Description |
|---|---|---|
channel | Channel enum | Filtre par canal (LINKEDIN, GMAIL…) |
contactId | string | Filtre 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) :
Première requête sans
deleteMode: si au moins une entreprise sélectionnée a des contacts rattachés, l'API retourne409 Conflictavec 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).Seconde requête avec
deleteMode:DETACH:contact.companyIdmis ànullsur les contacts liés (les contacts sont préservés)CASCADE:contact.deleteManysur 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
| Code | Situation |
|---|---|
404 | Entreprise introuvable (hors scope tenant) |
403 | L'utilisateur ne possède pas l'entreprise |
409 | Suppression bloquée : contacts encore rattachés (unitaire ou bulk) |
400 | Valeur 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
exchangeCountetlastActivityvia une requête SQL brute (une seule requête pour toutes les entreprises retournées) - Optimisation : ne lance pas le
$queryRawsi la liste est vide
findOne(id)
- Inclut les 5 derniers contacts (
updatedAt DESC) et les 5 prochains rappels non complétés (dueAt ASC) exchangeCountetlastActivitycalculés en SQL brut
getExchanges(companyId, filters)
- Valide
filters.channelcontre l'enum PrismaChannel— lèveBadRequestExceptionsi invalide - Retourne les échanges ordonnés par
exchangedAt DESCavec le contact lié
remove(id, ctx)
- Un seul
findFirstincluant_count.contacts— vérifie ownership et présence de contacts dans la même passe (pas de double lookup) - Lève
ConflictExceptionsi_count.contacts > 0avec le nombre exact dans le message
bulkRemove(ids, ctx, deleteMode?)
findManyavec_count.contactspour évaluer le bloquant en une passe.- Filtre par ownership (non-admins voient uniquement leurs entreprises).
- Si au moins une entreprise a des contacts ET que
deleteModeest absent →ConflictExceptionaveccompaniesWithContactsdétaillé pour permettre à l'UI de réafficher le choix DETACH/CASCADE. $transactionatomique :DETACH:contact.updateMany→companyId = nullsur les contacts liésCASCADE:contact.deleteManysur les contacts liés- puis
company.deleteManysur 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
| DTO | Fichier | Champs requis |
|---|---|---|
CreateCompanyDto | dto/create-company.dto.ts | name |
UpdateCompanyDto | dto/update-company.dto.ts | aucun (PATCH) |
CompanyQueryDto | dto/company-query.dto.ts | aucun |
DeleteCompanyDto | dto/delete-company.dto.ts | deleteMode? (DETACH/CASCADE) |
BulkDeleteCompaniesDto | dto/bulk-delete.dto.ts | ids (1 à 100), deleteMode? |
5. Frontend — Architecture
Pages
| Page | Route | User story |
|---|---|---|
pages/companies/index.vue | /companies | US-3.3 |
pages/companies/[id].vue | /companies/:id | US-3.2 |
Composants
| Composant | Rôle |
|---|---|
components/company/CompanyFormModal.vue | Modale create/update/delete (US-3.1, US-3.4) |
components/company/CompanyDeleteModal.vue | Modale dédiée DETACH/CASCADE — unitaire et bulk |
components/CompanyCombobox.vue | Sélecteur d'entreprise dans le formulaire contact |
components/contact/ContactCompanyCard.vue | Mini-fiche entreprise dans la fiche contact |
components/BulkActionBar.vue | Barre d'actions flottante (sélection multi-lignes) |
CompanyFormModal
Props :
modelValue: boolean— ouverture/fermeturecompanyId?: string | null— mode édition si fourni
Comportement clé :
- Création : après
POST /companies, émetsavedpuis 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-deleteInvalidations 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ôle | Accès |
|---|---|
member | Voit uniquement ses entreprises (ownerUserId) |
admin | Voit toutes les entreprises de son enterpriseId |
super-admin | Bypass 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, filtresq/industry/size, stats SQL, liste videfindOne()— stats d'échange, NotFoundException hors scopegetExchanges()— filtres channel/contactId, BadRequestException channel invalide, NotFoundExceptioncreate()— injectionenterpriseId/ownerUserId, champs optionnelsupdate()— mise à jour, ForbiddenException, bypass admin/super-admin, NotFoundExceptionremove()— suppression simple, ConflictException avec contacts, ForbiddenException, bypass admin, appel unique à findFirstbulkRemove()— liste vide, sans contacts (pas de mode requis),ConflictException409 aveccompaniesWithContactssi 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
| Story | Titre | Priorité | Statut |
|---|---|---|---|
| US-3.1 | Créer une entreprise | Critique MVP | ✅ Done |
| US-3.2 | Voir une fiche entreprise | Critique MVP | ✅ Done |
| US-3.3 | Lister les entreprises | Critique MVP | ✅ Done |
| US-3.4 | Modifier / supprimer | Important | ✅ Done |
Document v1 · mai 2026 · à synchroniser avec les user stories (heriade-contacts-USER-STORIES.md)
