Files
roadwave/docs/adr/017-notifications-geolocalisees.md
jpgiannetti 6ba0688f87 refactor(adr): remplacer Firebase par implémentation directe APNS/FCM
Remplace toutes les références au SDK Firebase par une implémentation
directe des APIs APNS (iOS) et FCM (Android) pour éliminer le vendor
lock-in et assurer la cohérence avec la stratégie self-hosted.

Modifications :
- ADR-017 : Architecture notifications avec APNS/FCM direct
- ADR-018 : Remplacement firebase.google.com/go par sideshow/apns2 + oauth2
- ADR-020 : Remplacement firebase_messaging par flutter_apns + flutter_fcm
- Règles métier 09 & 14 : Mise à jour références coûts notifications

Avantages :
- Aucun vendor lock-in (code 100% maîtrisé)
- Cohérence avec ADR-008 (self-hosted) et ADR-015 (souveraineté)
- Gratuit sans limite (APNS/FCM natifs)
- APIs standard HTTP/2 et OAuth2
2026-02-02 21:36:59 +01:00

288 lines
12 KiB
Markdown

# ADR-017 : Architecture des Notifications Géolocalisées
**Statut** : Accepté
**Date** : 2026-01-28
**Supersède** : Résout l'incohérence identifiée entre ADR-002 et Règle Métier 05 (Mode Piéton)
## Contexte
Le mode piéton exige des notifications push en temps réel lorsque l'utilisateur approche d'un point d'intérêt (rayon de 200m), **même si l'application est fermée ou en arrière-plan**.
ADR-002 spécifie HLS pour tout le streaming audio, mais HLS est un protocole unidirectionnel (serveur → client) qui ne permet pas au serveur d'envoyer des notifications push vers un client inactif.
## Décision
Architecture hybride en **2 phases** :
### Phase 1 (MVP) : WebSocket + APNS/FCM Direct
```
[App Mobile] → [WebSocket] → [Backend Go]
[PostGIS Worker]
[APNS / FCM Direct API]
[Push Notification]
```
**Fonctionnement** :
1. L'utilisateur ouvre l'app → connexion WebSocket établie
2. L'app envoie sa position GPS toutes les 30s via WebSocket
3. Un worker backend (goroutine) interroge PostGIS toutes les 30s :
```sql
SELECT poi.*, users.push_token, users.platform
FROM points_of_interest poi
JOIN user_locations users ON ST_DWithin(
poi.geom,
users.last_position,
200 -- rayon en mètres
)
WHERE users.notifications_enabled = true
AND users.last_update > NOW() - INTERVAL '5 minutes'
```
4. Si proximité détectée → envoi de push notification via FCM (Android) ou APNS (iOS)
5. Utilisateur clique → app s'ouvre → HLS démarre l'audio (ADR-002)
**Limitations MVP** :
- Fonctionne uniquement si l'utilisateur a envoyé sa position < 5 minutes
- En voiture rapide (>80 km/h), possible de "manquer" un POI si position pas mise à jour
### Phase 2 (Post-MVP) : Ajout du Geofencing Local
```
[Mode Connecté] → WebSocket + Push serveur (Phase 1)
[Mode Offline] → Geofencing natif iOS/Android
[Mode Économie] → Geofencing natif (batterie < 20%)
```
**Fonctionnement additionnel** :
1. Quand l'utilisateur télécharge du contenu pour mode offline → synchronisation des POI proches (rayon 10 km)
2. Configuration de **geofences locales** sur iOS/Android (limite : 20 sur iOS, 100 sur Android)
3. Sélection intelligente des 20 POI les plus pertinents selon les jauges d'intérêt
4. Système d'exploitation surveille les geofences même app fermée
5. Entrée dans geofence → notification locale (pas de serveur)
## Alternatives considérées
### Architecture de délivrance (serveur vs local)
| Option | Fonctionne offline | Batterie | Complexité | Limite POI | Précision |
|--------|-------------------|----------|------------|------------|-----------|
| **WebSocket + FCM (Phase 1)** | ❌ Non | ⭐ Optimale | ⭐ Faible | ∞ | ⭐⭐ Bonne |
| Geofencing local seul | ⭐ Oui | ⚠️ Élevée | ⚠️ Moyenne | 20 (iOS) | ⭐⭐⭐ Excellente |
| Polling GPS continu | ⭐ Oui | ❌ Critique | ⭐ Faible | ∞ | ⭐⭐⭐ Excellente |
| **Hybride (Phase 1+2)** | ⭐ Oui | ⭐ Adaptative | ⚠️ Moyenne | ∞/20 | ⭐⭐⭐ Excellente |
### Fournisseurs de push notifications
| Provider | Fiabilité | Coût MVP | Coût 100K users | Self-hosted | Vendor lock-in | Verdict |
|----------|-----------|----------|-----------------|-------------|----------------|---------|
| **APNS/FCM Direct (choix)** | 99.95% | **0€** | **0€** | ✅ Oui | 🟢 Aucun | ✅ Optimal |
| OneSignal | 99.95% | 0€ | 500€/mois | ❌ Non | 🔴 Fort | ❌ Plus cher |
| Pusher Beams | 99.9% | 0€ | 300€/mois | ❌ Non | 🔴 Fort | ❌ Niche |
| Firebase SDK | 99.95% | 0€ | 0€ | ❌ Non | 🔴 Fort (Google) | ❌ Vendor lock-in |
| Novu (open source) | 99.9% | 15€ | 50€ | ✅ Oui | 🟢 Aucun | ❌ Overhead inutile |
| Brevo API | 99.9% | 0€ | 49€ | ✅ Oui | 🟢 Aucun | ❌ Email seulement |
## Justification
### Pourquoi WebSocket et pas HTTP long-polling ?
- **Efficacité** : 1 connexion TCP vs multiples requêtes HTTP
- **Batterie** : Connexion persistante optimisée par l'OS mobile
- **Bi-directionnel** : Backend peut envoyer des mises à jour instantanées (ex: "nouveau POI créé par un créateur que tu suis")
### Pourquoi implémentation directe APNS/FCM et pas SDK Firebase ?
**Réalité technique** : Notifications natives requièrent obligatoirement Google/Apple
- **APNS (Apple)** : Seul protocole pour notifications iOS → dépendance Apple inévitable
- **FCM (Google)** : Protocole standard Android (Google Play Services)
**Implémentation directe choisie** :
- **Gratuit** : APNS et FCM sont gratuits (pas de limite de volume)
- **Self-hosted** : Code backend 100% maîtrisé, pas de dépendance SDK tiers
- **Fiabilité** : Infrastructure Apple/Google avec 99.95% uptime
- **Batterie** : Utilise les mécanismes système natifs
- **Souveraineté** : Aucun vendor lock-in, appels directs aux APIs
- **Simplicité** : HTTP/2 pour APNS, HTTP pour FCM
**Alternatives rejetées** :
1. **Firebase SDK** :
- ❌ Vendor lock-in Google
- ❌ Dépendance SDK externe
- ❌ Contradictoire avec ADR-008 (self-hosted) et ADR-015 (souveraineté)
- ⚠️ Pas d'avantage technique par rapport aux APIs directes
2. **OneSignal / Pusher** :
- ❌ Vendor lock-in + coût élevé (500€+/mois @ 100K users)
- ❌ Abstraction inutile par-dessus APNS/FCM
3. **Novu (open source)** :
- ❌ Overhead sans gain réel
- ❌ Toujours wrapper autour APNS/FCM
**Décision technique** :
- Implémentation directe APNS/FCM dès le MVP
- **Cohérence ADR** : Respecte ADR-008 (self-hosted) et ADR-015 (souveraineté française)
- **Abstraction layer** : Interface `NotificationProvider` pour faciliter maintenance
- **Complexité** : Gestion des certificats APNS + JWT FCM (standard backend)
### Pourquoi limiter le geofencing local à Phase 2 ?
- **Complexité** : Permissions "Always Location" difficiles à obtenir (taux d'acceptation ~30%)
- **ROI** : 80% des utilisateurs auront un réseau mobile disponible
- **Priorité** : Livrer le MVP rapidement avec la solution serveur
## Conséquences
### Positives
- ✅ Notifications temps réel en mode piéton (< 1 minute de latence)
- ✅ Fonctionne avec HLS pour l'audio (pas de conflit avec ADR-002)
- ✅ Scalable : Worker backend peut gérer 10K utilisateurs/seconde avec PostGIS indexé
- ✅ Mode offline disponible en Phase 2 sans refonte
- ✅ Coût zéro jusqu'à millions de notifications (gratuit MVP + croissance)
- ✅ Géolocalisation natif iOS/Android optimisé (moins de batterie)
### Négatives
- ⚠️ **Gestion certificats APNS** : Renouvellement annuel + configuration
- Mitigé par scripts automation (certificats auto-renouvelés)
- Documentation complète du processus
- ⚠️ **Tokens push sensibles** : Tokens FCM/APNS stockés côté backend
- Chiffrement tokens en base (conformité RGPD)
- Rotation automatique des tokens expirés
- ❌ WebSocket nécessite maintien de connexion (charge serveur +10-20%)
- ❌ Mode offline non disponible au MVP (déception possible des early adopters)
### Impact sur les autres ADR
- **ADR-002 (Streaming)** : Aucun conflit - HLS reste pour l'audio
- **ADR-005 (Base de données)** : Ajouter index PostGIS `GIST (geom)` sur `points_of_interest`
- **ADR-010 (Architecture Backend)** : Ajouter un module `geofencing` avec worker dédié
- **ADR-010 (Frontend Mobile)** : Intégrer plugins APNS/FCM natifs (Flutter) et gérer permissions
## Abstraction Layer (Maintenabilité)
Implémentation d'une interface abstraite pour gérer APNS et FCM de manière unifiée :
```go
// backend/internal/notification/provider.go
type NotificationProvider interface {
SendNotification(ctx context.Context, platform, token, title, body, deepLink string) error
UpdateToken(ctx context.Context, userID, platform, newToken string) error
}
// backend/internal/notification/apns_provider.go
type APNSProvider struct {
client *apns2.Client
bundleID string
}
func (p *APNSProvider) SendNotification(ctx context.Context, platform, token, title, body, deepLink string) error {
if platform != "ios" {
return nil // Not applicable
}
notification := &apns2.Notification{
DeviceToken: token,
Topic: p.bundleID,
Payload: payload.NewPayload().
AlertTitle(title).
AlertBody(body).
Custom("deepLink", deepLink),
}
res, err := p.client.Push(notification)
if err != nil {
return err
}
if res.StatusCode != 200 {
return fmt.Errorf("APNS error: %s", res.Reason)
}
return nil
}
// backend/internal/notification/fcm_provider.go
type FCMProvider struct {
projectID string
client *http.Client
}
func (p *FCMProvider) SendNotification(ctx context.Context, platform, token, title, body, deepLink string) error {
if platform != "android" {
return nil // Not applicable
}
message := map[string]interface{}{
"message": map[string]interface{}{
"token": token,
"notification": map[string]string{
"title": title,
"body": body,
},
"data": map[string]string{
"deepLink": deepLink,
},
},
}
// Call FCM HTTP v1 API
url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", p.projectID)
// ... HTTP POST with OAuth2 token
return err
}
// backend/internal/notification/service.go
type NotificationService struct {
apnsProvider NotificationProvider
fcmProvider NotificationProvider
repo NotificationRepository
}
func (s *NotificationService) SendPush(ctx context.Context, userID, title, body, deepLink string) error {
user, err := s.repo.GetUser(ctx, userID)
if err != nil {
return err
}
// Route to appropriate provider based on platform
if user.Platform == "ios" {
return s.apnsProvider.SendNotification(ctx, "ios", user.PushToken, title, body, deepLink)
}
return s.fcmProvider.SendNotification(ctx, "android", user.PushToken, title, body, deepLink)
}
```
**Bénéfice** : Code modulaire, testable, et facile à maintenir. Ajout futur de providers alternatifs simple.
## Métriques de Succès
- Latence notification < 60s après entrée dans rayon 200m
- Taux de livraison > 95% (hors utilisateurs avec notifications désactivées)
- Consommation batterie < 5% / heure en mode piéton
- Coût serveur < 0.01€ / utilisateur / mois
## Migration et Rollout
### Phase 1 (MVP - Sprint 3-4)
1. Backend : Implémenter WebSocket endpoint `/ws/location`
2. Backend : Worker PostGIS avec requête ST_DWithin
3. Backend : Configuration APNS (certificats .p8) + FCM (OAuth2)
4. Mobile : Intégrer plugins natifs APNS/FCM + gestion push tokens
5. Test : Validation en conditions réelles (Paris, 10 testeurs)
### Phase 2 (Post-MVP - Sprint 8-10)
1. Mobile : Implémenter geofencing avec `flutter_background_geolocation`
2. Backend : API `/sync/nearby-pois?lat=X&lon=Y&radius=10km`
3. Mobile : Algorithme de sélection des 20 POI prioritaires
4. Test : Validation mode avion (offline complet)
## Références
- [Apple Push Notification Service (APNS) Documentation](https://developer.apple.com/documentation/usernotifications)
- [Firebase Cloud Messaging HTTP v1 API](https://firebase.google.com/docs/cloud-messaging/http-server-ref)
- [PostGIS ST_DWithin Performance](https://postgis.net/docs/ST_DWithin.html)
- [iOS Geofencing Best Practices](https://developer.apple.com/documentation/corelocation/monitoring_the_user_s_proximity_to_geographic_regions)
- Règle Métier 05 : Section 5.1.2 (Mode Piéton, lignes 86-120)