Leads Entreprise (Grands Comptes)
Ce document présente le module “Grands Comptes” (apps/api/src/leads + apps/store/app/grands-comptes + apps/dashboard/app/(dashboard)/leads-entreprise) : un formulaire de contact B2B public, traité manuellement par un administrateur via une file d’attente en temps réel.
1. Vue d’ensemble
“Grands Comptes” cible les gros acheteurs B2B (restauration collective, distributeurs, transformateurs) qui ne passent pas par le parcours d’achat classique du Store, mais souhaitent être contactés pour négocier des tarifs au volume, des commandes récurrentes ou des délais de paiement adaptés.
Le mécanisme est volontairement simple : un formulaire public sans authentification (POST /leads/enterprise) crée un EnterpriseLead, et l’administrateur le traite dans une file dédiée du dashboard (PENDING → CONTACTED → CLOSED).
[!IMPORTANT] Ce dépôt n’a aucune capacité d’envoi d’email ou de SMS (pas de SMTP, pas de passerelle SMS configurée). Il n’existe donc aucune notification automatique envoyée à l’entreprise qui soumet sa demande, ni de confirmation par email. Le seul mécanisme de notification est une file d’attente admin, poussée en temps réel via SSE — voir §4. Le suivi du contact réel (appel téléphonique, email manuel) est entièrement hors du système, effectué par l’équipe commerciale.
2. Modèle de Données
EnterpriseLead
| Champ | Type | Description |
|---|---|---|
id | String (UUID) | Identifiant unique. |
companyName | String | Raison sociale de l’entreprise demandeuse. |
contactName | String | Nom de la personne à contacter. |
email | String | Email professionnel du contact. |
phone | String | Téléphone du contact. |
estimatedVolume | String? | Volume estimé (texte libre, ex: "500kg / semaine"), optionnel. |
message | String? | Besoin décrit librement par le demandeur, optionnel. |
status | LeadStatus | PENDING (défaut) → CONTACTED → CLOSED. |
createdAt / updatedAt | DateTime | Horodatage. |
[!NOTE]
EnterpriseLeadn’a aucune relation Prisma versUser: la demande est soumise sans compte, avant toute authentification. Ce n’est pas un modèle “denormalisé pour l’historique” commeAuditLog— c’est structurellement un formulaire de contact anonyme, indépendant du reste du schéma.
LeadStatus (enum)
PENDING → demande reçue, pas encore traitée
CONTACTED → un commercial a pris contact
CLOSED → dossier clôturé (converti ou non)La progression est strictement linéaire dans l’UI admin : il n’existe pas de bouton pour revenir en arrière (CLOSED → CONTACTED, par exemple).
3. Rôles et Autorisations (RBAC)
| Endpoint | Méthode | Rôle Requis | Description / Règle métier |
|---|---|---|---|
/leads/enterprise | POST | Public | Soumettre une demande de contact. Même convention que POST /auth/register : aucune authentification requise. |
/leads/enterprise | GET | ADMIN | Lister les demandes, avec filtre optionnel ?status=PENDING|CONTACTED|CLOSED. |
/leads/enterprise/:id | PATCH | ADMIN | Faire avancer le statut d’une demande ({ status }). |
[!NOTE] Les deux endpoints admin sont protégés par
JwtAuthGuard+RolesGuard+@Roles(UserRole.ADMIN), la même combinaison que pourPATCH /countries/:idouPATCH /products/:id/sponsorship.
4. Notification Temps Réel (SSE)
Il n’y a pas d’email envoyé à la création d’une demande. À la place, leads.service.ts pousse un événement SSE à tous les administrateurs connectés via SseService (le même service @Global() utilisé par le module de sponsorisation) :
this.sseService.broadcastToAdmins('lead.enterprise.created', {
id: lead.id,
companyName: lead.companyName,
contactName: lead.contactName,
});Côté dashboard, DashboardEventsListener.tsx écoute cet événement (parmi d’autres : sponsorship.requested, vendor.registration.created…) sur un flux EventSource('/api/events') monté une seule fois dans le layout, pour tous les rôles :
source.addEventListener('lead.enterprise.created', (event) => {
const data: EnterpriseLeadCreatedPayload = JSON.parse((event as MessageEvent).data);
toast.warning(`Nouvelle demande Grands Comptes : "${data.companyName}" (${data.contactName})`);
router.refresh();
});[!NOTE] Le filtrage par rôle est fait côté serveur dans
SseService.broadcastToAdmins(qui n’envoie qu’aux clients dontrole === 'ADMIN') : un vendeur ou un acheteur connecté au même flux/api/eventsne recevra jamais cet événement, même si le listener est monté globalement dans le layout.
AuditService est bien utilisé dans ce module, mais uniquement lors du changement de statut (updateStatus), pas à la création : chaque transition de statut est journalisée avec l’ancien et le nouveau statut en métadonnées ({ from: existing.status, to: updated.status }), et seulement si le statut a réellement changé.
5. Logique Métier Clés et Flux
A. Soumission publique (POST /leads/enterprise)
Aucune vérification de doublon, aucun rate-limiting applicatif visible dans le service : chaque soumission crée une nouvelle ligne EnterpriseLead en PENDING, puis déclenche la notification SSE. Les champs obligatoires (companyName, contactName, email, phone) sont validés côté formulaire Store (required HTML) et côté server action (actions.ts) avant l’appel API — le DTO API lui-même (CreateLeadDto) ne porte pas de décorateurs class-validator visibles dans le fichier, donc la validation stricte des formats (email, téléphone) repose sur la couche formulaire, pas sur l’API.
B. Traitement admin (PATCH /leads/enterprise/:id)
- Le service vérifie que la demande existe (
404 Not Foundsinon). - Il met à jour
status. - Si le statut a changé, il journalise l’action via
AuditService(action"Traitement demande Grands Comptes", sévéritéINFO).
Il n’y a aucune validation de transition au niveau service (par exemple, rien n’empêche techniquement de repasser de CLOSED à PENDING via un appel API direct) — c’est l’interface du dashboard qui restreint les transitions à un sens unique.
C. Compteur badge sidebar
getPendingLeadsCount() (dans actions.ts, apps/dashboard/app/(dashboard)/leads-entreprise/) appelle GET /leads/enterprise?status=PENDING et renvoie la longueur du tableau. Ce compte est chargé dans apps/dashboard/app/(dashboard)/layout.tsx et transmis en prop à Sidebar (pendingLeadsCount), qui affiche un badge numérique sur l’entrée de menu “Grands Comptes” — même mécanique que le badge des demandes de sponsorisation en attente ou des vendeurs en attente de validation.
6. Interface Publique (Store)
La page apps/store/app/grands-comptes/page.tsx présente une page marketing statique (arguments : tarifs négociés, commandes récurrentes, interlocuteur dédié) à côté du formulaire GrandsComptesForm.tsx :
- Formulaire client (
'use client') piloté paruseActionState, sans authentification. - Champs :
companyName*,contactName*,email*,phone*,estimatedVolume(optionnel),message(optionnel). - En cas de succès (
state.success), le formulaire est remplacé par un message de confirmation (“Un membre de notre équipe commerciale vous recontactera très prochainement.”) — pas de redirection, pas d’email de confirmation envoyé au demandeur (voir la note en §1).
7. Interface d’Administration (Dashboard)
La page apps/dashboard/app/(dashboard)/leads-entreprise/page.tsx (réservée au rôle ADMIN) organise les demandes en trois sections empilées, chacune avec son propre compteur dans le titre :
- Demandes en attente (
PENDING) - En cours de traitement (
CONTACTED) - Clôturées (
CLOSED)
Chaque ligne de tableau affiche entreprise, contact (nom + email + téléphone), volume estimé, message et date de réception, avec un bouton d’action fourni par LeadStatusButtons.tsx (même dossier) :
- Statut
PENDING→ bouton “Marquer contactée” →PATCH { status: 'CONTACTED' }. - Statut
CONTACTED→ bouton “Clôturer” →PATCH { status: 'CLOSED' }. - Statut
CLOSED→ aucun bouton (état terminal dans l’UI).
8. Guide des Endpoints et Exemples de Payloads
Soumission d’une demande (POST /leads/enterprise)
- Accès : Public, aucun header d’authentification.
- Requête :
{ "companyName": "Cantine Centrale SARL", "contactName": "Jean Dupont", "email": "contact@cantine-centrale.com", "phone": "+237 6XX XXX XXX", "estimatedVolume": "500kg / semaine", "message": "Recherche un fournisseur régulier de légumes bio pour notre cuisine centrale." } - Réponse (
201 Created) :{ "id": "b3e1f9c2-7a4d-4e2b-9f1a-6c8d0e2f4a1b", "companyName": "Cantine Centrale SARL", "contactName": "Jean Dupont", "email": "contact@cantine-centrale.com", "phone": "+237 6XX XXX XXX", "estimatedVolume": "500kg / semaine", "message": "Recherche un fournisseur régulier de légumes bio pour notre cuisine centrale.", "status": "PENDING", "createdAt": "2026-06-23T10:00:00.000Z", "updatedAt": "2026-06-23T10:00:00.000Z" }
Liste filtrée (GET /leads/enterprise?status=PENDING)
- Accès : Admin uniquement (Header
Authorization: Bearer <token_admin>). - Réponse (
200 OK) : tableau d’EnterpriseLead, trié parcreatedAtdécroissant.
Traitement d’une demande (PATCH /leads/enterprise/:id)
- Accès : Admin uniquement.
- Requête :
{ "status": "CONTACTED" } - Réponse (
200 OK) :{ "id": "b3e1f9c2-7a4d-4e2b-9f1a-6c8d0e2f4a1b", "companyName": "Cantine Centrale SARL", "contactName": "Jean Dupont", "email": "contact@cantine-centrale.com", "phone": "+237 6XX XXX XXX", "estimatedVolume": "500kg / semaine", "message": "Recherche un fournisseur régulier de légumes bio pour notre cuisine centrale.", "status": "CONTACTED", "createdAt": "2026-06-23T10:00:00.000Z", "updatedAt": "2026-06-23T10:15:00.000Z" } - Erreur (
404 Not Found) :{ "statusCode": 404, "message": "Demande Grands Comptes avec l'ID 'b3e1f9c2-...' introuvable." }
9. Bonnes Pratiques de Développement
- Ne pas supposer qu’une notification email part réellement : toute évolution de ce module doit soit brancher une vraie passerelle SMTP/SMS (aucune n’existe aujourd’hui dans ce repo), soit continuer à s’appuyer uniquement sur SSE + traitement manuel côté admin.
- Ne pas fier la confidentialité de l’endpoint public à autre chose qu’à l’absence de données sensibles retournées :
POST /leads/enterpriseest volontairement ouvert sans authentification (commePOST /auth/register), donc ne jamais y faire transiter ou renvoyer d’information nécessitant un contrôle d’accès. - Respecter le flux à sens unique de statut côté UI, même si l’API ne l’impose pas techniquement — toute nouvelle surface d’administration (ex: un futur export CSV, une API mobile) doit reproduire cette même contrainte métier plutôt que de permettre un retour arrière arbitraire.