Skip to main content

Messaging, notifications, emails et push

Cette page décrit la chaîne messaging/notification vérifiée dans les modules Convex. Sources principales :
  • convex/messaging/events.ts
  • convex/messaging/bus.ts
  • convex/messaging/notifications.ts
  • convex/email/outbox.ts
  • convex/email/dispatch.ts
  • convex/email/resend.ts
  • convex/social/recipient_groups.ts
  • convex/messaging/push.ts
  • convex/social/notifications.ts
  • web/src/hooks/usePushNotifications.ts
  • web/src/components/pwa/PwaServiceWorkerBootstrap.tsx
  • web/public/sw.js

Modèle d’événements

Le format d’entrée est MessagingEvent (validator dans events.ts) et les événements sont transformés en plan d’envoi (in-app + emails) par buildPlan dans bus.ts. Événements observés (non exhaustif des payloads, mais exhaustif des kind listés dans le validator) :
  • auth.password_reset_requested
  • auth.email_verification_requested
  • feedback.submission_succeeded
  • feedback.submission_failed
  • ea.api.health_alerted
  • ea.potential_draw_review_required
  • user.unbanned
  • user.banned
  • user.reactivated
  • user.deleted
  • person_sanction.activated
  • person_sanction.ended
  • competition_ban.activated
  • competition_ban.ended
  • admin.bet_bulk_job.updated
  • admin.bet_bulk_job.partial_failure_alerted
  • admin.user_premium.updated
  • bet.status_changed
  • ea.score.confirmed
  • ea.score.unavailable
  • ea.score.ambiguous
  • ea.score.error
  • transfer.invitation.*
  • transfer.join_request.*
  • transfer.premium_break.executed
  • transfer.player.left_source_club
  • totw.published
  • season_awards.published
  • match.scheduling.*
  • match.dispute.reported
  • match.dispute.resolved
  • match.report.reported
  • match.postponement.*
  • match.result.auto_validated
  • match.result.diverged
  • match.result.admin_validated
  • match.result.contested
  • league.calendar.deleted
  • article.published
Variantes exactes des familles abrégées :
  • transfer.invitation.created|expired|rejected|accepted|cancelled
  • transfer.join_request.created|expired|rejected|accepted|cancelled
  • match.scheduling.proposed|accepted|rejected|cancelled
  • match.postponement.proposed|accepted|counterProposed|rejected

Bus dispatch

dispatch (Mutation interne) :
  • construit le plan via buildPlan,
  • applique deliverInApp,
  • résout les destinataires email,
  • planifie l’action email internal.email.dispatch.send via scheduler runAfter(0, ...).
Les événements competition_ban.activated et competition_ban.ended ciblent explicitement l’utilisateur sanctionné en in-app. L’email passe par la résolution users, donc une adresse vide ou invalide produit uniquement la notification in-app. dispatchAction (Action interne) :
  • même logique de plan,
  • écrit in-app via recordInApp mutation,
  • envoie emails via internal.email.dispatch.send en action (pas de wrapper scheduler).
Le résumé de retour contient :
  • inApp.recipients|inserted|duplicates|skipped|notificationIds
  • email.requested|queued|failed

Fanout in-app et règles de sécurité

  • inAppTarget supportés : user, users, group (voir inAppTarget dans notifications.ts).
  • Cibles de groupe via resolveRecipientGroupMembers (social/recipient_groups.ts).
  • Dedup :
    • dédup par (userId, dedupKey),
    • pour users, la key est suffixée par :<userId> pour éviter collision inter-destinataires.
    • les bans compétition utilisent competition_ban:{sanctionId}:activated et competition_ban:{sanctionId}:ended.
  • match.scheduling.*, match.postponement.*, league.calendar.deleted ont des cibles explicites imposées (pas de groupe) via assertSafeInAppTarget.
  • assertTargetedInAppPlan exige pour ces événements :
    • exactement une enveloppe in-app,
    • cibles utilisateur explicites cohérentes avec les recipientUserIds / managerUserIds,
    • cap MAX_MATCH_TARGETED_IN_APP_RECIPIENTS = 40.

Push

  • push planifié par schedulePushDeliveries en lots,
  • PUSH_DELIVERY_BATCH_SIZE = 200,
  • chaque lot via scheduler.runAfter(0, dispatchInAppNotifications).

Abonnement navigateur

usePushNotifications :
  • vérifie la présence de serviceWorker, PushManager et Notification,
  • demande la permission navigateur lors de l’activation,
  • exige NEXT_PUBLIC_VAPID_PUBLIC_KEY,
  • crée ou réutilise l’abonnement PushManager,
  • remplace l’abonnement si sa clé VAPID ne correspond plus,
  • persiste endpoint, p256dh, auth, installationId et userAgent via upsertPushSubscription,
  • réconcilie l’abonnement au chargement, au focus, au retour réseau et au retour de visibilité,
  • désabonne le navigateur et désactive la préférence côté Convex à la désactivation.
Le contrôle est exposé dans NotificationBell.

Service worker et livraison

PwaServiceWorkerBootstrap enregistre /sw.js uniquement en production et dans un contexte sécurisé (https, localhost ou 127.0.0.1). En développement, il supprime les anciens workers et caches. web/public/sw.js :
  • gère les événements push,
  • affiche la notification avec icône/badge par défaut,
  • refuse les URL externes dans le payload et retombe sur /dashboard,
  • gère le clic de notification vers une fenêtre existante ou une nouvelle fenêtre,
  • fournit aussi le cache PWA et la page /offline.
Le backend charge web-push dynamiquement et exige VAPID_SUBJECT, NEXT_PUBLIC_VAPID_PUBLIC_KEY et VAPID_PRIVATE_KEY. Si la configuration ou le runtime manque, la livraison push est ignorée avec un warning sans bloquer les notifications in-app.

Outbox / retry email

Queue

enqueueEmailBatch (email/outbox.ts) :
  • déduplique par dedupKey (index by_dedupKey),
  • si existant + status != FAILED : skip,
  • si existant + FAILED : requeue (status PENDING, attemptCount reset),
  • sinon insert PENDING,
  • déclenche processDueEmailsAction avec limite par défaut DEFAULT_BATCH_SIZE = 20 (max MAX_BATCH_SIZE = 50).

Traitement batch

processDueEmailsAction :
  • claimDueEmails prend les due rows PENDING et passe en SENDING,
  • envoie via fetch("https://api.resend.com/emails"),
  • en succès : markEmailSent,
  • en échec : markEmailAttemptFailed,
  • si taille du lot atteinte, auto replanifie le batch suivant.

Backoff/retry

  • max tentatives: MAX_RETRY_ATTEMPTS = 8,
  • délai progressif BASE_RETRY_DELAY_MS = 60_000 avec jitter + cap MAX_RETRY_DELAY_MS = 6h,
  • status:
    • réessai jusqu’au max : PENDING + nextAttemptAt,
    • au max atteint : FAILED.
markEmailAttemptFailed planifie de nouveau processDueEmailsAction jusqu’à épuisement.

Envoi effectif

email/dispatch.ts expose send pour la couche unifiée. sendEmailBatchCore (email/resend.ts) :
  • Promise.allSettled par destinataire,
  • journalisation monitoring via recordSendAttempts vers email_delivery_logs,
  • retourne { requested, queued, failed }.
Legacy sendEmailBatch reste présent mais délègue au même core (sendEmailBatchCore) pour compatibilité admin.
Last modified on July 16, 2026