Skip to content

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 depuis FollowService::follow() après un INSERT réussi sur user_follow. data contient {actorId, actorUsername, actorDisplayName}. dedupKey = "follow.received:<actorHex>" pour fondre les bascules follow/unfollow/follow rapprochées en une seule entrée.
  • badge.earned — émis depuis BadgeAwardService une fois par palier traversé (un backfill L0 → L3 émet 3 lignes). data contient {badgeId, badgeSlug, level, maxLevel, label, icon, xpReward}. dedupKey = "badge.earned:<badgeHex>:<level>" (réplay-safe par palier). Voir Badges (gamification).
  • user.level.up — émis depuis ExperienceService::award() une fois par niveau franchi lorsqu’une attribution d’XP fait passer level à une valeur supérieure (un award qui fait passer L4 → L7 produit donc 3 lignes). data contient {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 depuis ExperienceService::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é. data contient {level, titleSlug, titleLabel, rankIndex, displayTitle} — le displayTitle est figé dans la locale par défaut au dispatch (le destinataire n’a pas de colonne locale cô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) :

  1. court-circuit si le destinataire a désactivé le canal inApp pour ce type — aucune ligne en base ;
  2. soft-dedup : s’il existe déjà une ligne non lue avec le même (user_id, dedup_key) dans la fenêtre NOTIFICATION_DEDUP_WINDOW_MINUTES (défaut 5), seule la colonne created_at est mise à jour (pushed_at n’est PAS réinitialisé — la push éventuelle a déjà été envoyée) ;
  3. sinon INSERT classique avec UUID v4.

Flush / push OneSignal (cron, stratégie G’.3) :

  • Un script CLI bin/notifications-flush.php est exécuté toutes les NOTIFICATION_DIGEST_INTERVAL_MINUTES minutes (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).
  • Les lignes dont le type a désactivé le canal push reçoivent quand même un pushed_at (sinon elles s’accumuleraient), seule la push est suppressed.
  • L’aliasing OneSignal est external_id = <recipientHex> (32 char) ; côté SDK mobile/web, appeler OneSignal.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.


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) — 400 si malformé
    • ?limit=<1..100> (défaut 20)
    • ?filter=unread (optionnel) — restreint à read_at IS NULL
  • Réponse 200 OK : collection JSON:API de ressources notifications. 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.


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 :
    • 422 UUID invalide
    • 404 notification 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 : ressource notificationBulkRead avec attributes.affected (entier = nombre de lignes flipées).

Suppression dure d’une notification. Le WHERE inclut user_id, impossible de supprimer celle d’un tiers.

  • Auth : requise
  • Action : DeleteNotificationAction
  • Erreurs : 422 UUID invalide, 404 inexistante.
  • 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.

{
"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.

{ "follow.received": { "push": false } }
  • Erreurs 422 (par cellule, avec meta.code) :
    • unknownType — type inconnu
    • unknownChannel — canal inconnu
    • expectedObject — entrée non-objet pour un type connu
    • expectedBool — 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).