# Spécification opérationnelle — push POS Android / WebView

> Document complémentaire à `PUSH_POS_CADRAGE.md`.
> 
> Ce document transforme le cadrage produit et technique en plan d'implémentation backend concret pour `TTM_API`.

---

## 1. Objectif de ce document

Cette spécification décrit :

- le modèle de données à créer ;
- les endpoints backend à exposer ;
- le contrat minimal attendu côté Android / WebView ;
- la règle exacte de décision d'envoi ;
- le point d'intégration dans les services backend existants ;
- les logs, la sécurité et les tests à prévoir ;
- l'ordre d'implémentation recommandé.

Ce document doit permettre d'implémenter la V1 sans ambiguïté majeure.

---

## 2. Références techniques existantes

Le design doit rester compatible avec l'existant suivant :

- Auth HTTP : `src/middlewares/auth.middleware.ts`
- Résolution shop : `src/middlewares/shop.middleware.ts`
- Auth POS : `src/modules/auth/pos-auth.service.ts`
- Signal métier READY : `src/modules/orderItem/orderItem.service.ts`
- Scope temps réel déjà injecté : helpers de `orderItem.service.ts`
- Identité métier du caissier : `Order.createdById`
- Schéma Prisma source : `prisma/schema.enums.prisma` + `prisma/schema.custom.prisma`
- Fichier Prisma généré : `prisma/schema.prisma` (ne pas éditer directement)

---

## 3. Décisions figées pour la V1

## 3.1 Identité métier du destinataire

Pour la V1, le propriétaire d'une commande est :

- `Order.createdById`

Le push READY sera donc ciblé sur le `ShopUser` qui a créé la commande.

## 3.2 Déclencheur métier

Le signal déclencheur V1 est :

- transition effective d'un `OrderItem` vers `READY`

Les méthodes backend principalement concernées sont :

- `updateStatus(...)`
- `acknowledgeItem(...)`
- `batchAcknowledgeItems(...)`
- `batchMarkReadyItems(...)`

## 3.3 Source de vérité pour l'activité utilisateur

La présence active de l'utilisateur dans l'app ne doit pas être déduite uniquement du WebSocket.

La source principale doit être un état remonté par l'application :

- `FOREGROUND`
- `BACKGROUND`
- `UNKNOWN`

## 3.4 Unicité du terminal

Pour la V1, `terminalId` est considéré comme :

> un identifiant globalement unique d'installation Android/WebView POS.

Conséquences :

- un même `terminalId` ne doit correspondre qu'à une seule installation logique ;
- cette installation peut changer de `shopUserId` et de `shopId` au fil du temps ;
- si le shop change, l'installation est rescopée, pas dupliquée.

## 3.5 Règle anti-spam globale

S'il existe au moins **une** installation active en `FOREGROUND` pour le `shopUser` cible dans le `shop` courant, alors :

- aucun push n'est envoyé.

Même si d'autres appareils de ce même utilisateur sont en arrière-plan.

---

## 4. Modèle Prisma cible

## 4.1 Enum à ajouter dans `prisma/schema.enums.prisma`

### `PushAppState`

Valeurs :

- `FOREGROUND`
- `BACKGROUND`
- `UNKNOWN`

### `PushPlatform`

Valeurs V1 :

- `ANDROID`

### `PushUnregisterReason` *(optionnel mais recommandé)*

Valeurs possibles :

- `LOGOUT`
- `USER_SWITCH`
- `TOKEN_REFRESH`
- `MANUAL`
- `UNKNOWN`

Cette enum est surtout utile pour audit / debug si on choisit de stocker la dernière cause de désactivation.

---

## 4.2 Modèle `PosPushInstallation` à ajouter dans `prisma/schema.custom.prisma`

### Champs recommandés

