---
title: "Notifications"
description: "Système de notifications adossé à une table polymorphe notification (clé primaire = UUID, data JSON figé au dispatch, dedup_key optionnel, read_at/pushed_a…"
---

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)](#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)](#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)](#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](../src/Domain/Notification) et [src/Infrastructure/OneSignal](../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`

Liste paginée du feed in-app de l'utilisateur authentifié (plus récents d'abord).

- **Auth** : requise (Bearer)
- **Action** : [ListNotificationsAction](../src/Http/Action/Api/Notifications/ListNotificationsAction.php)
- **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 :

```json
{
  "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`

Endpoint léger pour le badge de la navbar.

- **Auth** : requise
- **Action** : [UnreadNotificationCountAction](../src/Http/Action/Api/Notifications/UnreadNotificationCountAction.php)
- **Réponse `200 OK`** : ressource `notificationUnreadCount` avec `attributes.count` (entier).

---

## `PATCH /api/users/me/notifications/{id}/read`

Marque une notification comme lue. Idempotent (200 si déjà lue).

- **Auth** : requise
- **Action** : [MarkNotificationReadAction](../src/Http/Action/Api/Notifications/MarkNotificationReadAction.php)
- **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`

Passe en lecture tout l'inbox de l'utilisateur.

- **Auth** : requise
- **Action** : [MarkAllNotificationsReadAction](../src/Http/Action/Api/Notifications/MarkAllNotificationsReadAction.php)
- **Réponse `200 OK`** : ressource `notificationBulkRead` avec `attributes.affected` (entier = nombre de lignes flipées).

---

## `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](../src/Http/Action/Api/Notifications/DeleteNotificationAction.php)
- **Erreurs** : `422` UUID invalide, `404` inexistante.
- **Réponse `204 No Content`** (corps vide).

---

## `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](../src/Http/Action/Api/Notifications/ListNotificationPreferencesAction.php)
- **Réponse `200 OK`** :

```json
{
  "data": {
    "type": "notificationPreferences",
    "id":   "<userUuid>",
    "attributes": {
      "matrix": {
        "follow.received": { "inApp": true, "push": true }
      }
    }
  }
}
```

---

## `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](../src/Http/Action/Api/Notifications/UpdateNotificationPreferencesAction.php)
- **Body** (flat ou `data.attributes`) :

```json
{ "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).

---
