diff --git a/CLAUDE.md b/CLAUDE.md index 473daa6..bb79741 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,5 +57,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file — ni audio ni géoloc en R4). - ✅ **R4 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R4.1 socle mobile** : `apps/mobile` (Expo SDK 57, TS strict), connexion + sélecteur démo ADR-002, tabbar (onglets futurs marqués), « Ma journée » triée priorité/échéance, cache TanStack persisté (lecture hors-ligne D1), jeton SecureStore, client typé du contrat, `CORS_ORIGINS` opt-in côté API (Expo web/debug seulement), 6 tests jest-expo + job CI `mobile`. Vérifié 10/10 en Expo web (connexion démo → Ma journée → hors-ligne/retour). - ✅ **R4.2 — terrain en ligne** : scanner QR réel (expo-camera, `analyseScan` testée, résolution locale D4, QR étrangers refusés), fiche ascenseur (D3, historique scopé), fiche OT (machine à états, coûts figés, garde par `closureBlockers` API), clôture terrain (bilan 6 champs au pouce), préventif → grille cochable (appui long = N/A, `aria-checked`). Écritures en ligne assumées (bandeaux) — la file est R4.3. Vérifié 12/12 en Expo web (dont clôture complète d'un OT de test), 12 tests jest-expo. -- 🔄 **Reprise ici** : R4.3 — file d'écriture persistée + verrou optimiste (D2) + écran Synchro & conflits + photos en file (D5) → recette « mode avion » sur téléphone (Expo Go). En parallèle : redéployer `release/r3` sur Dokploy et vérifier en ligne. +- ✅ **R4.3 — file & verrou** : verrou optimiste serveur (`baseUpdatedAt` → 409 contextualisé, toute écriture avance la version, web inchangé, ADR-003 sécurité/routage mobile) ; file persistée rejouée dans l'ordre (propagation de version intra-lot, arrêt sur conflit, patchs optimistes, photos D5 compressées en file), écran Synchro & conflits (rejouer sur version à jour / abandonner), préchargement parc+référentiels, purge complète à la déconnexion. Recette « mode avion » 13/13 en Expo web (conflit tranché par l'humain, serveur DONE + bilan), 17 tests jest-expo, 74 tests API. +- 🔄 **Reprise ici** : recette R4 sur téléphone (Expo Go, mode avion réel + scan caméra) avec le référent → tag `release/r4` → R5 IA (design d'abord). En parallèle : redéployer `release/r3` sur Dokploy et vérifier en ligne. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/api/src/work-orders/work-orders.service.ts b/apps/api/src/work-orders/work-orders.service.ts index 920b488..3549091 100644 --- a/apps/api/src/work-orders/work-orders.service.ts +++ b/apps/api/src/work-orders/work-orders.service.ts @@ -199,6 +199,7 @@ export class WorkOrdersService { user: AuthenticatedUser, ): Promise { const detail = await this.get(id, user); + await this.assertVersion(id, dto.baseUpdatedAt); if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) { throw new ConflictException( `Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`, @@ -245,6 +246,7 @@ export class WorkOrdersService { await this.prisma.workOrderEvent.create({ data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId }, }); + await this.toucher(id); return this.get(id, user); } @@ -284,6 +286,7 @@ export class WorkOrdersService { user: AuthenticatedUser, ): Promise { await this.get(id, user); + await this.assertVersion(id, dto.baseUpdatedAt); // Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active) for (const [colonne, field] of Object.entries(REPORT_FIELDS)) { const valeur = dto[colonne as keyof ReportUpsert]; @@ -310,6 +313,7 @@ export class WorkOrdersService { update: donnees, create: { workOrderId: id, ...donnees }, }); + await this.toucher(id); return this.get(id, user); } @@ -320,6 +324,7 @@ export class WorkOrdersService { user: AuthenticatedUser, ): Promise { await this.get(id, user); + await this.assertVersion(id, dto.baseUpdatedAt); const { count } = await this.prisma.checklistItem.updateMany({ where: { id: itemId, workOrderId: id }, data: { @@ -329,6 +334,7 @@ export class WorkOrdersService { }, }); if (count === 0) throw new NotFoundException('Tâche inconnue'); + await this.toucher(id); const item = await this.prisma.checklistItem.findUniqueOrThrow({ where: { id: itemId }, include: { doneBy: true }, @@ -368,6 +374,7 @@ export class WorkOrdersService { byId: user.userId, }, }); + await this.toucher(id); return this.get(id, user); } @@ -398,6 +405,7 @@ export class WorkOrdersService { note: dto.note, }, }); + await this.toucher(id); return this.get(id, user); } @@ -522,6 +530,43 @@ export class WorkOrdersService { }, allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus], closureBlockers: blockers, + updatedAt: row.updatedAt.toISOString(), }; } + + /** Verrou optimiste (D2 mobile) : si l'appelant fournit la version lue et + * que l'OT a changé depuis, 409 avec le contexte (qui, quand) — rien + * n'est écrasé en silence. Sans version fournie (web), comportement + * inchangé. */ + private async assertVersion(id: string, baseUpdatedAt?: string): Promise { + if (!baseUpdatedAt) return; + const wo = await this.prisma.workOrder.findUniqueOrThrow({ + where: { id }, + select: { + updatedAt: true, + events: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { by: true }, + }, + }, + }); + if (wo.updatedAt.getTime() > new Date(baseUpdatedAt).getTime()) { + const dernier = wo.events[0]; + const qui = dernier?.by?.displayName ?? 'quelqu’un'; + const quand = new Intl.DateTimeFormat('fr-FR', { + hour: '2-digit', + minute: '2-digit', + }).format(wo.updatedAt); + throw new ConflictException( + `Conflit de version : cet OT a été modifié par ${qui} à ${quand} — votre saisie n'a pas été appliquée.`, + ); + } + } + + /** Toute écriture « secondaire » (coche, bilan, commentaire, coûts) fait + * avancer la version de l'OT — sinon le verrou D2 ne verrait rien. */ + private async toucher(id: string): Promise { + await this.prisma.workOrder.update({ where: { id }, data: { updatedAt: new Date() } }); + } } diff --git a/apps/api/test/recherche.e2e-spec.ts b/apps/api/test/recherche.e2e-spec.ts index 3786708..7374926 100644 --- a/apps/api/test/recherche.e2e-spec.ts +++ b/apps/api/test/recherche.e2e-spec.ts @@ -139,3 +139,109 @@ describe('Recherche globale & corrections de recette R3 (e2e)', () => { .expect(400); }); }); + +describe('Verrou optimiste D2 (e2e) — la version protège les saisies mobiles', () => { + let app: INestApplication; + let salma: string; + let ahmed: string; + let otId: string; + const prisma = new PrismaClient(); + const http = () => request(app.getHttpServer()); + const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); + const suffix = `verrou-${Date.now().toString(36)}`; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule.forRoot()], + }).compile(); + app = moduleRef.createNestApplication(); + await app.init(); + const { body } = await http().get('/auth/demo-accounts'); + const login = async (roleName: string) => { + const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName); + return (await http().post('/auth/demo-login').send({ userId: compte.id })).body + .accessToken as string; + }; + salma = await login('Dispatcher'); + ahmed = await login('Technicien'); + const { body: assets } = await http().get('/assets').set(auth(salma)); + const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1'); + const technicien = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien'); + const cree = await http() + .post('/work-orders') + .set(auth(salma)) + .send({ + title: `Verrou E2E ${suffix}`, + type: 'CORRECTIVE', + assetId: a1.id, + assigneeIds: [technicien.id], + }) + .expect(201); + otId = cree.body.id; + }); + + afterAll(async () => { + await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } }); + await app?.close(); + await prisma.$disconnect(); + }); + + it('version à jour : la transition passe ; le détail expose updatedAt', async () => { + const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200); + expect(detail.body.updatedAt).toBeTruthy(); + await http() + .post(`/work-orders/${otId}/transition`) + .set(auth(ahmed)) + .send({ to: 'IN_PROGRESS', baseUpdatedAt: detail.body.updatedAt }) + .expect(200); + }); + + it('OT modifié entre-temps (commentaire) : 409 « Conflit de version » contextualisé', async () => { + const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200); + const versionLue = detail.body.updatedAt as string; + // Salma commente pendant qu'Ahmed est « hors-ligne » — la version avance + await http() + .post(`/work-orders/${otId}/comments`) + .set(auth(salma)) + .send({ message: 'Réassigné après appel du syndic' }) + .expect(201); + const refus = await http() + .post(`/work-orders/${otId}/transition`) + .set(auth(ahmed)) + .send({ to: 'ON_HOLD', baseUpdatedAt: versionLue }) + .expect(409); + expect(refus.body.message).toContain('Conflit de version'); + expect(refus.body.message).toContain('Salma'); + // L'OT n'a PAS bougé — rien d'écrasé en silence + const apres = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200); + expect(apres.body.status).toBe('IN_PROGRESS'); + }); + + it('le bilan et la coche font avancer la version (sinon le verrou est aveugle)', async () => { + const avant = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt; + const { body: refs } = await http().get('/reference-values').set(auth(ahmed)); + const porte = refs.referenceValues.find( + (v: { field: string; isActive: boolean }) => v.field === 'DOOR_STATE' && v.isActive, + ); + await http() + .put(`/work-orders/${otId}/report`) + .set(auth(ahmed)) + .send({ doorStateId: porte.id }) + .expect(200); + const apres = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt; + expect(new Date(apres).getTime()).toBeGreaterThan(new Date(avant).getTime()); + }); + + it('sans baseUpdatedAt (web) : comportement inchangé, même après modification', async () => { + await http() + .post(`/work-orders/${otId}/comments`) + .set(auth(salma)) + .send({ message: 'Nouvelle note' }) + .expect(201); + await http() + .post(`/work-orders/${otId}/transition`) + .set(auth(ahmed)) + .send({ to: 'ON_HOLD' }) + .expect(200); + }); +}); diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx index 30b88e7..d117a10 100644 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -1,5 +1,7 @@ import { Tabs } from 'expo-router'; import { Text, type ColorValue } from 'react-native'; +import { useAssets, useReferenceValues } from '@/api/exploitation'; +import { useFile } from '@/file/store'; import { useTokens } from '@/theme/tokens'; /** La tabbar de la maquette R4. Les onglets à venir restent visibles @@ -12,6 +14,12 @@ function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) { export default function CoquilleTabs() { const t = useTokens(); + const file = useFile(); + const conflits = file.some((s) => s.statut === 'CONFLIT'); + // D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan + // se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense. + useAssets(); + useReferenceValues(); return ( , + tabBarBadge: file.length || undefined, + tabBarBadgeStyle: { + backgroundColor: conflits ? t.prioBloque : t.stAttente, + color: '#fff', + fontFamily: 'Manrope_800ExtraBold', + fontSize: 10, + }, }} /> diff --git a/apps/mobile/app/(tabs)/synchro.tsx b/apps/mobile/app/(tabs)/synchro.tsx index 1df4419..3e39e47 100644 --- a/apps/mobile/app/(tabs)/synchro.tsx +++ b/apps/mobile/app/(tabs)/synchro.tsx @@ -1,11 +1,182 @@ -import { AVenir } from '@/composants/a-venir'; +import { useQueryClient } from '@tanstack/react-query'; +import { router } from 'expo-router'; +import { FlatList, Pressable, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useHorsLigne } from '@/auth/session'; +import { BoutonTel } from '@/composants/ui'; +import { useFile, type Saisie } from '@/file/store'; +import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro'; +import { useTokens } from '@/theme/tokens'; + +/** Écran 7 de la maquette R4 : la file VISIBLE et honnête. Rejeu dans + * l'ordre ; un conflit (verrou D2) arrête la file sur l'élément, montre le + * message de l'API (qui, quand) et VOUS tranchez — rien n'est écrasé ni + * perdu en silence. */ + +const ICONES: Record = { + TRANSITION: '🏁', + COCHE: '✓', + BILAN: '📋', + PHOTO: '🖼', +}; export default function PageSynchro() { + const t = useTokens(); + const horsLigne = useHorsLigne(); + const file = useFile(); + const queryClient = useQueryClient(); + + const conflit = file.find((s) => s.statut === 'CONFLIT'); + const heure = (iso: string) => + new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(new Date(iso)); + return ( - + + + + + Synchro + + + {conflit ? 'arrêtée sur le conflit' : file.length ? 'rejouée dans l’ordre' : ''} + + + + {conflit ? ( + + + ⚠ Conflit sur {conflit.otReference} + + + {conflit.erreur ?? 'L’OT a été modifié pendant que vous étiez hors-ligne.'} + + + Votre saisie (« {conflit.libelle} », {heure(conflit.creeA)}) est conservée telle + quelle dans la file — rien n’est perdu. + + router.push(`/ot/${conflit.otId}`)} + /> + void rejouerSurVersionAJour(conflit.id, queryClient)} + /> + void abandonnerSaisie(conflit.id, queryClient)} + /> + + ) : null} + + s.id} + contentContainerStyle={{ gap: 8, paddingBottom: 12 }} + ListEmptyComponent={ + + + Rien en attente + + + Toutes vos saisies sont sur le serveur. Les gestes faits hors-ligne apparaîtront + ici, rejoués dans l’ordre au retour du réseau. + + + } + renderItem={({ item: s }) => ( + + + {ICONES[s.type]} + + + + {s.libelle} + + + {s.otReference} · {heure(s.creeA)} + + + + {s.statut === 'CONFLIT' ? 'conflit' : s.statut === 'ENVOI' ? 'envoi…' : 'en attente'} + + + )} + /> + + {file.length && !horsLigne ? ( + void rejouer(queryClient)} + style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }} + > + + Rejouer la file maintenant + + + ) : null} + {horsLigne ? ( + + Hors-ligne — la file repartira seule au retour du réseau. + + ) : null} + + ); } diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 1953467..347cf5f 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -14,6 +14,8 @@ import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { useEffect, useState } from 'react'; import { HorsLigneProvider } from '@/auth/session'; +import { chargerFile } from '@/file/store'; +import { rejouer } from '@/file/synchro'; import { useTokens } from '@/theme/tokens'; /** Racine de l'app (D1 validée) : le cache TanStack est PERSISTÉ dans @@ -46,15 +48,16 @@ export default function RacineApp() { Manrope_800ExtraBold, }); - useEffect( - () => - NetInfo.addEventListener((etat) => { - const enLigne = !!etat.isConnected; - onlineManager.setOnline(enLigne); - setHorsLigne(!enLigne); - }), - [], - ); + useEffect(() => { + // D1 : la file survit au redémarrage — on la recharge avant tout. + void chargerFile().then(() => rejouer(queryClient)); + return NetInfo.addEventListener((etat) => { + const enLigne = !!etat.isConnected; + onlineManager.setOnline(enLigne); + setHorsLigne(!enLigne); + if (enLigne) void rejouer(queryClient); // le retour du réseau vide la file + }); + }, []); if (!polices) return null; diff --git a/apps/mobile/app/ot/[id]/cloture.tsx b/apps/mobile/app/ot/[id]/cloture.tsx index cd80541..edd4ad1 100644 --- a/apps/mobile/app/ot/[id]/cloture.tsx +++ b/apps/mobile/app/ot/[id]/cloture.tsx @@ -1,6 +1,6 @@ import { router, useLocalSearchParams } from 'expo-router'; import { useState } from 'react'; -import { Alert, ScrollView, Text, View } from 'react-native'; +import { ScrollView, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { BILAN_FIELD_LABELS, @@ -9,16 +9,27 @@ import { type BilanField, type ReportUpsert, } from '@siop/shared'; -import { useBilan, useReferenceValues, useTransition, useWorkOrder } from '@/api/exploitation'; +import { useQueryClient } from '@tanstack/react-query'; +import { useReferenceValues, useWorkOrder } from '@/api/exploitation'; import { useHorsLigne } from '@/auth/session'; import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui'; +import { enfilerBilan, enfilerTransition } from '@/file/actions'; import { useTokens } from '@/theme/tokens'; /** Écran 3 de la maquette R4 : le bilan codé R2 au pouce — 3 champs requis, - * garde visible et bloquante (les MESSAGES viennent de l'API : une seule - * source de vérité, comme le web). Clôture en ligne en R4.2. */ + * garde visible et bloquante. Depuis R4.3, bilan et clôture partent EN + * FILE (D1) : hors-ligne, l'OT passe localement à « Terminé (en file) » + * et le serveur tranchera au rejeu (verrou D2 si l'OT a bougé). */ -const CHAMP_VERS_ID: Record = { +type ChampBilanId = + | 'doorStateId' + | 'cabinPositionId' + | 'anomalyId' + | 'externalCauseId' + | 'actionTakenId' + | 'componentConcernedId'; + +const CHAMP_VERS_ID: Record = { DOOR_STATE: 'doorStateId', CABIN_POSITION: 'cabinPositionId', ANOMALY: 'anomalyId', @@ -41,8 +52,7 @@ export default function PageCloture() { const { id } = useLocalSearchParams<{ id: string }>(); const { data: ot } = useWorkOrder(id); const { data: valeurs } = useReferenceValues(); - const bilan = useBilan(id); - const transition = useTransition(id); + const queryClient = useQueryClient(); const [choix, setChoix] = useState>>({}); if (!ot) return null; @@ -61,27 +71,23 @@ export default function PageCloture() { return ot.report?.[CHAMP_VERS_VALEUR[champ]] ?? null; }; - const enregistrer = (apres?: () => void) => { + const enregistrer = () => { const corps: ReportUpsert = {}; + const labels: Parameters[3] = {}; for (const champ of BILAN_FIELDS) { - if (champ in choix) corps[CHAMP_VERS_ID[champ]] = choix[champ] ?? null; + if (champ in choix) { + corps[CHAMP_VERS_ID[champ]] = choix[champ] ?? null; + labels[CHAMP_VERS_ID[champ]] = valeurDe(champ); + } } - bilan.mutate(corps, { - onSuccess: apres, - onError: (e) => Alert.alert('Bilan refusé', e.message), - }); + enfilerBilan(queryClient, ot, corps, labels); }; - const cloturer = () => - enregistrer(() => - transition.mutate( - { to: 'DONE' }, - { - onSuccess: () => router.replace(`/ot/${id}`), - onError: (e) => Alert.alert('Clôture bloquée', e.message), - }, - ), - ); + const cloturer = () => { + enregistrer(); + enfilerTransition(queryClient, ot, 'DONE'); + router.replace(`/ot/${id}`); + }; const manquants = REQUIRED_BILAN_FIELDS.filter((c) => !valeurDe(c)); @@ -116,26 +122,29 @@ export default function PageCloture() { enregistrer()} + surAppui={() => { + enregistrer(); + router.replace(`/ot/${id}`); + }} /> 0 || bilan.isPending || transition.isPending} + desactive={manquants.length > 0} surAppui={cloturer} /> {horsLigne ? ( - Hors-ligne : la clôture en file arrive en R4.3 — pour l’instant, revenez au réseau. + Hors-ligne : la clôture est enregistrée sur le téléphone et sera rejouée telle quelle + au retour du réseau. Si l’OT a changé entre-temps, VOUS trancherez (écran Synchro). ) : null} diff --git a/apps/mobile/app/ot/[id]/grille.tsx b/apps/mobile/app/ot/[id]/grille.tsx index 667b04b..e903764 100644 --- a/apps/mobile/app/ot/[id]/grille.tsx +++ b/apps/mobile/app/ot/[id]/grille.tsx @@ -1,30 +1,38 @@ +import { useQueryClient } from '@tanstack/react-query'; import { useLocalSearchParams } from 'expo-router'; -import { Alert, Pressable, ScrollView, Text, View } from 'react-native'; +import { Pressable, ScrollView, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useCocheChecklist, useWorkOrder } from '@/api/exploitation'; +import { useWorkOrder } from '@/api/exploitation'; import { useHorsLigne } from '@/auth/session'; import { EnteteFiche } from '@/composants/ui'; +import { enfilerCoche } from '@/file/actions'; +import { useFile } from '@/file/store'; import { prochainEtat, progression } from '@/lib/checklist'; import { useTokens } from '@/theme/tokens'; /** Écran 6 de la maquette R4 : la grille au pouce. Appui simple = coche, - * appui long = non-applicable. En R4.2 chaque coche part immédiatement à - * l'API (hors-ligne : lecture seule — la file individuelle arrive en R4.3). */ + * appui long = non-applicable. Chaque coche part en file INDIVIDUELLEMENT + * (D1) : 7 coches faites = 7 coches sauvées, réseau ou pas. */ export default function PageGrille() { const t = useTokens(); const horsLigne = useHorsLigne(); const { id } = useLocalSearchParams<{ id: string }>(); const { data: ot } = useWorkOrder(id); - const coche = useCocheChecklist(id); + const queryClient = useQueryClient(); + const file = useFile(); if (!ot) return null; const { faits, total, pct } = progression(ot.checklist); + const enFile = new Set( + file.flatMap((s) => (s.otId === ot.id && s.type === 'COCHE' ? [s.payload.itemId] : [])), + ); - const basculer = (itemId: string, etat: Parameters[0], versNA = false) => { - if (horsLigne) return; - coche.mutate( - { itemId, state: versNA ? (etat === 'NA' ? 'PENDING' : 'NA') : prochainEtat(etat) }, - { onError: (e) => Alert.alert('Coche refusée', e.message) }, + const basculer = (item: { id: string; label: string }, etat: Parameters[0], versNA = false) => { + enfilerCoche( + queryClient, + ot, + item, + versNA ? (etat === 'NA' ? 'PENDING' : 'NA') : prochainEtat(etat), ); }; @@ -55,8 +63,7 @@ export default function PageGrille() { }} > - ⚠ Hors-ligne — les coches en file arrivent en R4.3 ; pour l’instant la grille est en - lecture. + ⚠ Hors-ligne — chaque coche part en file et se synchronisera au retour du réseau. ) : null} @@ -66,9 +73,8 @@ export default function PageGrille() { accessibilityRole="checkbox" aria-checked={item.state === 'DONE'} accessibilityLabel={item.label} - disabled={horsLigne || coche.isPending} - onPress={() => basculer(item.id, item.state)} - onLongPress={() => basculer(item.id, item.state, true)} + onPress={() => basculer(item, item.state)} + onLongPress={() => basculer(item, item.state, true)} style={{ flexDirection: 'row', alignItems: 'center', @@ -109,7 +115,21 @@ export default function PageGrille() { > {item.label} - {item.doneBy ? ( + {enFile.has(item.id) ? ( + + en file + + ) : item.doneBy ? ( {item.doneBy.displayName.split(' ')[0]} diff --git a/apps/mobile/app/ot/[id]/index.tsx b/apps/mobile/app/ot/[id]/index.tsx index c39e9bc..9233744 100644 --- a/apps/mobile/app/ot/[id]/index.tsx +++ b/apps/mobile/app/ot/[id]/index.tsx @@ -1,45 +1,70 @@ +import { useQueryClient } from '@tanstack/react-query'; import { router, useLocalSearchParams } from 'expo-router'; -import { Alert, ScrollView, Text, View } from 'react-native'; +import { ScrollView, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { WORK_ORDER_PRIORITY_LABELS, WORK_ORDER_TYPE_LABELS, } from '@siop/shared'; -import { useTransition, useWorkOrder } from '@/api/exploitation'; +import { useWorkOrder } from '@/api/exploitation'; import { useHorsLigne } from '@/auth/session'; import { BoutonTel, Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui'; +import { CartePhotos } from '@/composants/carte-photos'; +import { enfilerTransition } from '@/file/actions'; +import { useFile } from '@/file/store'; import { progression } from '@/lib/checklist'; import { useTokens } from '@/theme/tokens'; const fmt = new Intl.NumberFormat('fr-FR'); /** Écran 2 de la maquette R4 : la même machine à états que le web — UN - * bouton principal selon l'état, la garde de clôture visible. En R4.2 les - * écritures restent en ligne (la file arrive en R4.3 — assumé à l'écran). */ + * bouton principal selon l'état. Depuis R4.3, chaque geste part en file + * (D1) : l'écran montre l'état local tout de suite, la synchro suit. */ export default function PageFicheOT() { const t = useTokens(); const horsLigne = useHorsLigne(); const { id } = useLocalSearchParams<{ id: string }>(); const { data: ot } = useWorkOrder(id); - const transition = useTransition(id); + const queryClient = useQueryClient(); + const file = useFile(); if (!ot) return null; const grille = progression(ot.checklist); + const enFile = file.filter((s) => s.otId === ot.id); const peutDemarrer = ot.allowedTransitions.includes('IN_PROGRESS'); const peutCloturer = ot.allowedTransitions.includes('DONE'); const peutSuspendre = ot.allowedTransitions.includes('ON_HOLD'); - const demarrer = () => - transition.mutate( - { to: 'IN_PROGRESS' }, - { onError: (e) => Alert.alert('Transition refusée', e.message) }, - ); + const demarrer = () => enfilerTransition(queryClient, ot, 'IN_PROGRESS'); return ( - } /> + + {enFile.length ? ( + + {enFile.length} en file + + ) : null} + + + } + /> {ot.title} @@ -54,7 +79,7 @@ export default function PageFicheOT() { }} > - ⚠ Hors-ligne — lecture seule pour l’instant : les saisies hors réseau arrivent en R4.3 (file). + ⚠ Hors-ligne — vos gestes partent en file et se synchroniseront au retour du réseau. ) : null} @@ -129,7 +154,6 @@ export default function PageFicheOT() { {peutDemarrer ? ( ) : null} @@ -137,7 +161,6 @@ export default function PageFicheOT() { router.push(`/ot/${ot.id}/cloture`)} /> ) : null} @@ -145,16 +168,12 @@ export default function PageFicheOT() { - transition.mutate( - { to: 'ON_HOLD' }, - { onError: (e) => Alert.alert('Transition refusée', e.message) }, - ) - } + surAppui={() => enfilerTransition(queryClient, ot, 'ON_HOLD')} /> ) : null} + + {ot.events.slice(0, 5).map((e) => ( diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js index afa73f4..8919b5e 100644 --- a/apps/mobile/jest.config.js +++ b/apps/mobile/jest.config.js @@ -3,6 +3,7 @@ * recette sur appareil. */ module.exports = { preset: 'jest-expo', + setupFiles: ['./jest.setup.js'], moduleNameMapper: { '^@/(.*)$': '/src/$1', }, diff --git a/apps/mobile/jest.setup.js b/apps/mobile/jest.setup.js new file mode 100644 index 0000000..9602036 --- /dev/null +++ b/apps/mobile/jest.setup.js @@ -0,0 +1,4 @@ +/* AsyncStorage : mock officiel du paquet (pas de natif sous jest). */ +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock'), +); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e7530de..7431b97 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -14,6 +14,8 @@ "expo-camera": "~57.0.3", "expo-constants": "~57.0.5", "expo-font": "~57.0.1", + "expo-image-manipulator": "~57.0.4", + "expo-image-picker": "~57.0.4", "expo-linking": "~57.0.3", "expo-router": "~57.0.6", "expo-secure-store": "~57.0.1", diff --git a/apps/mobile/src/api/exploitation.ts b/apps/mobile/src/api/exploitation.ts index 5b7afc2..40ff306 100644 --- a/apps/mobile/src/api/exploitation.ts +++ b/apps/mobile/src/api/exploitation.ts @@ -34,6 +34,18 @@ export function useAsset(id: string) { }); } +export function useDocumentsOT(workOrderId: string) { + return useQuery({ + queryKey: ['documents', workOrderId], + queryFn: async () => + ( + await unwrap( + await api.GET('/documents', { params: { query: { workOrderId } } }), + ) + ).documents, + }); +} + export function useReferenceValues() { return useQuery({ queryKey: ['reference-values'], diff --git a/apps/mobile/src/api/schema.d.ts b/apps/mobile/src/api/schema.d.ts index 1758421..539c5d6 100644 --- a/apps/mobile/src/api/schema.d.ts +++ b/apps/mobile/src/api/schema.d.ts @@ -1776,6 +1776,8 @@ export interface components { }; allowedTransitions: ("OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED")[]; closureBlockers: string[]; + /** Format: date-time */ + updatedAt: string; }; ConsumePart: { /** Format: uuid */ @@ -1945,6 +1947,8 @@ export interface components { /** @enum {string} */ to: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED"; comment?: string; + /** Format: date-time */ + baseUpdatedAt?: string; }; CommentCreate: { message: string; @@ -1960,6 +1964,8 @@ export interface components { externalCauseId?: string | null; actionTakenId?: string | null; componentConcernedId?: string | null; + /** Format: date-time */ + baseUpdatedAt?: string; }; ChecklistItem: { /** Format: uuid */ @@ -1978,6 +1984,8 @@ export interface components { ChecklistPatch: { /** @enum {string} */ state: "PENDING" | "DONE" | "NA"; + /** Format: date-time */ + baseUpdatedAt?: string; }; RequestsResponse: { requests: { diff --git a/apps/mobile/src/auth/session.tsx b/apps/mobile/src/auth/session.tsx index ad852b8..bbaaeef 100644 --- a/apps/mobile/src/auth/session.tsx +++ b/apps/mobile/src/auth/session.tsx @@ -1,7 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createContext, useContext, type ReactNode } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { api, unwrap } from '@/api/client'; import { ecrireJeton, effacerJeton } from '@/api/jeton'; +import { viderFile } from '@/file/store'; /** Session mobile — mêmes règles que le web : le sélecteur démo n'existe * que si l'API répond (ADR-002 : 404 sinon, une seule source de vérité). */ @@ -57,8 +59,12 @@ export function useDemoLogin() { export function useLogout() { const queryClient = useQueryClient(); return async () => { + // Rien ne survit à la session sur l'appareil : jeton (trousseau), file + // d'écriture, cache OT persisté (loi 09-08 — minimisation). await effacerJeton(); + viderFile(); queryClient.clear(); + await AsyncStorage.removeItem('siop.cache'); }; } diff --git a/apps/mobile/src/composants/carte-photos.tsx b/apps/mobile/src/composants/carte-photos.tsx new file mode 100644 index 0000000..45ef92c --- /dev/null +++ b/apps/mobile/src/composants/carte-photos.tsx @@ -0,0 +1,98 @@ +import { useQueryClient } from '@tanstack/react-query'; +import * as ImageManipulator from 'expo-image-manipulator'; +import * as ImagePicker from 'expo-image-picker'; +import { Alert, Pressable, Text, View } from 'react-native'; +import type { WorkOrderDetail } from '@siop/shared'; +import { useDocumentsOT } from '@/api/exploitation'; +import { Carte } from '@/composants/ui'; +import { enfilerPhoto } from '@/file/actions'; +import { useFile } from '@/file/store'; +import { useTokens } from '@/theme/tokens'; + +/** Photos d'intervention (D5) : compressées côté app (~1 600 px) puis mises + * EN FILE comme le reste — rattachées à l'OT dans la bibliothèque R3. + * Ni audio ni géolocalisation en R4 (loi 09-08, minimisation). */ +export function CartePhotos({ ot }: { ot: WorkOrderDetail }) { + const t = useTokens(); + const queryClient = useQueryClient(); + const file = useFile(); + const { data: documents } = useDocumentsOT(ot.id); + + const photosServeur = (documents ?? []).filter((d) => d.contentType.startsWith('image/')); + const photosEnFile = file.filter((s) => s.otId === ot.id && s.type === 'PHOTO'); + + const prendre = async () => { + const resultat = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + quality: 0.9, + }); + const image = resultat.assets?.[0]; + if (!image) return; + try { + // Compression D5 : jamais un original de 12 Mo dans la file. + const contexte = ImageManipulator.ImageManipulator.manipulate(image.uri); + if (image.width > 1600) contexte.resize({ width: 1600 }); + const rendu = await contexte.renderAsync(); + const sauve = await rendu.saveAsync({ + compress: 0.7, + format: ImageManipulator.SaveFormat.JPEG, + }); + enfilerPhoto(queryClient, ot, { + uri: sauve.uri, + nom: `intervention-${ot.reference}-${Date.now().toString(36)}.jpg`, + mime: 'image/jpeg', + }); + } catch (e) { + Alert.alert('Photo impossible', e instanceof Error ? e.message : 'Erreur inconnue'); + } + }; + + return ( + + + {photosServeur.map((d) => ( + + 🖼 + + ))} + {photosEnFile.map((s) => ( + + 🖼 + + en file + + + ))} + void prendre()} + style={vignette('transparent', t.bordureForte, true)} + > + + + + + + ); +} + +const vignette = (fond: string, bordure: string, pointille = false) => + ({ + width: 56, + height: 56, + borderRadius: 9, + backgroundColor: fond, + borderWidth: 1.5, + borderColor: bordure, + borderStyle: pointille ? ('dashed' as const) : ('solid' as const), + alignItems: 'center' as const, + justifyContent: 'center' as const, + }) as const; diff --git a/apps/mobile/src/file/actions.ts b/apps/mobile/src/file/actions.ts new file mode 100644 index 0000000..4c3e227 --- /dev/null +++ b/apps/mobile/src/file/actions.ts @@ -0,0 +1,125 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { + WORK_ORDER_STATUS_LABELS, + WORK_ORDER_TRANSITIONS, + type ChecklistState, + type ReportUpsert, + type WorkOrderDetail, + type WorkOrderStatus, +} from '@siop/shared'; +import { enfiler, lireFile } from './store'; +import { rejouer } from './synchro'; + +/** Chaque geste terrain = une saisie en file + un patch OPTIMISTE du cache + * (le technicien voit son travail tout de suite, même en mode avion — le + * cache persisté garde ce patch après redémarrage). En ligne, la file se + * vide dans la foulée : le comportement R4.2 est conservé. */ + +function patchDetail( + queryClient: QueryClient, + otId: string, + patch: (ot: WorkOrderDetail) => WorkOrderDetail, +): void { + queryClient.setQueryData(['work-orders', otId], (courant) => + courant ? patch(courant) : courant, + ); +} + +export function enfilerTransition( + queryClient: QueryClient, + ot: WorkOrderDetail, + to: WorkOrderStatus, +): void { + enfiler({ + type: 'TRANSITION', + otId: ot.id, + otReference: ot.reference, + libelle: + to === 'DONE' ? 'Clôture de l’intervention' : `Passage à « ${WORK_ORDER_STATUS_LABELS[to]} »`, + baseUpdatedAt: ot.updatedAt, + payload: { to }, + }); + patchDetail(queryClient, ot.id, (c) => ({ + ...c, + status: to, + allowedTransitions: WORK_ORDER_TRANSITIONS[to], + })); + void rejouer(queryClient); +} + +export function enfilerCoche( + queryClient: QueryClient, + ot: WorkOrderDetail, + item: { id: string; label: string }, + state: ChecklistState, +): void { + enfiler({ + type: 'COCHE', + otId: ot.id, + otReference: ot.reference, + libelle: `Coche « ${item.label} » → ${state === 'DONE' ? 'fait' : state === 'NA' ? 'N/A' : 'à faire'}`, + baseUpdatedAt: ot.updatedAt, + payload: { itemId: item.id, state }, + }); + patchDetail(queryClient, ot.id, (c) => ({ + ...c, + checklist: c.checklist.map((i) => (i.id === item.id ? { ...i, state } : i)), + })); + void rejouer(queryClient); +} + +export function enfilerBilan( + queryClient: QueryClient, + ot: WorkOrderDetail, + corps: Omit, + labels: Partial>, +): void { + enfiler({ + type: 'BILAN', + otId: ot.id, + otReference: ot.reference, + libelle: 'Bilan d’intervention codé', + baseUpdatedAt: ot.updatedAt, + payload: corps, + }); + patchDetail(queryClient, ot.id, (c) => ({ + ...c, + report: { + note: c.report?.note ?? null, + doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null), + cabinPosition: + labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null), + anomaly: labels.anomalyId !== undefined ? (labels.anomalyId ?? null) : (c.report?.anomaly ?? null), + externalCause: + labels.externalCauseId !== undefined ? (labels.externalCauseId ?? null) : (c.report?.externalCause ?? null), + actionTaken: + labels.actionTakenId !== undefined ? (labels.actionTakenId ?? null) : (c.report?.actionTaken ?? null), + componentConcerned: + labels.componentConcernedId !== undefined + ? (labels.componentConcernedId ?? null) + : (c.report?.componentConcerned ?? null), + }, + })); + void rejouer(queryClient); +} + +export function enfilerPhoto( + queryClient: QueryClient, + ot: WorkOrderDetail, + fichier: { uri: string; nom: string; mime: string }, +): void { + enfiler({ + type: 'PHOTO', + otId: ot.id, + otReference: ot.reference, + libelle: `Photo — ${fichier.nom}`, + baseUpdatedAt: ot.updatedAt, + payload: fichier, + }); + void rejouer(queryClient); +} + +/** Ce que la file porte encore pour cet OT — chips « en file » des écrans. */ +export function saisiesPour(otId: string) { + return lireFile().filter((s) => s.otId === otId); +} diff --git a/apps/mobile/src/file/store.ts b/apps/mobile/src/file/store.ts new file mode 100644 index 0000000..2bf20cf --- /dev/null +++ b/apps/mobile/src/file/store.ts @@ -0,0 +1,93 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useSyncExternalStore } from 'react'; +import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared'; + +/** La file d'écriture (D1) : chaque geste hors-ligne devient une saisie + * datée, persistée dans AsyncStorage — elle survit au redémarrage — et + * rejouée DANS L'ORDRE au retour du réseau. Chaque saisie porte la version + * de l'OT lue au moment du geste (D2, verrou optimiste). */ + +export type Saisie = { + id: string; + creeA: string; // ISO — affiché à l'écran Synchro + otId: string; + otReference: string; + libelle: string; // « Coche “Jeu des coulisseaux” » + baseUpdatedAt: string; // version lue (D2) + statut: 'EN_ATTENTE' | 'ENVOI' | 'CONFLIT'; + erreur?: string; // message du 409 / refus métier — montré tel quel +} & ( + | { type: 'TRANSITION'; payload: { to: WorkOrderStatus } } + | { type: 'COCHE'; payload: { itemId: string; state: ChecklistState } } + | { type: 'BILAN'; payload: Omit } + | { type: 'PHOTO'; payload: { uri: string; nom: string; mime: string } } +); + +const CLE = 'siop.file.v1'; +let file: Saisie[] = []; +const abonnes = new Set<() => void>(); + +function notifier(): void { + for (const a of abonnes) a(); + void AsyncStorage.setItem(CLE, JSON.stringify(file)); +} + +/** À l'ouverture de l'app : la file survit au redémarrage (D1). */ +export async function chargerFile(): Promise { + const brut = await AsyncStorage.getItem(CLE); + if (brut) { + // Un ENVOI interrompu par un crash redevient EN_ATTENTE (rejouable). + file = (JSON.parse(brut) as Saisie[]).map((s) => + s.statut === 'ENVOI' ? { ...s, statut: 'EN_ATTENTE' } : s, + ); + for (const a of abonnes) a(); + } +} + +export const lireFile = (): Saisie[] => file; + +export function enfiler(saisie: Omit): Saisie { + const complete = { + ...saisie, + id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + creeA: new Date().toISOString(), + statut: 'EN_ATTENTE', + } as Saisie; + file = [...file, complete]; + notifier(); + return complete; +} + +export function majSaisie(id: string, patch: Partial>): void { + file = file.map((s) => (s.id === id ? ({ ...s, ...patch } as Saisie) : s)); + notifier(); +} + +export function retirer(id: string): void { + file = file.filter((s) => s.id !== id); + notifier(); +} + +export function useFile(): Saisie[] { + return useSyncExternalStore( + (cb) => { + abonnes.add(cb); + return () => abonnes.delete(cb); + }, + lireFile, + lireFile, + ); +} + +/** Déconnexion : la file appartient à la SESSION — on la purge (sécurité : + * un autre compte sur le même téléphone ne doit jamais rejouer les saisies + * du précédent), avec sa persistance. */ +export function viderFile(): void { + file = []; + notifier(); +} + +/** Réservé aux tests : repartir d'une file vide (sans persistance). */ +export function _viderPourTests(): void { + file = []; +} diff --git a/apps/mobile/src/file/synchro.test.ts b/apps/mobile/src/file/synchro.test.ts new file mode 100644 index 0000000..bbad708 --- /dev/null +++ b/apps/mobile/src/file/synchro.test.ts @@ -0,0 +1,93 @@ +import { QueryClient } from '@tanstack/react-query'; +import { enfiler, lireFile, retirer, _viderPourTests, type Saisie } from './store'; +import { rejouer, type ResultatEnvoi } from './synchro'; + +/** Le contrat D1/D2 de la file, testé sans réseau : rejeu DANS L'ORDRE, + * arrêt sur conflit (l'humain tranche), reprise après coupure. */ + +const qc = new QueryClient(); + +const saisie = (libelle: string): Saisie => + enfiler({ + type: 'TRANSITION', + otId: 'ot-1', + otReference: 'OT-TEST', + libelle, + baseUpdatedAt: '2026-07-17T10:00:00.000Z', + payload: { to: 'IN_PROGRESS' }, + }) && lireFile().at(-1)!; + +beforeEach(() => _viderPourTests()); + +describe('rejouer (file D1/D2)', () => { + it('rejoue dans l’ordre et vide la file quand tout passe', async () => { + saisie('a'); + saisie('b'); + const envoyes: string[] = []; + await rejouer(qc, async (s) => { + envoyes.push(s.libelle); + return { ok: true }; + }); + expect(envoyes).toEqual(['a', 'b']); + expect(lireFile()).toHaveLength(0); + }); + + it('conflit : la saisie passe en CONFLIT et la file S’ARRÊTE là — rien n’est perdu', async () => { + saisie('a'); + saisie('b'); + saisie('c'); + const envoyer = async (s: Saisie): Promise => + s.libelle === 'b' + ? { ok: false, reseau: false, message: 'Conflit de version : modifié par Salma à 14 h 38' } + : { ok: true }; + await rejouer(qc, envoyer); + const file = lireFile(); + expect(file.map((s) => [s.libelle, s.statut])).toEqual([ + ['b', 'CONFLIT'], + ['c', 'EN_ATTENTE'], // derrière le conflit : on attend l'humain + ]); + expect(file[0]!.erreur).toContain('Salma'); + }); + + it('coupure réseau : tout reste EN_ATTENTE, rejouable au retour', async () => { + saisie('a'); + saisie('b'); + await rejouer(qc, async () => ({ ok: false, reseau: true, message: 'Réseau indisponible' })); + expect(lireFile().map((s) => s.statut)).toEqual(['EN_ATTENTE', 'EN_ATTENTE']); + // le réseau revient + await rejouer(qc, async () => ({ ok: true })); + expect(lireFile()).toHaveLength(0); + }); + + it('après « abandonner » le conflit, le reste de la file repart', async () => { + saisie('a'); + saisie('b'); + await rejouer(qc, async (s) => + s.libelle === 'a' ? { ok: false, reseau: false, message: 'refus métier' } : { ok: true }, + ); + expect(lireFile()).toHaveLength(2); + retirer(lireFile()[0]!.id); // l'humain abandonne la saisie en conflit + await rejouer(qc, async () => ({ ok: true })); + expect(lireFile()).toHaveLength(0); + }); +}); + +describe('propagation de version (nos écritures ne se conflictent pas entre elles)', () => { + it('au succès, la version fraîche se propage aux saisies restantes du même OT', async () => { + saisie('a'); + saisie('b'); + await rejouer(qc, async (s) => + s.libelle === 'a' ? { ok: true, nouvelleVersion: '2026-07-17T11:00:00.000Z' } : { ok: true }, + ); + // b a été envoyée après propagation — vérifions via un rejeu espion + _viderPourTests(); + saisie('c'); + saisie('d'); + const basesVues: string[] = []; + await rejouer(qc, async (s) => { + basesVues.push(s.baseUpdatedAt); + return { ok: true, nouvelleVersion: 'V-FRAICHE' }; + }); + expect(basesVues).toEqual(['2026-07-17T10:00:00.000Z', 'V-FRAICHE']); + }); +}); diff --git a/apps/mobile/src/file/synchro.ts b/apps/mobile/src/file/synchro.ts new file mode 100644 index 0000000..1202e9a --- /dev/null +++ b/apps/mobile/src/file/synchro.ts @@ -0,0 +1,160 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { api } from '@/api/client'; +import { lireJeton } from '@/api/jeton'; +import { API_URL } from '@/api/client'; +import { lireFile, majSaisie, retirer, type Saisie } from './store'; + +/** Rejeu de la file (D1/D2) : dans l'ordre, une saisie à la fois. + * - succès → la saisie sort de la file ; + * - coupure réseau → tout reste EN_ATTENTE, on réessaiera ; + * - refus (409 verrou, garde métier) → la saisie passe en CONFLIT et la + * file S'ARRÊTE LÀ : l'humain tranche à l'écran Synchro. */ + +export type ResultatEnvoi = + | { ok: true; nouvelleVersion?: string } + | { ok: false; reseau: boolean; message: string }; + +export async function envoyerSaisie(s: Saisie): Promise { + try { + let status: number; + let message = ''; + let nouvelleVersion: string | undefined; + if (s.type === 'TRANSITION') { + const res = await api.POST('/work-orders/{id}/transition', { + params: { path: { id: s.otId } }, + body: { to: s.payload.to, baseUpdatedAt: s.baseUpdatedAt }, + }); + status = res.response.status; + message = messageDe(res.error); + nouvelleVersion = res.data?.updatedAt; + } else if (s.type === 'COCHE') { + const res = await api.PATCH('/work-orders/{id}/checklist/{itemId}', { + params: { path: { id: s.otId, itemId: s.payload.itemId } }, + body: { state: s.payload.state, baseUpdatedAt: s.baseUpdatedAt }, + }); + status = res.response.status; + message = messageDe(res.error); + } else if (s.type === 'BILAN') { + const res = await api.PUT('/work-orders/{id}/report', { + params: { path: { id: s.otId } }, + body: { ...s.payload, baseUpdatedAt: s.baseUpdatedAt }, + }); + status = res.response.status; + message = messageDe(res.error); + nouvelleVersion = res.data?.updatedAt; + } else { + // PHOTO — multipart hors client typé (D5) : fichier compressé en file. + const form = new FormData(); + form.append('kind', 'PHOTO'); + form.append('workOrderId', s.otId); + if (s.payload.uri.startsWith('http') || s.payload.uri.startsWith('blob:') || s.payload.uri.startsWith('data:')) { + const blob = await (await fetch(s.payload.uri)).blob(); + form.append('file', new File([blob], s.payload.nom, { type: s.payload.mime })); + } else { + // URI de fichier natif : React Native sait téléverser {uri, name, type} + form.append('file', { + uri: s.payload.uri, + name: s.payload.nom, + type: s.payload.mime, + } as unknown as Blob); + } + const jeton = await lireJeton(); + const res = await fetch(`${API_URL}/documents`, { + method: 'POST', + headers: jeton ? { Authorization: `Bearer ${jeton}` } : undefined, + body: form, + }); + status = res.status; + if (!res.ok) { + const corps = (await res.json().catch(() => null)) as { message?: string } | null; + message = corps?.message ?? `Téléversement refusé (${res.status})`; + } + } + if (status < 400) { + // Coche/photo ne renvoient pas le détail : on relit la version pour + // que les saisies suivantes du lot ne se heurtent pas à NOS écritures. + if (!nouvelleVersion) { + const frais = await api.GET('/work-orders/{id}', { params: { path: { id: s.otId } } }); + nouvelleVersion = frais.data?.updatedAt; + } + return { ok: true, nouvelleVersion }; + } + return { ok: false, reseau: false, message: message || `Refus (${status})` }; + } catch { + return { ok: false, reseau: true, message: 'Réseau indisponible' }; + } +} + +function messageDe(erreur: unknown): string { + return erreur && typeof erreur === 'object' && 'message' in erreur + ? String((erreur as { message: unknown }).message) + : ''; +} + +let enCours = false; + +export async function rejouer( + queryClient: QueryClient, + envoyer: (s: Saisie) => Promise = envoyerSaisie, +): Promise { + if (enCours) return; + enCours = true; + try { + for (;;) { + const suivante = lireFile().find((s) => s.statut === 'EN_ATTENTE'); + if (!suivante) break; + // Un conflit plus ancien barre la route : l'ordre est la promesse D1. + const conflitAvant = lireFile().some( + (s) => s.statut === 'CONFLIT' && s.creeA <= suivante.creeA, + ); + if (conflitAvant) break; + + majSaisie(suivante.id, { statut: 'ENVOI' }); + const resultat = await envoyer(suivante); + if (resultat.ok) { + retirer(suivante.id); + // Notre propre écriture a fait avancer la version : les saisies + // restantes du même OT repartent de là (un écart ÉTRANGER ultérieur + // restera détecté par le verrou). + if (resultat.nouvelleVersion) { + for (const s of lireFile()) { + if (s.otId === suivante.otId && s.statut === 'EN_ATTENTE') { + majSaisie(s.id, { baseUpdatedAt: resultat.nouvelleVersion }); + } + } + } + await queryClient.invalidateQueries({ queryKey: ['work-orders'] }); + await queryClient.invalidateQueries({ queryKey: ['documents'] }); + } else if (resultat.reseau) { + majSaisie(suivante.id, { statut: 'EN_ATTENTE' }); + break; // le réseau reviendra — rien n'est perdu + } else { + majSaisie(suivante.id, { statut: 'CONFLIT', erreur: resultat.message }); + break; // l'humain tranche (D2) + } + } + } finally { + enCours = false; + } +} + +/** Résolutions du conflit (maquette écran 7). */ +export async function rejouerSurVersionAJour( + saisieId: string, + queryClient: QueryClient, +): Promise { + const saisie = lireFile().find((s) => s.id === saisieId); + if (!saisie) return; + const frais = await api.GET('/work-orders/{id}', { params: { path: { id: saisie.otId } } }); + if (frais.data) { + majSaisie(saisieId, { statut: 'EN_ATTENTE', baseUpdatedAt: frais.data.updatedAt, erreur: undefined }); + await rejouer(queryClient); + } +} + +export async function abandonnerSaisie(saisieId: string, queryClient: QueryClient): Promise { + retirer(saisieId); + // On recharge la vérité serveur : le patch optimiste local est annulé. + await queryClient.invalidateQueries({ queryKey: ['work-orders'] }); + await rejouer(queryClient); +} diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index 1758421..539c5d6 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -1776,6 +1776,8 @@ export interface components { }; allowedTransitions: ("OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED")[]; closureBlockers: string[]; + /** Format: date-time */ + updatedAt: string; }; ConsumePart: { /** Format: uuid */ @@ -1945,6 +1947,8 @@ export interface components { /** @enum {string} */ to: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED"; comment?: string; + /** Format: date-time */ + baseUpdatedAt?: string; }; CommentCreate: { message: string; @@ -1960,6 +1964,8 @@ export interface components { externalCauseId?: string | null; actionTakenId?: string | null; componentConcernedId?: string | null; + /** Format: date-time */ + baseUpdatedAt?: string; }; ChecklistItem: { /** Format: uuid */ @@ -1978,6 +1984,8 @@ export interface components { ChecklistPatch: { /** @enum {string} */ state: "PENDING" | "DONE" | "NA"; + /** Format: date-time */ + baseUpdatedAt?: string; }; RequestsResponse: { requests: { diff --git a/docs/03-architecture/adr/ADR-003-securite-routage-mobile.md b/docs/03-architecture/adr/ADR-003-securite-routage-mobile.md new file mode 100644 index 0000000..6d04c75 --- /dev/null +++ b/docs/03-architecture/adr/ADR-003-securite-routage-mobile.md @@ -0,0 +1,53 @@ +# ADR-003 — Sécurité et protocole de routage de l'app mobile (R4) + +- **Statut** : acceptée (R4.3, 17 juillet 2026) +- **Contexte** : l'app technicien est offline-first (D1) — elle garde des données métier + et des saisies en attente **sur l'appareil**. Il faut dire précisément ce qui vit où, + ce qui le protège, et comment on navigue (deep links compris). Demande du référent + pendant la construction de R4.3. + +## Sécurité — ce qui vit sur le téléphone, et ce qui le protège + +| Donnée | Où | Protection | Purge | +|---|---|---|---| +| Jeton JWT | **SecureStore** (trousseau iOS / Keystore Android) | chiffré par l'OS, jamais dans AsyncStorage | à la déconnexion | +| Cache de lecture (OT, parc, référentiels) | AsyncStorage (`siop.cache`, persister TanStack, 7 j max) | sandbox applicative de l'OS | à la déconnexion (loi 09-08 — minimisation) | +| File d'écriture (D1) | AsyncStorage (`siop.file.v1`) | sandbox applicative | à la déconnexion — **jamais** un compte ne rejoue les saisies d'un autre | +| Photos en attente (D5) | fichier compressé (~1 600 px) référencé par la file | sandbox applicative | sortent de la file à l'envoi | + +Règles : + +1. **L'API reste la seule autorité** : guard JWT global fermé par défaut, matrice de + permissions, invariant « voir autre », garde de clôture, verrou optimiste (D2 — + `baseUpdatedAt`, 409 contextualisé). Le mobile n'a AUCUNE logique de permission à lui : + ce qu'il affiche vient d'une réponse déjà filtrée ; ce qu'il écrit est revalidé serveur. +2. Sur **web** (vérifications de dev seulement), SecureStore n'existe pas : repli + AsyncStorage assumé. CORS est **opt-in** (`CORS_ORIGINS`, vide par défaut) — les apps + natives n'envoient pas d'Origin, le web de prod reste derrière son proxy même-origine. +3. **Ni géolocalisation ni audio en R4** (décision D5, loi 09-08) ; la caméra ne sert + qu'à lire les étiquettes du parc (scan local, D4) et aux photos d'intervention. +4. La déconnexion purge **tout** ce qui précède (`useLogout`) ; la file survit au + redémarrage mais pas au changement de session. + +## Protocole de routage (expo-router) + +- **Arborescence** : `/connexion` (publique) ; `/` = aiguillage de session ; + `(tabs)/journee|scanner|preventif|synchro` (coquille tabbar) ; fiches empilées + `/ascenseur/[id]`, `/ot/[id]`, `/ot/[id]/cloture`, `/ot/[id]/grille`. +- **Garde de session** : `/` redirige vers `/connexion` sans session, vers + `(tabs)/journee` sinon. Les écrans profonds ne « fuient » rien sans jeton : + leurs requêtes reçoivent 401 et l'écran reste vide — la donnée est protégée par + l'API, pas par l'obscurité du routage. +- **Deep links** : scheme **`siop://`** (app.json). Le scan QR n'ouvre PAS l'URL du + portail : il en **extrait la référence** (`analyseScan`) et résout localement + (D4) — un QR étranger est refusé avec un message, jamais suivi aveuglément. +- **Hors-ligne** : la navigation reste entière (cache + file) ; seul le contenu + jamais synchronisé affiche son état honnêtement. + +## Conséquences + +- Recette sécurité mobile : changer de compte démo sur le même appareil → file vide, + cache vide, aucune trace de la session précédente. +- À durcir plus tard si exigence client : chiffrement applicatif de la file + (`expo-secure-store` ne convient pas aux volumes ; envisager SQLCipher/MMKV chiffré), + et épinglage TLS. diff --git a/docs/journal/journal.md b/docs/journal/journal.md index c898a77..f4ec360 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,25 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-07-17 — Pr. Daaif (+ Claude) — R4.3 : file d'écriture, verrou optimiste, Synchro & conflits + +**Actions** + +- **Verrou optimiste côté serveur (D2)** : `updatedAt` exposé au détail OT ; `baseUpdatedAt` optionnel sur transition/coche/bilan → **409 « Conflit de version » contextualisé** (qui, quand — depuis le dernier événement). Toute écriture « secondaire » (coche, bilan, commentaire, conso, MO) fait désormais avancer la version, sinon le verrou serait aveugle. Sans version fournie, comportement web inchangé. 4 tests e2e dédiés (74 au total). +- **File d'écriture mobile (D1)** : store persisté AsyncStorage (survit au redémarrage, un ENVOI interrompu redevient rejouable), rejeu **dans l'ordre** ; succès → sortie de file **et propagation de la version fraîche** aux saisies restantes du même OT (nos propres écritures ne se conflictent pas entre elles — un écart étranger reste détecté) ; coupure → tout reste en attente ; refus → CONFLIT et la file s'arrête là. Transitions, coches, bilan et **photos (D5** : compressées ~1 600 px, expo-image-picker/manipulator**)** passent par la file, avec patch optimiste du cache (l'OT affiche « Terminé » localement, chips « en file » partout). +- **Écran Synchro & conflits** (écran 7) : file visible et horodatée, badge tabbar (ambre → rouge si conflit), carte de conflit avec le message de l'API et les trois choix — voir l'OT, **rejouer sur la version à jour**, abandonner. Préchargement du parc et des référentiels à l'entrée (trou D1 débusqué par la vérif : le bilan hors-ligne n'avait pas ses vocabulaires). +- **Sécurité & routage (demande du référent) : ADR-003** — qui vit où sur l'appareil et ce qui le protège (jeton en trousseau, cache/file en sandbox, purge complète à la déconnexion), l'API seule autorité, deep links `siop://` jamais suivis aveuglément (le scan extrait et résout localement). **Correctif réel au passage : la file et le cache persisté n'étaient pas purgés au logout** — un autre compte sur le même téléphone aurait pu rejouer les saisies du précédent. +- **Recette « mode avion » rejouée en Expo web piloté : 13/13** — OT ouvert, avion, Démarrer + bilan + clôture hors-ligne (état local immédiat, 3 en file), Salma modifie l'OT pendant ce temps, retour réseau → rejeu auto → **conflit tranché par l'humain** → file vide → serveur : Terminé avec bilan. 17 tests jest-expo (5 sur la file), zéro erreur console inattendue. + +**Décisions** + +- La recette officielle « mode avion en sous-sol » reste à rejouer **sur téléphone** (Expo Go) avec le référent — le web a validé toute la mécanique, pas les capteurs ni le vrai mode avion. +- Durcissements notés à l'ADR-003 : chiffrement applicatif de la file et épinglage TLS si exigence client. + +**Prochaine étape** : recette R4 sur appareil avec le référent → tag `release/r4` → R5 IA (design d'abord). Redéploiement Dokploy de `release/r3` toujours en attente côté serveur. + +--- + ## 2026-07-17 — Pr. Daaif (+ Claude) — R4.2 : scan QR, fiches terrain, grille cochable **Actions** diff --git a/docs/openapi.json b/docs/openapi.json index 3ac7719..990b7a8 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -6698,6 +6698,11 @@ "items": { "type": "string" } + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" } }, "required": [ @@ -6725,7 +6730,8 @@ "report", "costs", "allowedTransitions", - "closureBlockers" + "closureBlockers", + "updatedAt" ], "additionalProperties": false }, @@ -7528,6 +7534,11 @@ "comment": { "type": "string", "maxLength": 500 + }, + "baseUpdatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" } }, "required": [ @@ -7655,6 +7666,11 @@ "type": "null" } ] + }, + "baseUpdatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" } }, "additionalProperties": false @@ -7741,6 +7757,11 @@ "DONE", "NA" ] + }, + "baseUpdatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" } }, "required": [ diff --git a/packages/shared/src/schemas/exploitation.ts b/packages/shared/src/schemas/exploitation.ts index bdf7aea..73d0657 100644 --- a/packages/shared/src/schemas/exploitation.ts +++ b/packages/shared/src/schemas/exploitation.ts @@ -87,6 +87,9 @@ export const WorkOrderDetailSchema = WorkOrderSummarySchema.extend({ allowedTransitions: z.array(z.enum(WORK_ORDER_STATUSES)), /** Ce qui bloque la clôture (vide = clôturable) — messages métier. */ closureBlockers: z.array(z.string()), + /** Version pour le verrou optimiste (D2 mobile) : la file d'écriture la + * capture à la lecture et la renvoie avec chaque saisie. */ + updatedAt: z.iso.datetime(), }); export type WorkOrderDetail = z.infer; @@ -104,6 +107,8 @@ export type WorkOrderCreate = z.infer; export const TransitionRequestSchema = z.object({ to: z.enum(WORK_ORDER_STATUSES), comment: z.string().max(500).optional(), + /** Verrou optimiste (D2) : updatedAt lu — 409 si l'OT a changé depuis. */ + baseUpdatedAt: z.iso.datetime().optional(), }); export type TransitionRequest = z.infer; @@ -126,11 +131,13 @@ export const ReportUpsertSchema = z.object({ externalCauseId: z.uuid().nullable().optional(), actionTakenId: z.uuid().nullable().optional(), componentConcernedId: z.uuid().nullable().optional(), + baseUpdatedAt: z.iso.datetime().optional(), // verrou optimiste (D2) }); export type ReportUpsert = z.infer; export const ChecklistPatchSchema = z.object({ state: z.enum(CHECKLIST_STATES), + baseUpdatedAt: z.iso.datetime().optional(), // verrou optimiste (D2) }); export type ChecklistPatch = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f13ce5..a07424f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,12 @@ importers: expo-font: specifier: ~57.0.1 version: 57.0.1(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + expo-image-manipulator: + specifier: ~57.0.4 + version: 57.0.4(expo@57.0.6) + expo-image-picker: + specifier: ~57.0.4 + version: 57.0.4(expo@57.0.6) expo-linking: specifier: ~57.0.3 version: 57.0.3(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) @@ -3847,6 +3853,21 @@ packages: react: '*' react-native: '*' + expo-image-loader@57.0.1: + resolution: {integrity: sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==} + peerDependencies: + expo: '*' + + expo-image-manipulator@57.0.4: + resolution: {integrity: sha512-tbFaW4UEcBxp9wlHD8SawHBb2ecg9lTkNxY1GbLtSBnXxscP5aVpJe2K5sWFPB1xEnYO1zBpfXEnfaHfG+aimw==} + peerDependencies: + expo: '*' + + expo-image-picker@57.0.4: + resolution: {integrity: sha512-ypuO99+rMqrjYlTt8EqJ415ff+qIj/gPyEbcOW8g6CFtC6Ljk8OWYyiEFKZg3989Rdljla/fN3IWqXOXKN/2og==} + peerDependencies: + expo: '*' + expo-keep-awake@57.0.1: resolution: {integrity: sha512-28lkFImeXTS+bhAjuCFV7w7tW5bXg27BJVrxv+nC/nyYa86qEa0oFeHwqol6ha5k4pdVDQgBF09GM4A1k76Ssg==} peerDependencies: @@ -10867,6 +10888,20 @@ snapshots: react: 19.2.3 react-native: 0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2) + expo-image-loader@57.0.1(expo@57.0.6): + dependencies: + expo: 57.0.6(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.5)(expo-router@57.0.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3) + + expo-image-manipulator@57.0.4(expo@57.0.6): + dependencies: + expo: 57.0.6(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.5)(expo-router@57.0.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3) + expo-image-loader: 57.0.1(expo@57.0.6) + + expo-image-picker@57.0.4(expo@57.0.6): + dependencies: + expo: 57.0.6(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.5)(expo-router@57.0.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3) + expo-image-loader: 57.0.1(expo@57.0.6) + expo-keep-awake@57.0.1(expo@57.0.6)(react@19.2.3): dependencies: expo: 57.0.6(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.5)(expo-router@57.0.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3)