- `id: String @id @default(uuid())`
- `shopId: String @map("shop_id")`
- `shopUserId: String? @map("shop_user_id")`
- `terminalId: String @unique @map("terminal_id")`
- `fcmToken: String @unique @map("fcm_token")`
- `platform: PushPlatform`
- `appVersion: String? @map("app_version")`
- `isActive: Boolean @default(true) @map("is_active")`
- `appState: PushAppState @default(UNKNOWN) @map("app_state")`
- `lastSeenAt: DateTime @map("last_seen_at")`
- `lastForegroundAt: DateTime? @map("last_foreground_at")`
- `lastPushSentAt: DateTime? @map("last_push_sent_at")`
- `lastPushEvent: String? @map("last_push_event")`
- `lastFirebaseErrorCode: String? @map("last_firebase_error_code")`
- `lastFirebaseErrorAt: DateTime? @map("last_firebase_error_at")`
- `createdAt: DateTime @default(now()) @map("created_at")`
- `updatedAt: DateTime @updatedAt @map("updated_at")`

### Relations

- relation vers `Shop`
- relation nullable vers `ShopUser`

### Index recommandés

- `@@index([shopId, shopUserId, isActive])`
- `@@index([shopId, isActive, appState])`
- `@@index([lastSeenAt])`
- `@@index([shopUserId, lastSeenAt])`

### Mapping SQL recommandé

- `@@map("pos_push_installations")`

---

## 4.3 Relations à ajouter dans les modèles existants

### Dans `Shop`

Ajouter :

- `pushInstallations PosPushInstallation[]`

### Dans `ShopUser`

Ajouter :

- `pushInstallations PosPushInstallation[]`

---

## 4.4 Politique TTL / vieillissement

Pour la V1, une installation est considérée **stale** si :

- `lastSeenAt` est trop ancien
- ou `isActive = false`

Décision recommandée :

- ne jamais envoyer vers une installation inactive ;
- ne pas envoyer vers une installation dont `lastSeenAt` dépasse un TTL configurable.

### Valeur recommandée V1

- TTL d'éligibilité push : `7 jours`

Le TTL doit être piloté par configuration.

---

## 5. Contrat HTTP backend

## 5.1 Règles communes

Toutes les routes push sont :

- protégées par `protect`
- scoppées par `attachShop`
- montées sous `/api/push`

### En-têtes requis

- `Authorization: Bearer <token>`
- `X-Shop-Id: <shopId>`

### Règles d'autorité

Le backend déduit toujours :

- `shopUserId` depuis `req.auth.sub`
- `shopId` depuis `req.shop.id`

Le client ne doit pas pouvoir imposer un `shopUserId` cible.

---

## 5.2 `POST /api/push/register`

## Objectif

Créer ou mettre à jour l'installation push courante.

## Payload

```json
{
  "terminalId": "android-installation-123",
  "fcmToken": "xxxxx",
  "platform": "ANDROID",
  "appVersion": "0.9.17+75",
  "appState": "FOREGROUND"
}
```

## Validation

- `terminalId`: string non vide, longueur raisonnable
- `fcmToken`: string non vide
- `platform`: enum, V1 uniquement `ANDROID`
- `appVersion`: string optionnelle
- `appState`: enum optionnelle, défaut `UNKNOWN`

## Comportement backend

1. lire `shopId` et `shopUserId` du contexte auth ;
2. chercher une installation existante par `terminalId` ;
3. chercher une installation existante par `fcmToken` ;
4. fusionner intelligemment si nécessaire ;
5. upsert final sur l'installation logique ;
6. affecter :
   - `shopId`
   - `shopUserId`
   - `terminalId`
   - `fcmToken`
   - `isActive = true`
   - `appState`
   - `lastSeenAt = now()`
   - `lastForegroundAt = now()` si `appState = FOREGROUND`
7. désactiver / nettoyer l'ancien rattachement si le terminal était lié à un autre utilisateur.

## Réponse recommandée

```json
{
  "ok": true,
  "installation": {
    "id": "...",
    "shopId": "...",
    "shopUserId": "...",
    "terminalId": "android-installation-123",
    "platform": "ANDROID",
    "appVersion": "0.9.17+75",
    "isActive": true,
    "appState": "FOREGROUND",
    "lastSeenAt": "2026-05-08T12:00:00.000Z",
    "lastForegroundAt": "2026-05-08T12:00:00.000Z"
  }
}
```

