Aller au contenu

PD-105 — Plan d'implémentation

Story : Implémenter push notifications Date : 2026-02-10 Auteur : Claude (orchestrateur)


1. Découpage en composants

1.1 Frontend (ProbatioVault-app)

Module Responsabilité Fichiers
notification-service Configuration expo-notifications, gestion token APNs, listeners src/services/notifications/
notification-store État local notifications, historique, badges src/store/notificationStore.ts
notification-storage Persistance SQLite chiffré, rétention 90j src/services/notifications/storage.ts
notification-center UI centre de notifications, liste, détail src/screens/notifications/
notification-handlers Handlers foreground/background/killed, navigation src/services/notifications/handlers.ts
permission-flow Écran pré-permission avec message valeur probatoire src/components/notifications/PermissionPrompt.tsx

1.2 Backend (ProbatioVault-backend)

Module Responsabilité Fichiers
device-tokens API enregistrement/révocation tokens src/modules/notifications/device-tokens/
dispatch-service Envoi APNs, filtrage doctrine, latence src/modules/notifications/dispatch/
apns-client Client APNs (node-apn ou équivalent) src/modules/notifications/apns/
event-classifier Classification événements phase 1 → criticité src/modules/notifications/classifier/

2. Flux techniques

2.1 Enregistrement token (FN-105-01, FN-105-02)

[App] expo-notifications.getDevicePushTokenAsync()
  ├── Token obtenu
  │     └── POST /api/v1/notifications/device-tokens
  │           Body: { token, deviceId, environment }
  │           Headers: Authorization: Bearer <jwt>
  └── Backend
        ├── Valide JWT
        ├── Upsert token (user_id, device_id, token, active=true)
        ├── Invalide ancien token si existant
        └── 201 Created

2.2 Révocation token (INV-105-04, CA-105-13)

[App] Logout explicite
  └── DELETE /api/v1/notifications/device-tokens/:deviceId
        Headers: Authorization: Bearer <jwt>
        └── Backend
              ├── Marque token inactive (revoked_at = now)
              └── 204 No Content

2.3 Dispatch notification (FN-105-03, FN-105-04)

[Backend] Événement probatoire détecté
  ├── EventClassifier.classify(event)
  │     └── Retourne { type, criticite, shouldNotify }
  ├── Si shouldNotify = false → STOP (INV-105-02)
  ├── PayloadBuilder.build(event)
  │     └── Retourne { notificationId: UUID, eventType, targetId }
  │     └── AUCUNE donnée sensible (INV-105-07)
  ├── Si criticité = CRITIQUE | HAUTE-CRITIQUE
  │     └── DispatchService.sendAlert(payload, tokens)
  ├── Si criticité = HAUTE
  │     └── DispatchService.sendSilent(payload, tokens)
  └── Logs: timestamp_event, timestamp_dispatch, delta_ms

2.4 Réception notification (côté app)

[iOS] Notification reçue
  ├── Vérifier déduplication par notificationId (si déjà historisé → STOP)
  ├── Foreground → NotificationHandler.handleForeground()
  │     ├── Si notification.type === alert → Affiche in-app toast
  │     ├── Si notification.type === silent → Aucune alerte (INV-105-05)
  │     ├── Historise localement (avec mutex/queue sérialisé)
  │     └── Met à jour badge (atomique via computeBadge)
  ├── Background → NotificationHandler.handleBackground()
  │     ├── Sync silencieuse si silent
  │     ├── Historise localement (avec mutex/queue sérialisé)
  │     └── Met à jour badge (atomique via computeBadge)
  └── Killed → handleNotificationResponse() au lancement
        ├── Historise localement (avec mutex/queue sérialisé)
        ├── Navigate vers contexte
        └── Met à jour badge (atomique via computeBadge)

Note: Toutes les mises à jour badge sont sérialisées via une queue pour éviter les race conditions.
Déduplication: Avant toute historisation, vérifier si notificationId existe déjà dans le stockage local.

2.5 Navigation contextuelle (FN-105-06)

[App] Tap notification
  ├── Extraire targetId du payload
  ├── Resolver.resolve(targetId)
  │     ├── Si document → navigate('DocumentDetail', { id })
  │     ├── Si partage → navigate('ShareDetail', { id })
  │     └── Si event → navigate('EventDetail', { id })
  └── Si contexte introuvable
        └── navigate('NotificationDetail', { notification })
              └── Affiche message d'indisponibilité

3. Mapping Invariants → Mécanismes

