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
This commit is contained in:
jpgiannetti
2026-02-02 21:36:59 +01:00
parent b132fb957d
commit 6ba0688f87
5 changed files with 142 additions and 96 deletions

View File

@@ -14,14 +14,14 @@ ADR-002 spécifie HLS pour tout le streaming audio, mais HLS est un protocole un
Architecture hybride en **2 phases** :
### Phase 1 (MVP) : WebSocket + Firebase Cloud Messaging
### Phase 1 (MVP) : WebSocket + APNS/FCM Direct
```
[App Mobile] → [WebSocket] → [Backend Go]
[PostGIS Worker]
[Firebase FCM / APNS]
[APNS / FCM Direct API]
[Push Notification]
```
@@ -31,7 +31,7 @@ Architecture hybride en **2 phases** :
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.fcm_token
SELECT poi.*, users.push_token, users.platform
FROM points_of_interest poi
JOIN user_locations users ON ST_DWithin(
poi.geom,
@@ -41,7 +41,7 @@ Architecture hybride en **2 phases** :
WHERE users.notifications_enabled = true
AND users.last_update > NOW() - INTERVAL '5 minutes'
```
4. Si proximité détectée → envoi de push notification via Firebase (Android) ou APNS (iOS)
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** :
@@ -78,11 +78,11 @@ Architecture hybride en **2 phases** :
| Provider | Fiabilité | Coût MVP | Coût 100K users | Self-hosted | Vendor lock-in | Verdict |
|----------|-----------|----------|-----------------|-------------|----------------|---------|
| **Firebase (choix)** | 99.95% | **0€** | **0€** | ❌ Non | 🔴 Fort (Google) | ✅ Optimal MVP |
| **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 |
| Custom WS + APNS/FCM | Votre charge | 5€ | 100€+ | ✅ Oui | 🟢 Aucun | ⚠️ Complexe |
| Novu (open source) | 99.9% | 15€ | 50€ | ✅ Oui | 🟢 Aucun | 🟡 Phase 2 |
| 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
@@ -93,43 +93,40 @@ Architecture hybride en **2 phases** :
- **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 Firebase FCM et pas implémentation custom ?
- **Gratuit** : 10M notifications/mois (largement suffisant jusqu'à 100K utilisateurs)
- **Fiabilité** : Infrastructure Google avec 99.95% uptime
- **Batterie** : Utilise les mécanismes système (Google Play Services)
- **Cross-platform** : API unifiée iOS/Android
### Incohérence acceptée : Firebase vs self-hosted (ADR-008, ADR-015)
**Problème** : RoadWave promeut 100% self-hosted + souveraineté française, mais Firebase = dépendance Google Cloud.
### 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)** : Meilleur protocole Android (vs Huawei HMS, Samsung)
- **FCM (Google)** : Protocole standard Android (Google Play Services)
**Alternatives analysées** :
1. **Custom WebSocket** (self-hosted) :
- ✅ Zéro dépendance externe
- ❌ 150+ heures dev (2-3 sprints)
- ❌ Maintien de la reliability en-house
- ❌ Toujours besoin d'appeler APNS/FCM de toute façon
**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
2. **Novu (open source self-hosted)** :
- ✅ Self-hostable
- ❌ Jeune (moins mature)
- ❌ Toujours wrapper autour APNS/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
3. **OneSignal / Pusher** :
- ❌ Même vendor lock-in que Firebase
- ❌ Plus cher (500€+/mois @ 100K users)
**Décision pragmatique** :
- Firebase pour MVP : gratuit + fiabilité + time-to-market
- **Mitigation vendor lock-in** : Utiliser abstraction layer (`NotificationProvider` interface)
- **Exit path documenté** : Migration vers custom solution < 1 sprint si besoin futur
- **Probabilité de changement** : Très basse (MVP gratuit, pas d'incitation financière)
**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 ?
@@ -150,12 +147,12 @@ Architecture hybride en **2 phases** :
### Négatives
- ⚠️ **Dépendance Google (Firebase)** : Contradictoire avec ADR-008 (self-hosted) + ADR-015 (souveraineté FR)
- Mitigé par abstraction layer (`NotificationProvider` interface) → swap facile si besoin
- Exit path documenté pour migration custom (< 1 sprint)
- ⚠️ **Données utilisateur chez Google** : Tokens FCM, timestamps notifications
- Risque RGPD : Nécessite DPA Google valide
- À consulter avec DPO avant déploiement production
- ⚠️ **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)
@@ -164,58 +161,100 @@ Architecture hybride en **2 phases** :
- **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 `firebase_messaging` (Flutter) et gérer permissions
- **ADR-010 (Frontend Mobile)** : Intégrer plugins APNS/FCM natifs (Flutter) et gérer permissions
## Abstraction Layer (Mitigation Vendor Lock-in)
## Abstraction Layer (Maintenabilité)
Pour minimiser le coût de changement future, implémenter une interface abstraite :
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, token, title, body, deepLink string) error
UpdateToken(ctx context.Context, userID, newToken string) error
SendNotification(ctx context.Context, platform, token, title, body, deepLink string) error
UpdateToken(ctx context.Context, userID, platform, newToken string) error
}
// backend/internal/notification/firebase_provider.go
type FirebaseProvider struct {
client *messaging.Client
// backend/internal/notification/apns_provider.go
type APNSProvider struct {
client *apns2.Client
bundleID string
}
func (p *FirebaseProvider) SendNotification(ctx context.Context, token, title, body, deepLink string) error {
message := &messaging.Message{
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: map[string]string{
"deepLink": deepLink,
},
Token: token,
func (p *APNSProvider) SendNotification(ctx context.Context, platform, token, title, body, deepLink string) error {
if platform != "ios" {
return nil // Not applicable
}
_, err := p.client.Send(ctx, message)
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 {
provider NotificationProvider // ← Interface, pas concrète
repo NotificationRepository
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** : Swap Firebase → Custom/Novu sans changer business logic.
```go
// Futur : switch facilement
var provider NotificationProvider
if config.Provider == "firebase" {
provider = &FirebaseProvider{...}
} else if config.Provider == "custom" {
provider = &CustomProvider{...}
}
```
**Bénéfice** : Code modulaire, testable, et facile à maintenir. Ajout futur de providers alternatifs simple.
## Métriques de Succès
@@ -229,8 +268,9 @@ if config.Provider == "firebase" {
### Phase 1 (MVP - Sprint 3-4)
1. Backend : Implémenter WebSocket endpoint `/ws/location`
2. Backend : Worker PostGIS avec requête ST_DWithin
3. Mobile : Intégrer Firebase SDK + gestion FCM token
4. Test : Validation en conditions réelles (Paris, 10 testeurs)
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`
@@ -240,7 +280,8 @@ if config.Provider == "firebase" {
## Références
- [Firebase Cloud Messaging Documentation](https://firebase.google.com/docs/cloud-messaging)
- [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)