---

## 5.3 `POST /api/push/heartbeat`

## Objectif

Mettre à jour la fraîcheur d'une installation sans réenregistrer tout le contexte.

## Payload

```json
{
  "terminalId": "android-installation-123",
  "appState": "BACKGROUND"
}
```

## Validation

- `terminalId` requis
- `appState` optionnel

## Comportement backend

- retrouver l'installation par `terminalId`
- vérifier qu'elle est bien associée au `shopUser` courant ou au moins au `shop` courant
- mettre à jour :
  - `lastSeenAt = now()`
  - `appState` si fourni
  - `lastForegroundAt = now()` si `appState = FOREGROUND`
  - `isActive = true`

## Réponse recommandée

```json
{
  "ok": true,
  "lastSeenAt": "2026-05-08T12:05:00.000Z",
  "appState": "BACKGROUND"
}
```

---

## 5.4 `POST /api/push/app-state`

## Objectif

Endpoint dédié si on souhaite dissocier la mise à jour d'état de la notion de heartbeat.

## Payload

```json
{
  "terminalId": "android-installation-123",
  "appState": "FOREGROUND"
}
```

## Décision V1

Deux options acceptables :

### Option A — simple

Ne **pas** créer cet endpoint et intégrer `appState` dans `heartbeat`.

### Option B — plus lisible

Créer un endpoint séparé.

### Recommandation

Pour la V1 : **Option A**.

Donc :

- `heartbeat` porte aussi l'état `FOREGROUND / BACKGROUND`.

---

## 5.5 `POST /api/push/unregister`

## Objectif

Désactiver l'installation pour l'utilisateur / shop courant.

## Payload

```json
{
  "terminalId": "android-installation-123",
  "reason": "LOGOUT"
}
```

## Validation

- `terminalId` requis
- `reason` optionnel, purement indicatif

## Comportement backend

- retrouver l'installation par `terminalId`
- si elle correspond au scope courant, mettre :
  - `isActive = false`
  - `appState = UNKNOWN`
  - `shopUserId = null`
  - `lastSeenAt = now()`
- conserver l'historique technique minimal

## Réponse recommandée

```json
{
  "ok": true,
  "terminalId": "android-installation-123",
  "isActive": false
}
```

---

## 5.6 `GET /api/push/status`

## Objectif

Endpoint de debug / inspection côté app.

## Query optionnelle

- `terminalId`

## Réponse recommandée

```json
{
  "ok": true,
  "installation": {
    "terminalId": "android-installation-123",
    "shopId": "...",
    "shopUserId": "...",
    "isActive": true,
    "appState": "BACKGROUND",
    "lastSeenAt": "...",
    "lastForegroundAt": "..."
  }
}
```

---

## 6. Machine d'état d'une installation push

## 6.1 États principaux

### État logique d'abonnement

- `ACTIVE`
- `INACTIVE`

### État d'activité UI

- `FOREGROUND`
- `BACKGROUND`
- `UNKNOWN`

## 6.2 Transitions attendues

### Connexion normale

- création / mise à jour installation
- `ACTIVE + FOREGROUND` ou `ACTIVE + BACKGROUND`

### Mise en arrière-plan

- `ACTIVE + BACKGROUND`

### Retour premier plan

- `ACTIVE + FOREGROUND`
- `lastForegroundAt` mis à jour

### Logout

- `INACTIVE + UNKNOWN`
- `shopUserId = null`

### Switch utilisateur sur même appareil

- même `terminalId`
- réaffectation au nouveau `shopUserId`
- écrasement de l'ancien scope

### Token FCM renouvelé

- même `terminalId`
- nouveau `fcmToken`
- l'ancienne référence token est remplacée

### Erreur Firebase non récupérable

