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
- Build EAS obligatoire : Expo Go ne supporte pas les push notifications réelles
- Quota APNs silent : iOS limite les silent push (~2-3/heure en arrière-plan)
- Token rotation : Le token peut changer à tout moment, toujours écouter les changements
- Cold start : Gérer le cas où l'app est killed et l'utilisateur tape une notification
- 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)