INV Mécanisme technique Observable
INV-105-01 Plateforme: expo-notifications iOS uniquement Build EAS iOS
INV-105-02 EventClassifier.shouldNotify() + liste blanche phase 1 Logs filtrage
INV-105-03 useEffect sur auth state + token listener Trace backend
INV-105-04 Appel DELETE token au logout Trace backend
INV-105-05 APNs content-available:1, sound:null, alert:null Inspection payload
INV-105-06 EventClassifier.getCriticite() → alert si CRITIQUE/HAUTE-CRITIQUE Logs dispatch
INV-105-07 PayloadBuilder.sanitize() + tests regex PII Audit payloads
INV-105-08 NotificationStorage.save({ id: UUID v4, type, receivedAt, read }) SQLite + test unitaire validant regex UUID v4
INV-105-09 NotificationStore.computeBadge() → setBadgeCountAsync() Badge iOS
INV-105-10 Resolver + fallback NotificationDetail Navigation
INV-105-11 Timestamp event vs timestamp dispatch, p95 < 5s Métriques
INV-105-12 APNs feedback (succès = response 200, échec = 4xx/5xx) + compteur succès/échecs Dashboard 30j avec taux calculé
INV-105-13 CI/CD EAS build only .gitlab-ci.yml
INV-105-14 NotificationStorage.purgeOlderThan(90) Cron au lancement

4. Mapping Critères d'acceptation → Tests

CA Test(s) associé(s) Mécanisme
CA-105-01 TC-NOM-01 POST device-tokens après login
CA-105-02 TC-NOM-02 Upsert token + invalidation ancien
CA-105-03 TC-NOM-03 Silent push sans alerte
CA-105-04 TC-NOM-04 Alert push + historisation non lu
CA-105-05 TC-NOM-05 computeBadge() = count(read=false)
CA-105-06 TC-NOM-06, TC-NOM-07 Resolver + fallback
CA-105-07 TC-NOM-08 PayloadBuilder.sanitize()
CA-105-08 TC-NOM-09 Timestamp delta < 5s p95
CA-105-09 TC-NOM-10 Dashboard livraison >= 99%
CA-105-10 TC-NOM-12 Build EAS uniquement
CA-105-11 TC-NOM-11 shouldNotify() = false pour exclus
CA-105-12 TC-NOM-13 PermissionPrompt avant requestPermissions()
CA-105-13 TC-ERR-09 DELETE token au logout

5. Gestion des erreurs

ERR Mécanisme Observable
ERR-105-01 Retry avec backoff exponentiel (3 tentatives) + persistance échec pour reprise au prochain lancement/session Logs retry + flag local
ERR-105-02 Callback APNs feedback → marque token invalide État token BDD
ERR-105-03 Vérifier status permissions → pointer vers Settings UI Settings link
ERR-105-04 PayloadBuilder.validate() throw → log sécurité Logs sécurité
ERR-105-05 Resolver.resolve() catch → NotificationDetail Navigation fallback
ERR-105-06 Au lancement: computeBadge() → reconciliation Badge correct
ERR-105-07 Alert si p95 > 5s (monitoring) Grafana/alerting
ERR-105-08 CI refuse Expo Go artifacts Pipeline failure

6. Points de sécurité

Aspect Mesure
Payload PII PayloadBuilder.sanitize() + regex validation
Token transport HTTPS only, JWT auth
Stockage local SQLite chiffré (expo-sqlite) ou SecureStore
APNs credentials Auth Key p8 dans Vault, jamais en clair
Historique Rétention 90j, purge automatique

7. Hypothèses techniques

ID Hypothèse Validation
HT-01 expo-notifications disponible sur Expo SDK 52 Vérifié
HT-02 node-apn ou @parse/node-apn pour backend À installer
HT-03 SQLite chiffré via expo-sqlite Config requise
HT-04 Deep linking configuré dans l'app Existant (PD-99)
HT-05 Backend NestJS avec EventEmitter Architecture existante

8. Dépendances

Frontend

  • expo-notifications ^0.28.x
  • expo-sqlite ^14.x (pour historique chiffré)
  • uuid ^9.x (pour notification IDs)

Backend

  • @parse/node-apn ^5.x (client APNs)
  • Credentials APNs (Auth Key p8) dans Vault

9. Points de vigilance

  1. Build EAS obligatoire : Expo Go ne supporte pas les push notifications réelles
  2. Quota APNs silent : iOS limite les silent push (~2-3/heure en arrière-plan)
  3. Token rotation : Le token peut changer à tout moment, toujours écouter les changements
  4. Cold start : Gérer le cas où l'app est killed et l'utilisateur tape une notification
  5. Badge atomicité : Toujours recalculer depuis l'historique local, jamais incrémental seul

10. Estimation effort

Composant Effort Risque
notification-service (app) 2j Moyen (config APNs)
notification-store + storage 1j Faible
notification-center UI 1.5j Faible
device-tokens API (backend) 1j Faible
dispatch-service (backend) 2j Moyen (latence)
Tests E2E 1.5j Élevé (device physique)
Total 9j

Références

  • Spec : PD-105-specification.md (v2)
  • Tests : PD-105-tests.md (v1)
  • Epic : MOBILE-IOS (PD-195)