- installation désactivée ou au minimum token marqué invalide
- `lastFirebaseErrorCode` mis à jour

---

## 7. Règle de décision d'envoi backend

## 7.1 Fonction métier cible

Créer un service métier du type :

- `posPushDispatchService.dispatchOrderItemReady(...)`

ou

- `posPushDispatchService.dispatchBusinessEvent(...)`

Pour la V1, un service spécialisé est préférable.

---

## 7.2 Entrée minimale du service

Entrée recommandée :

- `shopId`
- `orderId`
- `orderItemId`
- `targetShopUserId`
- `terminalId` optionnel si besoin futur

---

## 7.3 Algorithme recommandé

### Étape 1 — valider l'éligibilité métier

Ne rien envoyer si :

- `targetShopUserId` est vide
- l'item n'est pas réellement `READY`
- la commande ne correspond pas au `shopId`

### Étape 2 — charger les installations éligibles

Charger les `PosPushInstallation` tels que :

- `shopId = shop courant`
- `shopUserId = targetShopUserId`
- `isActive = true`
- `lastSeenAt >= now - TTL`

### Étape 3 — appliquer la règle anti-spam

Si au moins une installation a :

- `appState = FOREGROUND`

alors :

- décision = `SKIP_FOREGROUND_PRESENT`
- aucun push n'est envoyé

### Étape 4 — sélectionner les destinataires finaux

Destinataires retenus :

- installations actives
- non stale
- `appState != FOREGROUND`
- token présent

### Étape 5 — envoyer via FCM

Envoyer un message `data-only` à tous les tokens retenus.

### Étape 6 — traiter les erreurs

Pour chaque erreur Firebase terminale :

- désactiver ou nettoyer le token concerné
- tracer le code d'erreur

---

## 7.4 Payload FCM V1

### Payload `data`

```json
{
  "event": "order_item_ready",
  "shopId": "...",
  "orderId": "...",
  "orderItemId": "...",
  "targetShopUserId": "..."
}
```

### Règles

- ne pas embarquer le détail complet de la commande ;
- le client doit refetch ;
- ne pas exposer d'information inutile ;
- rester stable et simple.

---

## 8. Intégration dans `orderItem.service.ts`

## 8.1 Principe impératif

Le push ne doit pas partir avant que la transaction métier soit réellement validée.

Comme `trackedTransaction(...)` flush les événements WebSocket après commit, le push doit suivre la même logique de sûreté.

## 8.2 Pattern recommandé

Dans chaque méthode concernée :

1. détecter si la transition effective devient `READY`
2. construire un `pushCandidate`
3. sortir de la transaction
4. après succès de `trackedTransaction(...)`, appeler le service d'envoi

### Exemple conceptuel

- pendant la transaction :
  - on collecte un tableau `pushCandidates`
- après le `await trackedTransaction(...)` :
  - `for ... await posPushDispatchService.dispatchOrderItemReady(candidate)`

Ce pattern garantit :

- pas de push si la transaction échoue ;
- pas de divergence entre état DB et notification.

---

## 8.3 Méthodes à instrumenter en priorité

### Priorité 1

- `updateStatus(...)`
- `acknowledgeItem(...)`
- `batchAcknowledgeItems(...)`
- `batchMarkReadyItems(...)`

### Priorité 2

Toute autre méthode introduisant une transition effective vers `READY`.

---

## 9. Services backend à créer

## 9.1 Module HTTP

Créer un module dédié :

- `src/modules/push/push.routes.ts`
- `src/modules/push/push.controller.ts`
- `src/modules/push/push.service.ts`
- `src/modules/push/push.schema.ts`

## 9.2 Service technique Firebase

Créer par exemple :

- `src/modules/push/push-fcm.service.ts`

Responsabilités :

- initialiser Firebase Admin ;
- envoyer un message à plusieurs tokens ;
- mapper les erreurs Firebase ;
- désactiver les installations invalides ;
- exposer un mode no-op si Firebase n'est pas configuré.

