# Audit hors-ligne — App mobile MissioFlow

> Date : 2026-06-11 · Auteur : audit Claude (session `mobile`) · Statut : vérifié sur code réel (mobile + backend coolcare/missioflow)
>
> Contexte : comparaison du dashboard technicien PWA vs app mobile. Le cœur métier (login → missions → workflow → photos/signatures → finalisation → récap) est implémenté et cohérent. **Mais 4 défauts mordent tous précisément quand le technicien est hors-ligne — c'est-à-dire sa réalité terrain.** L'infra offline existe (SQLite, sync queue multipart, drafts) mais elle est partiellement non câblée.

Légende ownership : 📱 = à corriger côté **mobile** · 🖥️ = à corriger côté **backend** (`app`) · 🔗 = cross-repo.

---

## A — ✅ Relance hors-ligne → déconnexion forcée  📱 (fait)

> **Statut 2026-06-11 (soir)** : **Corrigé.** Le bootstrap distingue désormais une **erreur réseau** (`ApiClientError` sans `status`) d'une **vraie 401** (`status === 401`). Hors-ligne avec un `session.user` en cache → **session optimiste restaurée** (l'app s'ouvre sur le snapshot SQLite, `setApiAuthTokens` réhydrate le cache), `clearSession` n'est plus appelé. Une 401 réelle continue de purger + renvoyer au login. Fichier : [App.tsx](../App.tsx) (bootstrap). Tests : +2 (offline → dashboard sans clear ; 401 → clear). typecheck + 670 tests verts.

### (Détail d'origine)

**Symptôme** : un technicien qui a utilisé l'app la veille et la relance **sans réseau** est renvoyé au login et ne peut plus entrer. L'app est de fait 100 % en ligne.

**Cause (vérifiée)** :
- Au boot, `App.tsx:818` appelle `authApi.me()` (`/mobile/me.php`).
- Sans réseau → `ApiClientError` → le `catch` `App.tsx:823-826` fait `clearSession()` + `clearApiAuthTokens()` → navigation Login.
- Pourtant la session locale contient déjà `session.user` (`src/services/tokenStorage.ts:23-28`) — **non utilisé en fallback**.

**Correctif** : dans le `catch` du bootstrap, distinguer **erreur réseau** (offline) d'une **vraie 401**.
- Erreur réseau + `session.user` présent → restaurer la session optimiste (`setUser(session.user)`), ne **pas** `clearSession()`.
- Vraie 401 (token révoqué / invalide) → comportement actuel (clear + login).

**Effort** : faible. **Impact** : débloque le lancement offline. **À faire en premier.**

---

## B — ✅ Photos prises hors-ligne perdues / finalisation bloquée  📱 (fait)

> **Statut 2026-06-11 (soir)** : **Corrigé.** En cas d'échec de l'upload immédiat, `CameraCapture` met désormais la photo en **file de sync multipart** (`enqueueWorkflowStepPhoto` → format `__multipart`/`upload_files` consommé par `postMultipartFromQueue`). Le `idempotency_key` est généré **une seule fois** et partagé entre la tentative immédiate et le replay → pas de doublon (dedup backend 24 h) si l'immédiat avait en fait atteint le serveur. Le replay est déclenché par `triggerReconnectSync` → `syncOnReconnect` → `processSyncQueue` (réseau/boot/foreground) : **la photo n'est plus jamais perdue**. Fichiers : [src/shared/CameraCapture.tsx](../src/shared/CameraCapture.tsx) · [src/services/interventionsApi.ts](../src/services/interventionsApi.ts) (`enqueueWorkflowStepPhoto`). Tests : +1 enqueue offline + replay, +1 contrat payload multipart. typecheck + 671 tests verts.
>
> Reste hors scope B (= défaut D) : finaliser **pendant** qu'on est offline échoue toujours (le POST `complete_workflow` n'est pas mis en file) — mais la photo, elle, est désormais sauvée et resync ; le tech finalise au retour du réseau, `activeFileCount` est alors correct.

### (Détail d'origine)

