Notifications
Système de notifications adossé à une table polymorphe notification (clé primaire = UUID, data JSON figé au dispatch, dedup_key optionnel, read_at/pushed_at nullables). Les libellés (title, body) ne sont pas stockés : le serveur les rend à la lecture / à l’envoi en utilisant la locale du destinataire — un seul catalogue (resources/lang/<locale>/notifications.php) sert à la fois l’in-app feed et la push OneSignal.
Canaux (NotificationChannel) : inApp (l’écriture en base est conditionnée à cette préférence), push (filtré au moment du flush cron). La matrice est type × channel → bool, défaut à true pour toutes les cellules (voir NotificationPreferenceService::defaultFor()).
Types pris en charge aujourd’hui :
follow.received— émis depuisFollowService::follow()après un INSERT réussi suruser_follow.datacontient{actorId, actorUsername, actorDisplayName}.dedupKey = "follow.received:<actorHex>"pour fondre les bascules follow/unfollow/follow rapprochées en une seule entrée.badge.earned— émis depuisBadgeAwardServiceune fois par palier traversé (un backfill L0 → L3 émet 3 lignes).datacontient{badgeId, badgeSlug, level, maxLevel, label, icon, xpReward}.dedupKey = "badge.earned:<badgeHex>:<level>"(réplay-safe par palier). Voir Badges (gamification).user.level.up— émis depuisExperienceService::award()une fois par niveau franchi lorsqu’une attribution d’XP fait passerlevelà une valeur supérieure (un award qui fait passer L4 → L7 produit donc 3 lignes).datacontient{level, previousLevel, experience}.dedupKey = "user.level.up:<recipientHex>:<level>"— un replay du même award (même tx ré-exécutée) ne crée pas de doublons. Atomique avec l’UPDATE XP : la dispatch a lieu DANS la transaction, donc un rollback emporte la notification avec lui (pas d’orphelin). Voir Titres (gamification).user.title.up— émis depuisExperienceService::award()quand la montée en niveau traverse une frontière de bucket de titre (tous les 5 niveaux, plafonné au bucket actif le plus haut). Une fois par bucket traversé.datacontient{level, titleSlug, titleLabel, rankIndex, displayTitle}— ledisplayTitleest figé dans la locale par défaut au dispatch (le destinataire n’a pas de colonnelocalecôtéuser).dedupKey = "user.title.up:<recipientHex>:<titleSlug>"(un user ne peut entrer dans un bucket donné qu’une fois — XP monotone — donc tout replay collapse proprement). Voir Titres (gamification).
Dispatch (NotificationService::dispatch) :
- court-circuit si le destinataire a désactivé le canal
inApppour ce type — aucune ligne en base ; - soft-dedup : s’il existe déjà une ligne non lue avec le même
(user_id, dedup_key)dans la fenêtreNOTIFICATION_DEDUP_WINDOW_MINUTES(défaut5), seule la colonnecreated_atest mise à jour (pushed_atn’est PAS réinitialisé — la push éventuelle a déjà été envoyée) ; - sinon INSERT classique avec UUID v4.
Flush / push OneSignal (cron, stratégie G’.3) :
- Un script CLI
bin/notifications-flush.phpest exécuté toutes lesNOTIFICATION_DIGEST_INTERVAL_MINUTESminutes (2 en dev, 5 en prod) via Task Scheduler / cron. - Par destinataire avec au moins une ligne
pushed_at IS NULL:- 1 ligne → push individuel rendu via
notifications.<type>.title|body; - ≥ 2 lignes → push digest rendu via
notifications.digest.title|body(avec{count}ICU).
- 1 ligne → push individuel rendu via
- Les lignes dont le type a désactivé le canal
pushreçoivent quand même unpushed_at(sinon elles s’accumuleraient), seule la push est suppressed. - L’aliasing OneSignal est
external_id = <recipientHex>(32 char) ; côté SDK mobile/web, appelerOneSignal.login(externalId)au moment du provisioning. - Failover : un échec OneSignal sur un utilisateur ne stoppe pas la boucle — l’erreur est loggée (
onesignal.pushFailed) et le tick continue.
Voir src/Domain/Notification et src/Infrastructure/OneSignal.
Pagination : keyset opaque sur (createdAt, id) desc. ?cursor=… (page suivante), ?before=… (page précédente), ?limit=<1..100> (défaut 20). Navigation via l’objet links racine JSON:API.
GET /api/users/me/notifications
Section titled “GET /api/users/me/notifications”Liste paginée du feed in-app de l’utilisateur authentifié (plus récents d’abord).
- Auth : requise (Bearer)
- Action : ListNotificationsAction
- Query :
?cursor=<opaque>(page suivante) /?before=<opaque>(page précédente) —400si malformé?limit=<1..100>(défaut20)?filter=unread(optionnel) — restreint àread_at IS NULL
- Réponse
200 OK: collection JSON:API de ressourcesnotifications. Chaque ressource :
{ "type": "notifications", "id": "<uuid>", "attributes": { "type": "follow.received", "title": "Nouveau follower", "body": "alice vous suit désormais.", "locale": "fr-FR", "data": { "actorId": "…", "actorUsername": "alice", "actorDisplayName": "Alice" }, "isRead": false, "readAt": null, "createdAt": "2026-06-07T12:34:56+00:00" }}meta : { "limit": 20, "unreadOnly": false, "total": 137 }. meta.total reflète le même filtre que la requête (avec ?filter=unread, il ne compte que les non-lues). Navigation paginée via links.{self,first,prev,next}.
GET /api/users/me/notifications/unread-count
Section titled “GET /api/users/me/notifications/unread-count”Endpoint léger pour le badge de la navbar.
- Auth : requise
- Action : UnreadNotificationCountAction
- Réponse
200 OK: ressourcenotificationUnreadCountavecattributes.count(entier).
PATCH /api/users/me/notifications/{id}/read
Section titled “PATCH /api/users/me/notifications/{id}/read”Marque une notification comme lue. Idempotent (200 si déjà lue).
- Auth : requise
- Action : MarkNotificationReadAction
- Path :
{id}UUID canonique de la notification - Erreurs :
422UUID invalide404notification inexistante ou appartenant à un autre utilisateur
- Réponse
200 OK:{ "data": { "type": "notifications", "id": "<uuid>", "attributes": { "isRead": true } } }.
POST /api/users/me/notifications/mark-all-read
Section titled “POST /api/users/me/notifications/mark-all-read”Passe en lecture tout l’inbox de l’utilisateur.
- Auth : requise
- Action : MarkAllNotificationsReadAction
- Réponse
200 OK: ressourcenotificationBulkReadavecattributes.affected(entier = nombre de lignes flipées).
DELETE /api/users/me/notifications/{id}
Section titled “DELETE /api/users/me/notifications/{id}”Suppression dure d’une notification. Le WHERE inclut user_id, impossible de supprimer celle d’un tiers.
- Auth : requise
- Action : DeleteNotificationAction
- Erreurs :
422UUID invalide,404inexistante. - Réponse
204 No Content(corps vide).
GET /api/users/me/notifications/preferences
Section titled “GET /api/users/me/notifications/preferences”Matrice complète type × channel (avec défauts overlayés sur les overrides) — telle quelle pour la page de réglages.
- Auth : requise
- Action : ListNotificationPreferencesAction
- Réponse
200 OK:
{ "data": { "type": "notificationPreferences", "id": "<userUuid>", "attributes": { "matrix": { "follow.received": { "inApp": true, "push": true } } } }}PATCH /api/users/me/notifications/preferences
Section titled “PATCH /api/users/me/notifications/preferences”Patch partiel (cellules absentes intouchées). Tout-ou-rien : la moindre erreur de validation rejette l’ensemble du patch en 422.
- Auth : requise
- Action : UpdateNotificationPreferencesAction
- Body (flat ou
data.attributes) :
{ "follow.received": { "push": false } }- Erreurs
422(par cellule, avecmeta.code) :unknownType— type inconnuunknownChannel— canal inconnuexpectedObject— entrée non-objet pour un type connuexpectedBool— feuille non-booléenne
- Erreur
400: corps non-JSON / non-objet. - Réponse
200 OK: la matrice post-update (même forme que le GET).