## 9.3 Service métier de dispatch

Créer par exemple :

- `src/modules/push/push-dispatch.service.ts`

Responsabilités :

- appliquer la logique métier READY ;
- appliquer la règle anti-spam ;
- sélectionner les installations ;
- construire le payload minimal.

---

## 10. Contrat minimal Android / WebView

## 10.1 Côté Android / WebView, à chaque connexion

Le client doit :

1. connaître le `shopId` actif ;
2. connaître le `terminalId` d'installation ;
3. obtenir le token FCM natif ;
4. appeler `POST /api/push/register`.

## 10.2 À chaque refresh token FCM

Le client doit réappeler :

- `POST /api/push/register`

avec le même `terminalId` et le nouveau `fcmToken`.

## 10.3 À chaque changement d'état de l'app

Le client doit informer le backend quand l'app :

- passe au premier plan
- passe en arrière-plan

Recommandation V1 :

- utiliser `POST /api/push/heartbeat` avec `appState`.

## 10.4 Au logout

Le client doit appeler :

- `POST /api/push/unregister`

avant ou pendant la déconnexion locale.

## 10.5 Au switch utilisateur sur même appareil

Séquence recommandée :

1. `unregister` pour l'ancien utilisateur si possible
2. login nouveau user
3. `register` pour le nouveau user avec le même `terminalId`

Même si l'étape 1 est ratée, l'étape 3 doit être suffisante pour rebinder proprement le scope côté backend.

## 10.6 À la réception d'un push

Le client ne doit pas faire confiance au payload comme source de vérité.

Il doit :

- réveiller l'UX si besoin ;
- refetch la liste / le détail nécessaires ;
- laisser le backend trancher l'état réel.

---

## 11. Variables d'environnement recommandées

## 11.1 Variables métier / runtime

- `PUSH_FCM_ENABLED=true|false`
- `PUSH_INSTALLATION_TTL_MINUTES=10080` *(7 jours par défaut)*

## 11.2 Variables Firebase — option A recommandée

### Option A — variables explicites

- `FIREBASE_PROJECT_ID`
- `FIREBASE_CLIENT_EMAIL`
- `FIREBASE_PRIVATE_KEY`

Remarque :

- `FIREBASE_PRIVATE_KEY` doit gérer correctement les sauts de ligne (`\n` -> newline)

### Option B — JSON complet

- `FIREBASE_SERVICE_ACCOUNT_JSON`

### Recommandation V1

Commencer par l'option A ou B selon le mode de déploiement réel.

## 11.3 Mode dégradé obligatoire

Si la configuration Firebase est absente ou invalide :

- le backend ne doit pas tomber ;
- le service push doit logguer un warning ;
- les appels d'envoi doivent devenir des no-op contrôlés.

---

## 12. Journalisation et observabilité

## 12.1 Log métier recommandé à chaque décision

Tracer au minimum :

- `event=push.order_item_ready.evaluate`
- `shopId`
- `orderId`
- `orderItemId`
- `targetShopUserId`
- `eligibleInstallationCount`
- `foregroundInstallationCount`
- `decision`

### Valeurs de `decision` recommandées

- `SKIP_NO_TARGET_USER`
- `SKIP_NO_INSTALLATION`
- `SKIP_FOREGROUND_PRESENT`
- `SKIP_STALE_INSTALLATIONS`
- `SEND`
- `SEND_PARTIAL`
- `SEND_NOOP_FCM_DISABLED`

## 12.2 Logs techniques FCM

Tracer :

- nombre de tokens envoyés
- nombre de succès
- nombre d'échecs
- codes d'erreur Firebase

### Règle de sécurité

Ne jamais logguer le token complet.

Utiliser :

- suffixe tronqué
- ou hash partiel

---

## 13. Sécurité

## 13.1 Règles HTTP

- toutes les routes push sont protégées ;
- `shopId` et `shopUserId` sont toujours déduits du contexte serveur.

## 13.2 Règles de confiance

Le client peut fournir :