**Symptôme** : une photo prise sans réseau n'est jamais resynchronisée, et la finalisation est ensuite refusée (« Nombre de photos insuffisant »).

**Cause (vérifiée)** :
- Capture → fichier permanent local OK (`src/core/fileStorage.ts:15-27`, survit au redémarrage).
- Upload **immédiat uniquement** vers `/mobile/intervention_workflow_files.php`.
- En cas d'échec réseau : l'erreur est juste affichée, **aucun `enqueueOfflineAction()`** (`src/shared/CameraCapture.tsx:72-78`). Le fichier local n'est référencé nulle part pour replay.
- L'infra multipart de la queue **existe mais n'est jamais alimentée** (`src/services/syncService.ts:35-68, 244-246` — `isMultipartQueuePayload` / `postMultipartFromQueue` sont du code mort).
- Limite admise en commentaire : `src/core/stepRepository.ts:88-90`.
- À la finalisation, le serveur valide `activeFileCount` → bloque si la photo n'a pas été uploadée.

**Correctif** : dans le `catch` d'upload de `CameraCapture`, **alimenter la queue multipart existante** (enqueue : `workflow_step_id`, `idempotency_key`, chemin fichier local). Rejouer au reconnect via `syncService` (l'infra `postMultipartFromQueue` est déjà là). Lier l'id serveur de fichier retourné au step local. Optionnel : empêcher/avertir clairement à la finalisation tant qu'un upload est en attente.

