Module Push Notifications
Notifications push mobiles via deux canaux :
- FCM HTTP v1 (Firebase Cloud Messaging) pour les clients Flutter et natifs
- Expo Push API (legacy) pour les clients React Native / Expo
Le routage est automatique selon le format du token enregistré.
Architecture
Service métier (Teller, FieldAgent, Loans, Cron rappels)
│
▼
MemberPushHelper.notifyDeposit() / notifyLoanRepayment() / ...
│
▼
PushService.send(tenant, { ownerType, ownerId, title, body, data })
│
├─ Charge devices ACTIVE pour ownerType + ownerId
├─ Sépare tokens Expo vs FCM (préfixe "ExponentPushToken[")
│
├─► sendViaExpo() ──► POST exp.host/--/api/v2/push/send
│ └─ Désactive devices UNREGISTERED (DeviceNotRegistered)
│
└─► sendViaFcm() ──► FcmProvider.send(token, title, body, data)
├─ Récupère access_token OAuth2 (cache 50 min)
├─ POST fcm.googleapis.com/v1/projects/{id}/messages:send
└─ Désactive devices UNREGISTERED / INVALID_ARGUMENT
Configuration
Variables d'environnement
# .env.prod
FCM_SERVICE_ACCOUNT=<JSON service account en base64>
# Optionnel — déjà dans le JSON :
FCM_PROJECT_ID=kairos-mfi-xxxxx
Générer le base64 depuis le JSON téléchargé via Firebase Console :
# Linux / Mac
base64 -w0 path/to/service-account.json
# Windows PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\path\service-account.json")) | Set-Clipboard
Firebase Console
- Créer un projet Firebase
- Ajouter une app Android avec le package
com.kairos.kairos_mobile - Télécharger
google-services.json→ placer danskairos-mobile/android/app/ - Générer une clé de service account : Paramètres du projet → Comptes de service → « Générer une nouvelle clé privée »
- Convertir le JSON en base64 →
FCM_SERVICE_ACCOUNTcôté backend
Activation du plugin Gradle (déjà fait)
kairos-mobile/android/settings.gradle.kts :
plugins {
id("com.google.gms.google-services") version "4.4.2" apply false
}
kairos-mobile/android/app/build.gradle.kts :
plugins {
id("com.google.gms.google-services")
}
Schéma DB (PushDevice)
| Champ | Type | Description |
|---|---|---|
id |
uuid PK | |
ownerType |
'MEMBER' ou 'USER' |
|
ownerId |
string | memberId ou userId selon ownerType |
expoPushToken |
string unique | Nom historique. Stocke tout type de token (Expo OU FCM) |
platform |
string | 'ios', 'android', 'web' |
deviceName |
string nullable | |
appVersion |
string nullable | |
isActive |
bool | Faux si token rejeté par le provider |
lastUsedAt |
timestamp | MAJ à chaque envoi réussi |
memberRefId |
string nullable | FK Member (pour cascade delete) |
Endpoints
| Méthode | URL | Auth | Description |
|---|---|---|---|
| POST | /v1/portal/push/register |
JWT membre | Enregistre / met à jour le token |
| POST | /v1/portal/push/unregister |
JWT membre | Désactive le token |
| POST | /v1/users/me/push/register |
JWT back-office | Idem côté user |
| POST | /v1/users/me/push/unregister |
JWT back-office | |
| POST | /v1/users/me/push/test |
JWT back-office | Envoie une notif test au user courant (pour debug) |
Helper MemberPushHelper
Centralise les notifs au membre par événement métier (libellés FR codés en dur, à terme i18n) :
notifyDeposit(tenant, memberId, { amountFormatted, balanceFormatted, accountNumber, channel })
notifyWithdrawal(tenant, memberId, { amountFormatted, balanceFormatted, accountNumber })
notifyLoanDisbursement(tenant, memberId, { amountFormatted, loanReference })
notifyLoanRepayment(tenant, memberId, { amountFormatted, loanReference, channel })
notifyScheduleReminder(tenant, memberId, { amountFormatted, daysUntilDue, loanReference })
Câblé sur :
TellerService.deposit/withdraw/loanRepaymentFieldAgentService.deposit/loanRepaymentLoansService.disburse/repayLoanRemindersService.processTenant(cron quotidien 06:00 Africa/Lome)
Côté mobile Flutter
lib/core/push_service.dart :
// Init au démarrage (main.dart)
await Firebase.initializeApp();
await PushService.init();
// Après login
PushService.registerForCurrentUser(UserRole.member);
PushService.registerForCurrentUser(UserRole.fieldAgent);
// Au logout
PushService.unregister(role);
Foreground : _handleForeground affiche un SnackBar global
(via scaffoldMessengerKey). Background / killed : Android affiche
la notif système automatiquement (champ notification du payload
FCM).
Test et debug
Test de bout en bout
- Login sur le mobile (membre ou agent)
- Vérifier le terminal Flutter :
[Push] Token enregistré côté backend (...) - Vérifier en DB :
SET search_path TO tenant_demo; SELECT "ownerType", platform, LEFT("expoPushToken", 30), "isActive" FROM "PushDevice" ORDER BY "lastUsedAt" DESC LIMIT 5; - Déclencher l'envoi test :
TOKEN=$(curl -s -X POST https://api.kairos-mfi.com/v1/auth/login \ -H "Content-Type: application/json" -H "x-tenant-id: demo" \ -d '{"email":"...","password":"..."}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['accessToken'])") curl -X POST https://api.kairos-mfi.com/v1/users/me/push/test \ -H "Authorization: Bearer $TOKEN" -H "x-tenant-id: demo"
Logs serveur
docker compose -f docker-compose.prod.yml --env-file .env.prod logs api --tail=50 \
| grep -iE "fcm|push"
Au démarrage, doit afficher :
[FcmProvider] FCM configuré : projet=kairos-mfi
Cas d'erreur fréquents
| Symptôme | Cause | Fix |
|---|---|---|
FCM non configuré au démarrage |
FCM_SERVICE_ACCOUNT manquant ou base64 invalide |
Vérifier la var, regénérer |
| Token enregistré mais pas de notif | isActive = false (désactivé par envoi précédent) |
Réactiver via UPDATE + relogin |
Aucun device pour MEMBER/<id> |
Membre n'a jamais loggé sur l'app | Faire login |
FCM device désactivé (UNREGISTERED) |
App désinstallée ou token expiré | Normal, le device sera réenregistré au prochain login |
INVALID_ARGUMENT |
Payload mal formé | Vérifier les nouveaux champs ajoutés dans FcmProvider.send |
Sécurité
- Le service account JSON est secret — jamais commit dans Git
- Base64 stocké en env → ne jamais logger
FCM_SERVICE_ACCOUNT - Les tokens FCM des utilisateurs sont des identifiants — pas du
contenu sensible — mais on les tronque dans les logs
(
token.substring(0, 20)…) - Rotation : un membre / user qui change de téléphone a son ancien token automatiquement désactivé au prochain envoi (UNREGISTERED)