- `terminalId`
- `fcmToken`
- `appVersion`
- `appState`

Mais le backend reste souverain sur :

- l'identité utilisateur ;
- le shop courant ;
- la décision finale d'envoi.

## 13.3 Règles de ciblage

Le backend ne doit jamais envoyer de push à un utilisateur autre que :

- le `createdById` de la commande liée à l'item prêt.

---

## 14. Tests à prévoir

## 14.1 Runner de test

Le repo backend n'expose pas aujourd'hui de stack de test dédiée visible dans `package.json`.

Recommandation V1 :

- ajouter `vitest`
- ajouter `supertest` si tests d'endpoints HTTP

## 14.2 Jeux de tests minimaux

### Enregistrement

- crée une installation si elle n'existe pas ;
- met à jour l'installation si même `terminalId` ;
- remplace le token si `fcmToken` renouvelé ;
- rebinde correctement le `shopUserId` si autre utilisateur sur même appareil.

### Désenregistrement

- met `isActive=false` ;
- vide `shopUserId` ;
- ne supprime pas brutalement l'historique technique.

### Heartbeat / appState

- met à jour `lastSeenAt` ;
- bascule `appState` ;
- renseigne `lastForegroundAt` si retour premier plan.

### Décision d'envoi

- READY + aucune installation => pas d'envoi ;
- READY + installation background => envoi ;
- READY + installation foreground => pas d'envoi ;
- READY + plusieurs appareils dont un foreground => pas d'envoi ;
- READY + plusieurs appareils tous background => envoi aux appareils éligibles.

### Sécurité / nettoyage

- token invalide Firebase => installation désactivée ou marquée invalide ;
- appel sans auth => rejet ;
- appel sans `X-Shop-Id` => rejet côté POS.

---

## 15. Ordre d'implémentation recommandé

## Étape 1 — Modèle et migration

- ajouter enums Prisma
- ajouter `PosPushInstallation`
- relier `Shop` et `ShopUser`
- générer Prisma
- migrer DB

## Étape 2 — Module HTTP `/api/push`

- schémas Zod
- service CRUD installation
- controller
- routes
- branchement dans `src/app.ts`

## Étape 3 — Service FCM technique

- intégration Firebase Admin
- mode no-op si non configuré
- gestion des erreurs terminales

## Étape 4 — Service métier de dispatch

- sélection des installations
- règle anti-spam
- payload `data-only`
- logs décisionnels

## Étape 5 — Intégration `orderItem.service.ts`

- collecte des `pushCandidates`
- dispatch après commit uniquement
- couverture des variantes batch

## Étape 6 — Tests

- tests de service installation
- tests de dispatch READY
- tests d'anti-spam
- tests invalid token

## Étape 7 — Contrat Android / QA

- vérification `register`
- vérification `background`
- vérification `logout`
- vérification switch utilisateur sur même appareil

---

## 16. Décisions à revalider avant codage effectif

Même si la V1 est cadrée, ces choix doivent être confirmés avant implémentation finale :

1. TTL exact d'une installation éligible
2. stratégie exacte de stockage des credentials Firebase
3. réponse du flow `switchShop` si on veut un scope push strictement cohérent lors des changements de shop
4. niveau d'audit à conserver sur les erreurs Firebase

---

## 17. Résumé exécutable

La V1 doit être implémentée comme suit :

- une installation push Android est enregistrée à la connexion POS ;
- elle est liée à `shopId + shopUserId + terminalId + fcmToken` ;
- elle remonte régulièrement `lastSeenAt` et `appState` ;
- lorsqu'un `orderItem` devient `READY`, le backend regarde les installations du `createdById` de la commande ;
- si au moins une installation est en `FOREGROUND`, aucun push n'est envoyé ;
- sinon, un push `data-only` minimal est envoyé aux installations actives éligibles ;
- si un autre utilisateur se connecte sur le même appareil, le scope de notification bascule immédiatement.

C'est ce résumé qui doit piloter l'implémentation concrète.

