Notifications
Notifications
Le module notifications gère l'envoi d'emails transactionnels déclenchés par les événements métier de la plateforme. Il expose un catalogue d'événements typés, un système de rendu de templates, une file d'attente persistante PostgreSQL et un worker de retry à backoff exponentiel.
Chemin : app/mypromo/server/modules/notifications/
Catalogue d'événements
Source de vérité : app/mypromo/server/modules/notifications/domain/notification-events.ts
Les événements sont organisés par domaine avec un nommage en points (domain.entite.action).
Auth
| Constante | Valeur |
|---|---|
AUTH_EMAIL_VERIFICATION_REQUESTED | auth.email_verification.requested |
AUTH_EMAIL_VERIFIED | auth.email_verification.completed |
AUTH_PASSWORD_RESET_REQUESTED | auth.password_reset.requested |
AUTH_PASSWORD_RESET_COMPLETED | auth.password_reset.completed |
AUTH_TWO_FACTOR_OTP_REQUESTED | auth.two_factor.otp_requested |
AUTH_MAGIC_LINK_REQUESTED | auth.magic_link.requested |
KYB
| Constante | Valeur |
|---|---|
KYB_SUBMITTED | kyb.submitted |
KYB_IN_REVIEW | kyb.in_review |
KYB_APPROVED | kyb.approved |
KYB_REJECTED | kyb.rejected |
Réservation
| Constante | Valeur |
|---|---|
RESERVATION_CREATED | reservation.created |
RESERVATION_PENDING_DISTRIBUTOR_APPROVAL | reservation.pending_distributor_approval |
RESERVATION_APPROVED | reservation.approved |
RESERVATION_COUNTER_PROPOSED | reservation.counter_proposed |
RESERVATION_REJECTED | reservation.rejected |
RESERVATION_CANCELLED | reservation.cancelled |
Facturation
| Constante | Valeur |
|---|---|
BILLING_INVOICE_ISSUED | billing.invoice.issued |
BILLING_INVOICE_PAID | billing.invoice.paid |
BILLING_PAYMENT_FAILED | billing.payment.failed |
BILLING_CREDIT_NOTE_ISSUED | billing.credit_note.issued |
Logistique
| Constante | Valeur |
|---|---|
LOGISTICS_PLATE_PREPARATION_STARTED | logistics.plate_preparation.started |
LOGISTICS_PLATE_SHIPPED | logistics.plate.shipped |
LOGISTICS_PLATE_DELIVERED | logistics.plate.delivered |
LOGISTICS_PLATE_RETURN_REQUESTED | logistics.plate.return_requested |
Meubles (demandes de meuble distributeur)
| Constante | Valeur | Destinataire |
|---|---|---|
FURNITURE_REQUEST_CREATED | furniture_request.created | Admins plateforme |
FURNITURE_REQUEST_APPROVED | furniture_request.approved | Membres du distributeur |
FURNITURE_REQUEST_REJECTED | furniture_request.rejected | Membres du distributeur |
Émis par app/mypromo/server/modules/catalog/application/furniture-request-notifications.ts, appelé depuis createFurnitureRequest (création) et approveFurnitureRequest/rejectFurnitureRequest (module catalog). Les admins destinataires sont les utilisateurs avec role: 'admin' ; les membres distributeur sont résolus via OrganizationMember (actifs, revokedAt: null) sur l'organisation de la demande.
Règles de nommage
- Noms en points, préfixés par domaine.
- Suffixes stables :
requested,completed,approved,rejected,submitted,in_review,issued,paid,failed,started,shipped,delivered,cancelled. - Les IDs d'événement servent de clé de payload dans la file d'attente et comme dimension dans les métriques de retry.
Destinataires — règle V1 et résolveur d'audience
Source de vérité : application/notification-recipient-resolver.ts · domain/notification-audience.ts · infrastructure/notification-recipient.repository.ts
Un appelant métier ne choisit jamais d'adresse e-mail. Il déclare une audience, et le module la traduit en destinataires. C'est le seul endroit du dépôt qui interroge la base pour savoir « qui reçoit ».
await dispatchNotification({
audience: { kind: 'organization_members', organizationId: distributorOrganizationId },
event: NOTIFICATION_EVENTS.RESERVATION_PENDING_DISTRIBUTOR_APPROVAL,
context: { reservationCount: 12, reservationItems: [...] }
})
Les trois audiences
| Audience | Champs | Résolution |
|---|---|---|
organization_members | organizationId, membershipRoles? | membres actifs de l'organisation, puis repli (ci-dessous) |
platform_admins | — | comptes user.role = 'admin' |
explicit | recipients | liste fournie, normalisée (pas d'accès base) |
membershipRoles filtre sur manager | member | billing. Non utilisé par la V1 réservation ; il
existe pour cibler billing sur les événements financiers sans toucher aux appelants.
La règle retenue et sa chaîne de repli
| Rang | Destinataires | Condition | Journal |
|---|---|---|---|
| 1 | tous les membres actifs de l'organisation (revokedAt IS NULL, compte non banni, status = active, e-mail plausible) | nominal | — |
| 2 | adresse générique de l'organisation (OrganizationBillingProfile.billingEmail) | aucun membre joignable | warn |
| 3 | administrateurs de la plateforme | aucun contact d'organisation | warn |
| 4 | personne | plateforme sans admin | error |
Le choix est global, pas configurable par organisation.
Pourquoi ce choix.
- Aucun destinataire « auteur de l'action » n'est adressable :
Reservationne porte pas de colonne d'auteur. Une règle fondée sur l'auteur imposerait une migrationcreatedByUserIdet un backfill — écrire du schéma pour une règle non arbitrée serait le pire ordre des opérations. - Une règle « manager uniquement » casse en silence : le rôle
managern'est écrit qu'à deux endroits et la rétrogradation ne compte pas les managers restants. Une organisation peut donc n'en avoir aucun. - C'est déjà le comportement de fait des envois existants (demande de meuble, expédition imprimeur) : la V1 généralise l'existant au lieu de créer une habitude à défaire.
- Le repli est bruyant par construction : une notification qui disparaît en silence est le pire mode de défaillance d'un tel système.
Trade-off assumé. Sur-notification : un membre non concerné reçoit une demande qui ne le concerne pas. C'est du bruit, pas une fuite — le périmètre reste l'organisation destinataire.
Évolution prévue. Un panneau de configuration par organisation (un responsable désigne les destinataires). L'audience étant un paramètre et la résolution vivant à un seul endroit, ce passage ne touchera aucun appelant métier : il ne changera que le contenu du résolveur.
Hors périmètre, volontairement. Aucune migration Prisma. Aucune préférence ni désabonnement : le sujet devient réglementaire au premier résumé ou à la première relance, donc au chantier « digest ».
Émission — le dispatcher
Source de vérité : application/notification-dispatcher.ts
const result = await dispatchNotification({ audience, event, context, requestId })
// → { event, source, usedFallback, recipientCount, enqueuedCount, failures }
- Ne lève jamais. Une notification en échec ne doit pas faire échouer l'opération métier qui l'a
déclenchée : une réservation créée reste créée. Les échecs de résolution comme de mise en file sont
journalisés et retournés dans
failures. - Un e-mail par destinataire, avec son propre
recipientNameinjecté dans le contexte. source/usedFallbackdisent par quel rang de la chaîne l'envoi est passé — utile en supervision.
Regroupement
Source de vérité : domain/notification-grouping.ts
Le regroupement n'est pas une optimisation, c'est un prérequis : une réservation de masse de 200 emplacements produirait sinon 200 e-mails × N membres, ce qui sature les boîtes et dégrade durablement la délivrabilité du domaine.
La V1 regroupe par lot d'émission : un appel métier qui crée N réservations produit un e-mail par
destinataire, quel que soit N. Les gabarits reservation.* acceptent les deux formes (élément unique
via reservationReference, lot via reservationCount + reservationItems), et la liste est tronquée
à 10 lignes (reservationOmittedCount porte le reste).
Le regroupement par fenêtre de temps (clé (destinataire, événement, fenêtre)) suppose un tampon
persistant : c'est une évolution identifiée, pas un prérequis de la V1.
Émissions câblées
| Déclencheur | Événement | Audience |
|---|---|---|
création depuis le panier (createReservationsFromCart) | reservation.pending_distributor_approval | membres de l'organisation distributeur, regroupés par organisation |
création en masse (createMassReservation) | idem | idem |
approbation distributeur (reviewReservationAsDistributor) | reservation.approved | membres de l'organisation industriel |
| refus distributeur | reservation.rejected | idem |
contre-proposition distributeur (pending_validation) | reservation.counter_proposed | idem |
| demande de meuble créée / approuvée / refusée | furniture_request.* | admins plateforme / membres du distributeur |
expédition imprimeur avec notifyIndustrial | logistics.plate.shipped | membres de l'organisation industriel |
| authentification (vérification, mot de passe, lien magique, OTP) | auth.* | l'utilisateur lui-même (audience non applicable) |
reservation.created et reservation.cancelled disposent d'un gabarit mais ne sont pas émis :
l'issue #111 borne la V1 à l'émission minimale — demande côté distributeur, réponse côté industriel.
Configuration des providers
Contrôlé par les variables d'environnement EMAIL_*.
| Variable | Valeurs / remarques |
|---|---|
EMAIL_PROVIDER | scaleway, resend ou devlog |
EMAIL_FROM | Identité d'expéditeur (requis) |
EMAIL_REPLY_TO | Adresse de réponse (optionnel) |
RESEND_API_KEY | Requis si EMAIL_PROVIDER=resend hors développement |
SMTP_HOST | Requis si EMAIL_PROVIDER=scaleway |
SMTP_PORT | Requis si EMAIL_PROVIDER=scaleway |
SMTP_SECURE | true (SSL/TLS) ou false (STARTTLS) |
SMTP_USERNAME | Project ID Scaleway TEM |
SMTP_PASSWORD | Clé secrète IAM Scaleway TEM |
Logique de résolution du provider
- Si
EMAIL_PROVIDERest explicite, il est utilisé. - Sinon, si
RESEND_API_KEYest défini, le provider résout àresend. - Si
resendest sélectionné sans clé API, ouscalewaysans SMTP complet, le runtime bascule surdevlogen développement seulement.
Provider devlog
En mode devlog, les emails ne sont pas envoyés — ils sont loggés en structuré avec les destinataires masqués. C'est le mode recommandé en développement local sauf test d'intégration explicite.
Scaleway TEM — valeurs de référence
- Host SMTP :
smtp.tem.scaleway.com - Ports STARTTLS :
587ou2587(SMTP_SECURE=false) - Ports SSL/TLS :
465ou2465(SMTP_SECURE=true) - Username : Project ID de l'espace Scaleway où le domaine TEM est configuré
- Password : Clé secrète d'une API key IAM avec droit SMTP TEM
DNS en production
Avant activation en production, configurer sur le domaine expéditeur :
- SPF : autoriser les serveurs d'envoi du provider
- DKIM : clés générées par le provider (obligatoire)
- DMARC : commencer en
p=none(monitoring), puisquarantine/reject
Templates d'email
Source de vérité : app/mypromo/server/modules/notifications/email/application/email-templates.ts
Contrat de rendu
renderTransactionalEmailTemplate(event: NotificationEvent, context: TransactionalEmailTemplateContext)
// → { subject: string, text: string, html: string }
Appelé via EmailService.sendTransactionalEmailByEvent(...).
- Entrée :
event(clé du catalogue) +context(données dynamiques). - Sortie :
subject,text(fallback email client sans HTML),html(layout tabulaire responsive).
Champs de contexte principaux
| Champ | Utilisé par |
|---|---|
recipientName | salutation personnalisée |
actionUrl | bouton CTA et lien de secours |
reservationReference | templates reservation.* (lot d'un seul élément) |
reservationCount | regroupement — nombre de réservations couvertes |
reservationStoreCount | regroupement — nombre de magasins distincts |
reservationItems | regroupement — lignes affichables (tronquées) |
reservationOmittedCount | regroupement — lignes non listées |
reservationPeriod | période demandée, déjà formatée |
reservationDecisionNote | motif de refus / message de contre-proposition |
counterProposalPeriod | reservation.counter_proposed |
counterProposalAmount | reservation.counter_proposed |
invoiceReference | templates billing.* |
amount | billing.invoice.issued |
kybReason | kyb.rejected |
otpCode | auth.two_factor.otp_requested |
logisticsCarrier | logistics.plate.shipped |
logisticsTrackingNumber | logistics.plate.shipped |
logisticsTrackingUrl | logistics.plate.shipped |
printOrderCode | logistics.plate.shipped |
furnitureRequestReference | templates furniture_request.* |
furnitureRequestReason | furniture_request.rejected |
Template auth.magic_link.requested
Template dédié au lien magique : bouton CTA en orange (#ea580c), carte d'information sécurité inlinée, avertissement de ne pas partager le lien.
Header branding
Le logo partagé pointe vers https://dev-images-mail.s3.fr-par.scw.cloud/pulsemypromo/AppLogo.svg directement (pas d'URL locale) pour la compatibilité avec les clients email mobiles. Le tag d'événement est automatiquement propagé (tags: [{ name: 'event', value: <event> }]).
File d'attente persistante et worker
Modèles Prisma
Table principale : notification_email_queue (modèle EmailQueueItem)
| Colonne | Type | Description |
|---|---|---|
id | String (cuid) | Identifiant interne |
jobId | String (unique) | Identifiant de job |
status | String | État courant (queued, …) |
to | String[] | Destinataires |
event | String | Clé événement |
context | Json | Contexte de rendu |
requestId | String | Corrélation avec la requête source |
attempts | Int | Nombre de tentatives effectuées |
maxAttempts | Int | Seuil maximal |
nextAttemptAt | DateTime | Prochaine tentative |
lockedAt | DateTime? | Verrouillage worker |
providerMessageId | String? | ID renvoyé par le provider |
lastError | String? | Dernière erreur |
Index : (status, nextAttemptAt) — utilisé par le worker pour le polling.
Table de rebut : notification_email_dead_letter (modèle EmailDeadLetter)
Mêmes colonnes qu'EmailQueueItem avec en plus deadLetteredAt (horodatage du passage en dead-letter). Le statut par défaut est dead_letter.
Worker
Plugin Nitro : app/mypromo/server/plugins/20-email-queue-worker.ts
Le worker tourne en tâche de fond dans le processus Nitro. Il interroge la table notification_email_queue à intervalle régulier, acquiert un verrou sur les jobs éligibles et tente l'envoi via le provider configuré.
Variables de contrôle du worker
| Variable | Valeur par défaut | Description |
|---|---|---|
EMAIL_QUEUE_ENABLED | true | Active ou désactive le worker |
EMAIL_QUEUE_POLL_INTERVAL_MS | 5000 | Intervalle de polling en ms |
EMAIL_QUEUE_MAX_ATTEMPTS | 5 | Nombre maximal de tentatives avant dead-letter |
EMAIL_QUEUE_RETRY_BASE_DELAY_MS | 15000 | Base du délai pour le backoff exponentiel |
Politique de retry
Backoff exponentiel basé sur EMAIL_QUEUE_RETRY_BASE_DELAY_MS. Après EMAIL_QUEUE_MAX_ATTEMPTS tentatives échouées, le job est déplacé dans notification_email_dead_letter.
API — smoke test
Authentification — la plateforme utilise des sessions par cookie (Better Auth). La session est établie à la connexion et transmise automatiquement via le cookie
better-auth.session_token. Dans les exemples curl, utilisez-b 'better-auth.session_token=<jeton-de-session>'.
Envoyer un email de smoke test
POST /api/notifications/email-smoke
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>
{
"to": "destinataire@example.com",
"event": "billing.invoice.issued"
}
Requiert la permission platform.manage (rôle admin).
event est optionnel (défaut : billing.invoice.issued). Il doit correspondre à une valeur valide du catalogue.
{
"actorUserId": "...",
"actorRole": "admin",
"to": "destinataire@example.com",
"event": "billing.invoice.issued",
"queueStatus": "queued",
"jobId": "job_...",
"nextAttemptAt": "2026-06-19T10:00:05.000Z"
}
L'envoi est asynchrone : la réponse confirme la mise en file, le worker envoie sur le prochain cycle de polling.
Développement local — récupérer les liens auth depuis les logs
En développement (NODE_ENV=development), pour les événements auth.email_verification.requested et auth.password_reset.requested, une entrée de log structurée est émise avant l'enfilage dans la queue :
scope: "better-auth"
message: "local auth email action link generated"
fields: eventType, userId, email, actionUrl, requestId
Procédure :
- Déclencher le flux (inscription, mot de passe oublié, reset admin).
- Rechercher
local auth email action link generateddans les logs du dev server. - Copier
actionUrlet l'ouvrir dans le navigateur.
Ce log est strictement local et n'est pas émis en production.
Sécurité
- Ne jamais committer de clés provider réelles.
- Maintenir
EMAIL_FROMaligné avec le domaine vérifié du provider. - En local, utiliser
EMAIL_PROVIDER=devlogsauf test d'intégration explicite.