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

  1. Créer un projet Firebase
  2. Ajouter une app Android avec le package com.kairos.kairos_mobile
  3. Télécharger google-services.json → placer dans kairos-mobile/android/app/
  4. Générer une clé de service account : Paramètres du projet → Comptes de service → « Générer une nouvelle clé privée »
  5. Convertir le JSON en base64 → FCM_SERVICE_ACCOUNT cô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 / loanRepayment
  • FieldAgentService.deposit / loanRepayment
  • LoansService.disburse / repay
  • LoanRemindersService.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

  1. Login sur le mobile (membre ou agent)
  2. Vérifier le terminal Flutter : [Push] Token enregistré côté backend (...)
  3. 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;
    
  4. 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)