# Notifications riches — Abonnements, Fréquences et Socle User Data (Plan V4)

Statut: validé — Option A (PERSONALIZED: 1 épisode par run) — purge Admin livrée
Responsable: Backend/Frontend
Périmètre: notifications riches (lecture GET, mutations via /api/commands et /api/admin/*), scheduler digest, abonnements utilisateurs, socle user data

## 0) Lignes rouges V4
- Mutations: uniquement POST `/api/commands` (côté utilisateur) et `/api/admin/*` (admin). Pas d’API parallèle.
- Réponses: `Response::ok/error` (Content-Type: application/json; charset=utf-8) partout.
- Source de vérité: DB + snapshot JSON V4 pour boards (inchangé ici).
- CSRF/rate-limit/auth: inchangés (middlewares existants).

## 1) Objectifs & Principes
- Catégories = flux: l’utilisateur ne voit que ce à quoi il est éligible.
- Paramétrage par catégorie:
  - Audience: `EVERYONE` (défaut) ou `SUBSCRIBERS`.
  - Mode d’acheminement: `BROADCAST` (même rythme pour tous) ou `PERSONALIZED` (cycle individuel).
  - Fréquence (calendrier réel): `IMMEDIATE`, `EVERY_N_DAYS(n≥1)`, `WEEKLY(1..7)`, `MONTHLY(1..28)`.
  - Ancre: `anchor_ts` (UTC) représentant une coupe locale Europe/Paris (00:00), pour éviter la dérive.
- Abonnements par utilisateur, avec override optionnel (si autorisé) pour la fréquence.
- Visibilité contrôlée via `notification_user_state.available_at` (UTC). Expédition future (email/push) via `delivered_at`.
- Socle générique `user_data` (KV JSON) + `user_achievements` (optionnel) pour stats, thèmes, Q/R, trophées.

Décisions actées:
- IMMEDIATE unifié → `available_at = created_at` écrit à la **création** (aucun backfill historique) ; lecture = condition unique `available_at <= now`.
- `PERSONALIZED × IMMEDIATE` interdit (validation UI + back → 422).
- Ordre “épisodes”: `notifications_rich.sequence_index` requis (UNIQUE par catégorie). **Aucun fallback** sur `created_at`.

## 2) DDL (SchemaManager)

### 2.1 notification_categories
- `audience_mode ENUM('EVERYONE','SUBSCRIBERS') NOT NULL DEFAULT 'EVERYONE'`
- `dispatch_mode ENUM('BROADCAST','PERSONALIZED') NOT NULL DEFAULT 'BROADCAST'`
- `frequency_kind ENUM('IMMEDIATE','EVERY_N_DAYS','WEEKLY','MONTHLY') NOT NULL DEFAULT 'IMMEDIATE'`
- `frequency_param TINYINT UNSIGNED NULL` (n≥1 | 1..7 | 1..28)
- `anchor_ts BIGINT UNSIGNED NULL` (UTC, ancre locale Europe/Paris)
- `allow_user_override TINYINT(1) NOT NULL DEFAULT 0`

### 2.2 notifications_rich
- `sequence_index INT NOT NULL`
- `UNIQUE(category_id, sequence_index)`

### 2.3 user_subscriptions
- `user_id INT UNSIGNED NOT NULL`
- `category_id INT UNSIGNED NOT NULL`
- `subscribed TINYINT(1) NOT NULL DEFAULT 1`
- `override_kind ENUM('IMMEDIATE','EVERY_N_DAYS','WEEKLY','MONTHLY') NULL`
- `override_param TINYINT UNSIGNED NULL`
- `cycle_anchor_ts BIGINT UNSIGNED NULL` (UTC; PERSONNALIZED)
- `last_delivered_notification_id BIGINT UNSIGNED NULL` (PERSONNALIZED)
- `created_at BIGINT UNSIGNED NOT NULL`
- `PRIMARY KEY (user_id, category_id)`
- `KEY ix_us_cat (category_id)`
- `KEY ix_us_cat_sub (category_id, subscribed)` (perf BROADCAST)
- FKs → `users(id)`, `notification_categories(id)`

### 2.4 notification_user_state (NUS)
- `available_at BIGINT UNSIGNED NULL`
- `delivered_at BIGINT UNSIGNED NULL`
- `PRIMARY KEY(user_id, notification_id)` (déjà présent)
- Index: `ix_nus_user_available (user_id, available_at)`

### 2.5 user_data (socle polyvalent)
- `user_id INT UNSIGNED NOT NULL`
- `namespace VARCHAR(64) NOT NULL` (ex: `stats`,`prefs`,`themes`,`answers`,`features`)
- ``key` VARCHAR(128) NOT NULL`
- `value_json JSON NOT NULL`
- `updated_at BIGINT UNSIGNED NOT NULL`
- `PRIMARY KEY (user_id, namespace, ` + "`key`" + `)`
- FK → `users(id)`

### 2.6 user_achievements (optionnel)
- `user_id INT UNSIGNED NOT NULL`
- `code VARCHAR(64) NOT NULL`
- `earned_at BIGINT UNSIGNED NOT NULL`
- `metadata JSON NULL`
- `PRIMARY KEY (user_id, code)`
- FK → `users(id)`

### 2.7 scheduler_meta (requis pour BROADCAST)
- `category_id INT UNSIGNED PRIMARY KEY`
- `last_slot_ts BIGINT UNSIGNED NULL`

## 3) Backfill technique (legacy)
— Retiré (zéro rétro‑compat / aucun rattrapage historique IMMEDIATE).

## 4) Validations (UI + Back)
- `PERSONALIZED` interdit avec `IMMEDIATE` → 422 `INVALID_CATEGORY_MODE`.
- `EVERY_N_DAYS`: `frequency_param >= 1` (plage recommandée 1..7; extensions éventuelles à valider séparément).
- `WEEKLY`: `frequency_param ∈ [1..7]`.
- `MONTHLY`: `frequency_param ∈ [1..28]`.
- Overrides autorisés uniquement si `dispatch_mode=PERSONALIZED` et `allow_user_override=1` ; en `BROADCAST` → 422 si tentés.
- `allow_user_override=0` → SetCategoryFrequencyOverride → 422.

## 5) Scheduler CLI (tools/scheduler/notifications-digest.php)
- Timezone de calcul: Europe/Paris → conversion UTC pour stockage.
-- next_slot(now, kind, param, anchor_ts):
  - `IMMEDIATE` → `created_at` (déjà visible via `available_at=created_at` à la création)
  - `EVERY_N_DAYS (n)` → midnight_local(now) + multiples de `n` (ancrés sur `anchor_ts` si fourni)
  - `WEEKLY (1..7)` → prochain jour-semaine à 00:00 local
  - `MONTHLY (1..28)` → prochain jour-mois à 00:00 local
- BROADCAST (nécessite `scheduler_meta`):
  - Lire `last_slot_ts` (scheduler_meta), calculer `slot`.
  - Si `now < slot` → noop.
  - Lister notifs de la catégorie depuis `last_slot_ts`, énumérer les utilisateurs éligibles (audience), upsert NUS avec `available_at=slot` (batches 5k). Mettre à jour `last_slot_ts`.
- PERSONALIZED:
  - Pour chaque `user_subscriptions` (subscribed=1): résoudre (override || catégorie) + ancre (subscription.cycle_anchor_ts || catégorie.anchor_ts).
  - Si `now < slot` → noop.
  - Trouver le “prochain épisode” (ordre strict `sequence_index`).
  - Upsert NUS avec `available_at=slot`; mettre à jour `last_delivered_notification_id`.
- Idempotence: `ON DUPLICATE KEY UPDATE` et ne pas sur‑écrire un `available_at` déjà fixé.
- Concurrence: lock applicatif simple par catégorie, pagination.

## 6) Lecture (GET /api/notifications/rich)
- Audience éligible: `EVERYONE` ou (`SUBSCRIBERS` & abonnement enabled).
- Visibilité: `available_at <= now` (inclut IMMEDIATE car `available_at=created_at`).
- États: `nus.deleted_at IS NULL`, `mode=history` = `nus.archived_at IS NOT NULL`.
- Catégories pour tabs:
  - `unread` badges (mode nouvelles) calculés sur `seen_at IS NULL`.
  - Historique: catégories avec archivées (sans badge).

## 7) Mutations (NonBoardBus → /api/commands)
- `User.SubscribeCategory { categoryId }`
- `User.UnsubscribeCategory { categoryId }`
- `User.SetCategoryFrequencyOverride { categoryId, kind, param? }` (si autorisé)

## 8) Admin surfaces
- Catégories (CRUD): champs Audience, Mode, Fréquence (avec param), Ancre (optionnelle), Autoriser override.
- Suppression Catégorie (DELETE): purge transactionnelle complète (états NUS → contenus → abonnements → meta scheduler → catégorie) avec rapport 200 OK.
- Fiche utilisateur (Phase 3):
  - GET `/api/admin/users/:id/data`: profil, `user_data.*`, `user_subscriptions`, progression (NUS), succès.
  - Actions: (dés)abonner, override, reset progression.

## 9) Tests
- Unit: `next_slot()` (EVERY_N_DAYS/WEEKLY/MONTHLY), DST Europe/Paris.
- Intégration: lecture GET /notifications/rich (audience/available_at/archived/deleted), validations (422) côté Admin & /api/commands.
- E2E: nouvel abonné PERSONNALIZED démarre à l’épisode 1, BROADCAST n’upsert que le delta, cron idempotent.

## 10) Phasage
- Phase 1 — DDL + surfaces (Admin Catégories / lecture available_at & audience / création `scheduler_meta`) [GO séparé]
- Phase 2 — Scheduler BROADCAST & PERSONALIZED (CLI) + curseur persisté
- Phase 3 — Abonnements (commands User.*) + Fiche utilisateur (Admin) + “Mes abonnements” (Compte)
- Phase 4 — Stats & succès (hooks bus → user_data, achievements)

## 11) Definition of Done (DoD)
- DDL appliqué (categories, notifications_rich.sequence_index NOT NULL, user_subscriptions, NUS.available_at/delivered_at, user_data), index OK.
- Validations strictes UI + back (422) pour cas interdits.
- Lecture: filtre unique `available_at <= now` + audience + états.
- Scheduler: idempotent, test DST, logs, documenté.
- BROADCAST: `scheduler_meta` en place (curseur par catégorie).
- Admin: champs visibles, clairs, cohérents.
- Tests: unitaires + intégration + E2E minimaux au vert.

## 12) Option produit — Backfill BROADCAST (hors Phase 1)
- But: rattrapage pour nouveaux abonnés aux catégories en BROADCAST.
- Modes:
  - NONE (défaut) : aucun rattrapage.
  - IMMEDIATE : pousser K items historiques d’un coup (`available_at = now()`), K configurable.
  - STAGED : rejouer l’historique avec une cadence, en s’appuyant sur `cycle_anchor_ts` et `last_delivered_notification_id` au niveau de l’abonné.
- Implémentation prévue en phase ultérieure (pas incluse en Phase 1). Ne change pas le DDL de base (peut s’appuyer sur un job ad‑hoc; champ optionnel `backfill_mode` envisageable si besoin).