**Effort** : moyen (l'infra existe, il faut la brancher). **Impact** : évite la perte de données terrain.

---

## C — ✅ Étapes conditionnelles absentes hors-ligne  🔗 (backend + mobile faits)

> **Statut 2026-06-11 (soir)** : **Backend missioflow-app livré** (thread #168, app) — filtre `is_visible` retiré des 2 endpoints mobiles, `is_visible` exposé en booléen JSON top-level, scope tenant conservé. **Coolcare : demande envoyée (#169), en attente.**
> **Mobile livré** : `is_visible` ajouté au type `MobileStep`, persisté en SQLite (colonne `is_visible`, migration `ensureStepsSchema`), et le **récap d'intervention terminée filtre `is_visible !== false`** pour ne pas afficher les extensions jamais déclenchées qui arrivent désormais (régression évitée). Le workflow en cours était déjà offline-ready (`computeVisibleStepIds` recalcule dynamiquement). typecheck + 668 tests verts.
> Fichiers mobile : [src/types/intervention.ts](../src/types/intervention.ts) · [src/core/localDatabase.ts](../src/core/localDatabase.ts) · [src/features/missions/MissionDetailScreen.tsx](../src/features/missions/MissionDetailScreen.tsx).

### (Détail d'origine)

**Symptôme** : hors-ligne, saisir une valeur « en panne » devrait révéler une extension (ex. « cause de la panne »), mais celle-ci **ne s'affiche jamais** car elle n'a jamais été téléchargée.

**Cause (vérifiée)** : le backend ne renvoie **que** les étapes `is_visible=1`, dans **les deux** endpoints mobiles :
- `coolcare-app/public/api/mobile/intervention_steps.php:63-65` (`if is_visible !== 1 { continue; }`)
- `coolcare-app/public/api/mobile/sync_pull.php` → `Intervention::getWorkflowStepsUpdatedSince` → `src/models/Intervention.php:983` (`AND s.is_visible = 1`)

Donc le mobile ne reçoit que le sous-ensemble visible à l'instant du prefetch. `computeVisibleStepIds` (`src/shared/workflowUtils.ts:206-251`) ne peut révéler que ce qui est **déjà en local** → une extension jamais reçue est inatteignable offline.

> Note importante : la **logique** de visibilité est alignée PWA/mobile (mêmes règles `visible_if_in_parent` / `visible_if_not_parent` + fallback « si panne », miroir du backend `InterventionWorkflowService.php:2365-2419, 2677-2710`). Le mobile reçoit déjà `rules_json` (mappé en `constraints`) et sait recalculer. **Le seul problème est le filtrage `is_visible` à la livraison.**

**Correctif (🖥️ backend)** : sur les endpoints **mobiles**, **ne plus filtrer `is_visible`** — envoyer **toutes** les étapes du workflow (visibles et masquées) avec le flag `is_visible` + les `rules`/`constraints`. Le mobile calcule la visibilité côté client (offline-first), comme la PWA le fait déjà en gardant tout le DOM.
**Correctif (📱 mobile)** : une fois le backend ouvert, vérifier que `applyServerStepsDelta` / `computeVisibleStepIds` gèrent bien les étapes initialement masquées (a priori oui, à tester).

**Effort** : moyen, cross-repo. **Impact** : parité de workflow PWA/mobile + complétude offline.

---

## D — ✅ Login à froid hors-ligne impossible  📱 (fait)

> **Statut 2026-06-11 (soir)** : **Implémenté** (reconnexion hors-ligne). Nouveau service [src/services/offlineCredentialStore.ts](../src/services/offlineCredentialStore.ts) : au login **en ligne** réussi, on cache `{email, salt, encodedHash, user}` dans SecureStore — le mot de passe est haché en **Argon2id** (module natif `react-native-argon2`), **mêmes paramètres que la PWA** (`memory=65536, t=4, p=1`, cf. `coolcare-app/src/utils/PasswordHasher.php`). Sur l'écran login, si le login en ligne échoue **pour cause réseau** (pas une vraie 401), on vérifie le credential offline ; si match → session restaurée sur les données en cache. Tokens encore présents → session pleine ; absents (post-logout) → session **offline-only** + invite à se reconnecter au retour du réseau (`triggerReconnectSync` neutralisé tant que tokenless ; le travail en file SQLite est préservé). Credential effacé au **change-instance** ; TTL 30 j. Fichiers : [App.tsx](../App.tsx), [src/services/offlineCredentialStore.ts](../src/services/offlineCredentialStore.ts), mocks `jest.setup.ts`. Tests : +10 service + 4 App. typecheck + 685 tests verts.
>
> ⚠️ **CONTRAINTE DE LIVRAISON** : `react-native-argon2` est un **module natif** → la reconnexion hors-ligne **ne s'active qu'après un rebuild APK** (`npm run build:test` puis `build:prod`) + réinstallation côté technicien. **Pas livrable en OTA.**
> **Dégradation gracieuse** (vérifiée par lecture du flux) : si ce bundle JS est poussé en OTA sur un APK qui n'a PAS encore le module natif, **rien ne casse** — `saveOfflineCredential` (login en ligne) et `verifyOfflineCredential` (login hors-ligne) sont tous deux en `try/catch`, donc l'appel à `argon2()` qui échoue est avalé : le login en ligne et tout le reste continuent de fonctionner, seule la reconnexion hors-ligne reste inactive jusqu'au rebuild.
> ✅ **VALIDÉ SUR DEVICE (2026-06-11)** : build EAS `preview` (APK) installé, et tests 1→4 réussis sur un vrai appareil — login en ligne (Argon2id natif OK sur **New Architecture RN 0.81**), reconnexion hors-ligne après déconnexion, refus sur mauvais mot de passe, invite de reconnexion au retour du réseau. La compat `react-native-argon2@4.0.0` / New Arch est donc **confirmée en conditions réelles** (plus une hypothèse).

### (Détail d'origine)

**Symptôme** : impossible de se connecter pour la première fois de la journée sans réseau (aucun fallback).

**Cause (vérifiée)** : `LoginScreen` → `authApi.login()` → POST `/mobile/login.php` ; sans réseau → `ApiClientError`. **Aucun équivalent** du `public/js/pwa/auth-offline.js` de la PWA (cache credentials hashés, TTL 24 h).

**Correctif** : implémenter un cache de credentials offline type PWA (hash local + TTL) pour autoriser un login dégradé hors-ligne. Dépend en partie de D vs A : si A est fait, le cas « relance » est couvert ; D ne concerne que le **tout premier** login d'une session sans réseau.

**Effort** : élevé. **Impact** : confort final. **À faire en dernier.**

---

## E — Complétude offline des workflows (préchargement)

Au-delà des 4 défauts, un enjeu de **complétude** : les **étapes** d'une intervention ne sont PAS dans la liste (`getMyInterventions`) et sont **matérialisées paresseusement** côté serveur à la 1ʳᵉ lecture (`getWorkflowState` → `createWorkflow`). Donc tant qu'une intervention n'a pas été ouverte **en ligne**, ses étapes n'existent ni en cache local ni en base serveur → ouverture hors-ligne = « aucune étape ».

**Couche 1 — préchargement au login/boot ✅ (fait, pur JS, OTA-able)** : après `loadInterventions` (en ligne), `prefetchWorkflows` boucle sur les interventions « à faire » et matérialise+cache chaque workflow en tâche de fond (message « N interventions prêtes hors-ligne »). Couvre « le tech ouvre l'app avec réseau ». Fichiers : [src/services/interventionsApi.ts](../src/services/interventionsApi.ts) (`prefetchWorkflows`), [App.tsx](../App.tsx) (`prefetchTodoWorkflows`).

**Couche 2 — data push → sync en arrière-plan 🟢 (mobile + backend faits, reste rebuild + validation device)** : pour couvrir « le tech n'ouvre PAS l'app » (assigné par téléphone → descend en cave). Tâche de fond mobile implémentée : [src/services/backgroundSync.ts](../src/services/backgroundSync.ts) (`expo-task-manager` + `expo-notifications.registerTaskAsync`). **Backends livrés** (app #177, coolcare #178) : data push `_contentAvailable:true`+`priority:high`, `data = { intervention_id, type, action }`. Le mobile **keye sur `action`** (`upsert` → pull détail+étapes via `getInterventionById` ; `delete` → `deleteInterventionLocally`, sur suppression OU réassignation où l'ancien tech reçoit `delete`), en ignorant `type` (granulaire pour le deep-link visible). Définie au scope module ([App.tsx](../App.tsx)) + enregistrée au boot. Tests : +12.
> **Reste** : (1) **rebuild APK** (module natif `expo-task-manager`, pas d'OTA) ; (2) **iOS** = `UIBackgroundModes:["remote-notification"]` dans app.json (best-effort Apple) ; (3) **validation device** (forme exacte du payload reçu en tâche de fond + réveil app fermée). **Dégradation gracieuse** : `defineBackgroundNotificationTask` en try/catch → si OTA sur APK sans le module natif, pas de crash, couche 2 juste inactive. **Sécurité** : le `data` ne porte que `intervention_id` + enums (`type`/`action`), zéro donnée métier.
> **Limite physique** : il faut au moins une fenêtre de réseau entre l'assignation et la cave — ce qui est le cas réel (trajet domicile→site).

## Ce qui marche (ne pas toucher)

- **Récap d'intervention terminée** ✅ : ouvrir le détail d'une mission terminée affiche le déroulé complet (`MissionDetailScreen.tsx:449-451` → `CompletedStepsSection` / `ReadOnlyStepRow`) : label, valeurs, **photos**, **signatures**, **commentaires**. Comportement aligné avec le récap HTML de la PWA. (Ni la PWA ni le mobile ne proposent de **téléchargement PDF** au technicien — c'est volontaire ; le PDF serveur est réservé admin via `requireAdminApiRole`.)
- Logique de visibilité (règles) alignée PWA/mobile — voir note point C.

---

## Ordre d'exécution recommandé

1. **A** (mobile, faible effort) — débloque le lancement offline.
2. **B** (mobile, moyen) — stoppe la perte de photos.
3. **C** (backend `app` d'abord, puis mobile) — peut démarrer **en parallèle** dès maintenant.
4. **D** (mobile, élevé) — confort, en dernier.

> Parallélisation : **C** est cross-repo et n'a aucune dépendance avec A/B/D → le backend peut l'attaquer pendant que le mobile traite A puis B.
