191 lines
13 KiB
Markdown
191 lines
13 KiB
Markdown
# Architecture Firestore Haptika
|
||
|
||
État vérifié le 8 septembre 2026 : accès CLI au projet `haptika-d13d3`
|
||
(numéro `167940133580`), base `(default)` Firestore Native en `eur3`.
|
||
Mise en place engagée : application iOS enregistrée, SDK natifs Auth/Firestore intégrés
|
||
dans Expo, règles et index déployés. Le périmètre livré et les étapes restantes
|
||
sont décrits dans `firebase/README.md`. Les sections ci-dessous restent la cible
|
||
d'architecture ; elles ne signifient pas que tous les capteurs et calculs sont branchés.
|
||
|
||
## Organisation retenue
|
||
|
||
Deux applications, un même compte Firebase Authentication et un historique commun.
|
||
L'UID Firebase est la frontière d'accès ; ni l'adresse e-mail ni l'identifiant
|
||
matériel ne servent de clé utilisateur. La Watch passe par le compagnon iPhone
|
||
avec WatchConnectivity ; son fonctionnement et ses vibrations restent locaux.
|
||
L'intégration WatchConnectivity dans le nouveau projet Expo reste à construire.
|
||
|
||
```text
|
||
users/{uid} profil et état du compte
|
||
onboarding/current réponses et étape d'onboarding
|
||
preferences/general préférences communes
|
||
practiceSettings/{practiceId} réglages respiration, ancrage, méditation…
|
||
devices/{installationId} installations et état de synchronisation
|
||
measurements/{measurementId} résumé d'une acquisition
|
||
measurementAnnotations/{measurementId} ressenti et contexte déclaratif
|
||
sessions/{sessionId} séance et références avant/après
|
||
reminders/{reminderId} intention de rappel/réveil
|
||
dailyStats/{dayKey} agrégats reconstruisibles, serveur
|
||
baselines/{baselineKey} référence personnelle versionnée, serveur
|
||
entitlements/current abonnement vérifié, serveur
|
||
tombstones/{entityKey} suppressions anti-résurrection, serveur
|
||
|
||
protocols/{protocolId}/versions/{version} paramètres publiés, serveur
|
||
```
|
||
|
||
Les sous-collections sont créées à la première écriture ; pas de documents vides
|
||
pour simuler des tables. Une mesure ou une séance est un document indépendant.
|
||
Aucun tableau d'historique croissant dans `users/{uid}`.
|
||
|
||
## Contrat des documents
|
||
|
||
Tous les documents persistés portent `schemaVersion: 1`. Les instants sont des
|
||
Firestore Timestamp UTC ; `createdAt` et `updatedAt` sont des timestamps serveur.
|
||
`startedAt`, `endedAt`, `recordedAt` décrivent le moment sur l'appareil et sont
|
||
validés indépendamment. Les IDs d'événements sont des UUID aléatoires créés hors
|
||
ligne puis réutilisés à chaque tentative. Les durées sont en secondes, le rythme
|
||
cardiaque en bpm et les intervalles en millisecondes. Une valeur indisponible est
|
||
`null`, jamais un zéro inventé.
|
||
|
||
| Document | Champs métier principaux |
|
||
| --- | --- |
|
||
| Profil | `firstName`, `birthYear` nullable, `gender` nullable, `locale`, `timeZone` IANA. `accountState` (`active`, `deleting`) est contrôlé par le serveur. E-mail et fournisseurs de connexion restent dans Auth. |
|
||
| Onboarding | `stress`, `sleep`, `energy` (`low`, `medium`, `high`), `experience` (`phone`, `watch`), `completedAt`, `flowVersion`. L'étape courante utilise une clé métier stable. |
|
||
| Préférences | Préférences de notification et de sauvegarde Santé. Une préférence ne prouve jamais une autorisation iOS ; les permissions sont relues sur l'appareil. |
|
||
| Réglages de pratique | `durationSeconds`, paramètres de respiration, intensité, son et écran éteint selon la pratique. Schéma fermé différent par pratique ; pas de dictionnaire arbitraire contenant tous les IDs Figma. |
|
||
| Installation | `platform`, `appVersion`, `pairedInstallationId` nullable, `lastSeenAt`, `lastSyncedAt`. Identifiant aléatoire d'installation, pas de numéro de série. L'état « connecté » vient de WatchConnectivity, pas de Firestore. |
|
||
| Mesure | `source`, `deviceId`, `startedAt`, `endedAt`, `durationSeconds`, `averageBpm`, `minimumBpm`, `maximumBpm`, `estimatedRmssdMs`, `sdnnMs`, `quality`, `coverage`, `valid`, `invalidReasons`, `algorithmVersion`, `stressResult` nullable. `stressResult` garde statut, confiance, limitations et version de référence utilisée. |
|
||
| Annotation | `feeling` entier 1–5 nullable, `posture`, `atRest`, `recentExercise`, `recentCaffeine`, `recentMeal`, `recordedAt`, `revision`. Modification indépendante du résultat acquis. |
|
||
| Séance | `practiceId`, `protocolVersion`, `deviceId`, `source`, `startedAt`, `endedAt` nullable, `plannedDurationSeconds`, `actualDurationSeconds`, `status`, `beforeMeasurementId`, `afterMeasurementId`, `feelingBefore`, `feelingAfter`, résumé de cohérence nullable, `revision`. |
|
||
| Rappel | `kind`, `localTime` (HH:mm), `weekdays` (1=lundi…7=dimanche), `timeZone`, `enabled`, `targetDeviceId`. Planification réelle par l'OS, avec gestion explicite des changements de fuseau et d'heure d'été. |
|
||
| Abonnement | `plan`, `status`, `expiresAt`, `provider`, `verifiedAt`. Écrit uniquement après validation serveur des achats ; choisir le parcours Watch ne donne aucun droit payant. |
|
||
|
||
Les sources canoniques de mesure sont `iphoneCameraPPG` et `appleWatchOptical`,
|
||
déjà utilisées dans le contrat Watch. Un éventuel import HealthKit doit avoir sa
|
||
propre source, provenance et méthode. Ne pas confondre PRV estimée par caméra,
|
||
mesure optique de BPM et HRV HealthKit ; ne pas remplir des métriques absentes
|
||
de la Watch à partir des exemples Figma. Qualité et couverture sont normalisées
|
||
entre 0 et 1. Chaque algorithme conserve sa version et son statut de disponibilité.
|
||
|
||
Les séances utilisent `inProgress`, `awaitingAfter`, `completed`, `cancelled`.
|
||
`beforeMeasurementId` et `afterMeasurementId` restent des IDs relatifs au même
|
||
utilisateur ; la transition finale valide leur existence et leur provenance.
|
||
Une séance sans mesure après ne reçoit pas de comparaison fabriquée.
|
||
|
||
## Synchronisation et conflits
|
||
|
||
1. Acquisition et minuteur fonctionnent localement ; aucune écriture par battement
|
||
ni par seconde. Résumé à la fin, puis mises à jour métier limitées.
|
||
2. La file d'envoi locale est persistante et partitionnée par UID. Les anciennes
|
||
données sans propriétaire restent locales jusqu'à une association explicite.
|
||
3. La Watch envoie son UUID original ; l'iPhone le conserve en Firestore. Les
|
||
renvois créent au plus un résultat. Le résumé d'acquisition devient immuable ;
|
||
une annotation utilise son document séparé.
|
||
4. Les modifications de séances/annotations utilisent une `revision` monotone
|
||
validée en transaction. Une révision périmée provoque relecture et résolution,
|
||
jamais un écrasement silencieux basé sur l'horloge de la Watch.
|
||
5. L'accusé de réception Watch distingue « enregistré durablement sur l'iPhone »
|
||
et « synchronisé dans le cloud ». La disparition du réseau ne perd pas la file.
|
||
6. Une suppression passe par une opération serveur idempotente : tombstone
|
||
déterministe (`measurement_UUID` ou `session_UUID`), suppression du contenu
|
||
et invalidation des agrégats. Toute recréation avec cet ID est refusée.
|
||
Les tombstones restent jusqu'à suppression du compte ; pas de TTL qui
|
||
permettrait à une vieille Watch hors ligne de ressusciter une mesure.
|
||
7. À la déconnexion/changement de compte, isoler caches, listeners et files ; la
|
||
Watch doit être réassociée avant d'envoyer au nouvel UID. Un message Watch
|
||
ne peut pas choisir arbitrairement l'UID de destination.
|
||
|
||
L'intégration Expo privilégiera les SDK natifs Firebase compatibles avec le
|
||
development build pour Auth et le cache Firestore. La file métier reste nécessaire
|
||
pour WatchConnectivity et les transactions qui exigent le réseau. Aucun compte
|
||
anonyme automatique tant que le parcours de rattachement/récupération n'est pas
|
||
défini ; le mode local peut fonctionner sans connexion.
|
||
|
||
## Historique, index et coût
|
||
|
||
| Écran / besoin | Requête prévue | Index composite |
|
||
| --- | --- | --- |
|
||
| Dernières mesures | `measurements orderBy(startedAt desc) limit(30)` + curseur | Index simple |
|
||
| Mesures par appareil/source | `where(source == …) orderBy(startedAt desc)` | `source ASC, startedAt DESC` |
|
||
| Historique de pratique | `sessions where(practiceId == …) orderBy(startedAt desc)` | `practiceId ASC, startedAt DESC` |
|
||
| Séances terminées par période | `sessions where(status == completed) orderBy(startedAt desc)` + bornes | `status ASC, startedAt DESC` |
|
||
| Graphique mensuel | `dailyStats` borné par date et fuseau d'agrégation | Selon requête finale |
|
||
|
||
Les requêtes partent toujours de `users/{uid}`. Pas de listener sur tout
|
||
l'historique et pas de requête globale `collectionGroup` côté utilisateur.
|
||
Les cartes d'accueil écoutent seulement les documents nécessaires. Exclure de
|
||
l'indexation les maps de résultats et les champs jamais interrogés.
|
||
|
||
Les agrégats sont une optimisation ultérieure, pas un prérequis du premier
|
||
branchement : lire d'abord une période bornée. Lors de leur introduction,
|
||
`dailyStats` porte `localDate`, `timeZone`, `aggregationVersion`, `computedAt` et
|
||
des métriques séparées par source. La clé inclut la date et une clé de fuseau
|
||
stable ; un changement de fuseau invalide/recalcule la vue au lieu de mélanger
|
||
des journées. Les baselines sont séparées par source, méthode et version
|
||
d'algorithme. Les traitements serveur doivent être idempotents face aux événements
|
||
dupliqués et gérer modification/suppression ; un simple incrément par trigger
|
||
n'est pas suffisant. Un résultat historique garde la référence utilisée à sa date.
|
||
|
||
## Données qui restent locales
|
||
|
||
Les frames, textes, traductions, icônes et valeurs de démonstration Figma restent
|
||
dans l'application. Les données démo ne migrent jamais dans l'historique réel.
|
||
Les échantillons PPG bruts, vidéos caméra, timelines haute fréquence et données
|
||
HealthKit détaillées ne sont pas téléversés par défaut. Si une fonction justifie
|
||
ultérieurement leur conservation distante, utiliser un stockage de fichiers privé
|
||
avec durée de conservation explicite et une référence dans Firestore, plutôt
|
||
qu'une série volumineuse dans chaque document de mesure.
|
||
|
||
## Politique d'accès à implémenter et tester
|
||
|
||
- Refus par défaut. Auth obligatoire et `request.auth.uid == uid` pour les
|
||
documents privés, avec vérification que le compte est actif.
|
||
- Autoriser des collections et des champs nommés explicitement ; pas de règle
|
||
générique `users/{uid}/{document=**}` autorisant toute écriture.
|
||
- Vérifier types, enums, tailles, plages, timestamps et transitions. Les champs
|
||
imbriqués nécessitent aussi leurs propres listes de champs autorisés.
|
||
- Le client peut créer son profil avec `accountState: active`, puis ne modifier
|
||
que les champs de profil. Il ne peut pas réactiver un compte en suppression.
|
||
- Le client crée une acquisition valide au sens du schéma, puis ne peut plus
|
||
réécrire ses métriques. Les annotations et réglages sont modifiables selon
|
||
leur contrat. « Schéma valide » ne signifie pas mesure certifiée par serveur.
|
||
- Abonnements, agrégats, références calculées, tombstones et catalogue de
|
||
protocoles : écriture serveur uniquement. Protocoles publiés lisibles selon le
|
||
parcours produit ; accès aux données privées strictement individuel.
|
||
- Les références avant/après restent sous le même UID. La suppression interdit
|
||
toute recréation et corrige les séances qui référencent la mesure supprimée.
|
||
- La suppression de compte bloque d'abord les écritures, purge récursivement
|
||
les sous-collections et fichiers associés, puis supprime Auth. Effacer le seul
|
||
document `users/{uid}` ne supprime pas ses sous-collections.
|
||
- Tests Emulator requis : non-connecté refusé, utilisateur A/B isolés, écriture
|
||
d'abonnement refusée, champs inconnus refusés, timestamps falsifiés refusés,
|
||
mauvaise révision refusée, rejeu idempotent, recréation après suppression
|
||
refusée, blocage pendant suppression du compte.
|
||
|
||
## Ordre d'intégration
|
||
|
||
1. Enregistrer l'app iOS Expo (`com.haptika.mobile.dev`) dans le projet, configurer
|
||
Auth selon le parcours retenu, puis intégrer le SDK natif au development build.
|
||
2. Écrire les règles et tests Emulator du contrat minimal : profil, préférences,
|
||
mesures, annotations et séances ; valider avant déploiement.
|
||
3. Brancher l'état Expo via des repositories métier. Mapper les clés Figma
|
||
actuelles vers des noms métier stables. Ne pas synchroniser `values` et
|
||
`toggles` tels quels et ne pas importer les faux profils de maquette.
|
||
4. Porter le pont WatchConnectivity dans Expo et tester hors ligne, rejeux,
|
||
changement de compte et suppression sur simulateurs/appareils adaptés.
|
||
5. Ajouter validation d'achat serveur, agrégats et catalogue distant uniquement
|
||
lorsque leurs fonctions correspondantes sont branchées.
|
||
|
||
## Sources techniques
|
||
|
||
- [Modèle Firestore et sous-collections](https://firebase.google.com/docs/firestore/data-model)
|
||
- [Bonnes pratiques et indexation](https://firebase.google.com/docs/firestore/best-practices)
|
||
- [Contrôle des champs par les règles](https://firebase.google.com/docs/firestore/security/rules-fields)
|
||
- [Persistance hors ligne](https://firebase.google.com/docs/firestore/manage-data/enable-offline)
|
||
|
||
Contrats existants examinés : `ios/HaptikaWatch/Core/WatchModels.swift`,
|
||
`ios/HaptikaWatch/Core/WatchStore.swift`, `src/features/ppg/storage/storageTypes.ts`,
|
||
`src/features/ppg/stress/stressTypes.ts`, `src/features/coherence/coherenceTypes.ts`,
|
||
`src/features/cardiacMirror/regulationSessionTypes.ts`,
|
||
`src/features/onboarding/onboardingRepository.ts`, `mobile/src/state/app-state.tsx`.
|