Ajoute les notes mémoire d'architecture, de philosophie de la couche online et d'UX pour le client online, clôture le ticket #64, reflète les tickets #46/#54/#57/#62, ajoute le cadrage des tickets #65 à #70. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
8.8 KiB
Markdown
171 lines
8.8 KiB
Markdown
---
|
|
name: gametime-architecture-online-client
|
|
description: memory note gametime-architecture-online-client
|
|
metadata:
|
|
type: project
|
|
---
|
|
# GameTime — Architecture client online, auth, sync et partage
|
|
|
|
Décision d'architecture pour le ticket #63, basée sur les mémoires `gametime-ux-online-client`, `gametime-online-layer-philosophy` et `gametime-server-architecture-sync-sharing`.
|
|
|
|
## Conclusion serveur : pas d'adaptation requise pour les étapes d'exercice
|
|
|
|
Le serveur ne valide pas la forme interne des payloads synchronisés. `synced_resources.payload_json` est un JSONB opaque stocké et retourné tel quel.
|
|
|
|
Preuves dans le code :
|
|
- `server/migrations/0001_initial_schema.sql` : `payload_json jsonb NOT NULL` sur `synced_resources`, avec contraintes seulement sur `resource_type`, `client_id`, `schema_version`, pas sur la forme de `payload_json`.
|
|
- `server/lib/infrastructure/postgres/synced_resource_repository.dart` : l'upsert écrit `@payload_json::jsonb`, avec `jsonEncode(resource.payloadJson)`, puis retourne `payload_json` via `_payloadValue(...)`. Aucune validation métier de structure exercice/programme/template n'est faite dans ce repository.
|
|
- `server/openapi.yaml` : `SyncPushItem.payload` et `SyncedResourceItem.payload` référencent seulement `JsonObject`. `CreateShareRequest.payload` et `ShareInboxItem.payload` idem.
|
|
|
|
Conclusion : l'ajout des étapes d'exercice côté client ne nécessite pas de modification serveur pour la sync v1. Le client peut envoyer la nouvelle forme JSON dans `payload` tant qu'elle reste un objet JSON valide et porte `schemaVersion` correctement.
|
|
|
|
## Principe produit non négociable
|
|
|
|
La couche online est optionnelle et additive :
|
|
- pas d'écran de login au démarrage ;
|
|
- aucune action locale ne dépend du serveur ;
|
|
- aucun échec réseau ne déclenche de popup globale ;
|
|
- les données nécessaires à l'UI restent en local ;
|
|
- logout ne supprime jamais exercices/programmes/séances/historique locaux.
|
|
|
|
## Architecture lib/
|
|
|
|
Conserver l'architecture existante :
|
|
|
|
- `lib/domain/entities.dart` : entités pures `UserAccountSession`, `SyncStatusSnapshot`, `ShareInboxItem`, `PendingShareAction` si elles sont stables métier.
|
|
- `lib/application/ports.dart` : ports `AuthTokenStore`, `OnlineAccountRepository`, `RemoteSyncApi`, `RemoteShareApi`, `OnlineSessionRepository`, `SyncMetadataRepository`, `ShareInboxRepository`.
|
|
- `lib/application/use_cases.dart` : `AuthUseCases`, `SyncUseCases`, `ShareUseCases`.
|
|
- `lib/infrastructure/local/` : cache profil, curseur sync, inbox partages, queue d'actions pending, mapping Drift.
|
|
- `lib/infrastructure/remote/` : adapter HTTP vers `server/openapi.yaml`, DTO wire, sérialisation JSON.
|
|
- `lib/presentation/` : Profil, auth, statut sync, partage sortant, inbox.
|
|
|
|
Le domaine/application ne dépend pas de `http`, `flutter_secure_storage` ni Drift.
|
|
|
|
## Dépendances client retenues
|
|
|
|
- Tokens : `flutter_secure_storage`.
|
|
- Justification : stockage sécurisé cross-platform via mécanismes natifs ; adapté à access/refresh tokens.
|
|
- Ne pas stocker le profil affichable uniquement dedans.
|
|
- HTTP : `package:http` avec `Client` injecté.
|
|
- Justification : API simple, composable, facile à fake en tests, dépendance plus légère que Dio. Ajouter un wrapper pour baseUrl, auth bearer, JSON, timeouts et mapping d'erreurs.
|
|
|
|
## Authentification client
|
|
|
|
Entité locale recommandée : `UserAccountSession`.
|
|
|
|
Champs :
|
|
- `id` local.
|
|
- `serverUserId`.
|
|
- `email`.
|
|
- `displayName?`.
|
|
- `avatarLocalUri?` ou `avatarRemoteUri?` si disponible plus tard.
|
|
- `isLoggedIn`.
|
|
- `createdAt`, `updatedAt`, `lastAuthenticatedAt?`.
|
|
|
|
Stockage :
|
|
- tokens opaques : `flutter_secure_storage` via port `AuthTokenStore`.
|
|
- profil/cache visible : Drift, pour affichage instantané hors ligne.
|
|
|
|
Use cases :
|
|
- `register(email, password)` -> API `POST /auth/register`, stocke tokens si réponse connectante, cache profil, déclenche sync en arrière-plan.
|
|
- `login(email, password)` -> `POST /auth/login`, stocke tokens, cache profil, déclenche sync en arrière-plan.
|
|
- `logout()` -> tente `POST /auth/logout`, mais supprime tokens localement même si réseau KO ; conserve données locales et profil dernier connu si utile à l'historique d'affichage, avec état déconnecté.
|
|
- `currentSession()` -> lit cache local + présence token.
|
|
|
|
Erreurs :
|
|
- erreurs credentials : retournées au formulaire auth en erreur inline.
|
|
- réseau/timeout : erreur typée non intrusive ; jamais popup globale.
|
|
|
|
## Synchronisation client
|
|
|
|
Ressources synchronisées v1 :
|
|
- `exercise`
|
|
- `program`
|
|
- `workoutTemplate`
|
|
- `workoutHistory`
|
|
- `mediaAsset` metadata uniquement
|
|
|
|
Mapping push :
|
|
|
|
```json
|
|
{
|
|
"resourceType": "exercise",
|
|
"clientId": "<local entity id>",
|
|
"schemaVersion": <local schema/payload version>,
|
|
"clientUpdatedAt": "<entity.metadata.updatedAt UTC>",
|
|
"deletedAt": "<entity.metadata.deletedAt|null>",
|
|
"payload": { "...": "snapshot complet local" }
|
|
}
|
|
```
|
|
|
|
Le payload doit être le snapshot local complet et autonome nécessaire pour reconstruire l'entité. Pour les exercices, il inclut les étapes ajoutées par #54. Le serveur ne l'interprète pas.
|
|
|
|
Source des changements à pousser : utiliser `change_log`, `syncState`, `updatedAt`, `deletedAt`, `localRevision`. Le gateway doit lire les entités concernées, assembler les payloads, appeler `POST /sync/push` ou `POST /sync/exchange`, puis marquer les items acceptés comme synced et enregistrer les mappings serveur si nécessaires.
|
|
|
|
Pull :
|
|
- stocker `serverCursor` localement.
|
|
- appeler `GET /sync/pull?since=<cursor>` ou `POST /sync/exchange`.
|
|
- appliquer chaque item selon LWW côté client : si `server.clientUpdatedAt` est plus récent que `local.updatedAt`, appliquer ; sinon ignorer localement.
|
|
- soft delete distant : appliquer `deletedAt` local sans hard delete.
|
|
- mettre à jour le curseur seulement après transaction locale réussie.
|
|
|
|
Déclencheurs :
|
|
- après login/register : sync en arrière-plan, sans loader bloquant.
|
|
- au démarrage si connecté : tentative silencieuse.
|
|
- après mutation locale : planifier une sync arrière-plan courte/debounced.
|
|
- périodique quand app active : intervalle raisonnable, pas besoin de temps réel.
|
|
- manuel depuis Profil : `Synchroniser maintenant`.
|
|
|
|
Échec sync :
|
|
- pas de popup ;
|
|
- statut neutre dans Profil ;
|
|
- retry différé ;
|
|
- l'app reste utilisable localement.
|
|
|
|
## Drift / migration client
|
|
|
|
Le schéma local actuel est `schemaVersion = 9`. Si les tables online sont ajoutées, passer à `schemaVersion = 10`.
|
|
|
|
Tables recommandées :
|
|
|
|
### `online_account_sessions`
|
|
- cache profil et état connecté/déconnecté.
|
|
- pas de tokens en clair.
|
|
|
|
### `sync_metadata`
|
|
- singleton ou key/value : `serverCursor`, `lastSuccessfulSyncAt`, `lastAttemptAt`, `lastFailureAt`, `status`, `pendingPushCount?`.
|
|
|
|
### `remote_resource_mappings`
|
|
- `resourceType`, `clientId`, `serverId`, `serverUpdatedAt`, unique `(resourceType, clientId)`.
|
|
|
|
### `share_inbox_items`
|
|
- `shareId`, sender info, `resourceType`, `payloadJson`, `status`, `createdAt`, `updatedAt`, cache offline.
|
|
|
|
### `pending_share_actions`
|
|
- queue locale pour share/send/accept/decline/revoke si réseau indisponible.
|
|
- champs : `id`, `actionType`, `shareId?`, `resourceType?`, `payloadJson?`, `recipientEmailsJson?`, `createdAt`, `lastAttemptAt?`, `attemptCount`, `status`.
|
|
|
|
## Partage client
|
|
|
|
Ports/use cases :
|
|
- `sendShare(resourceType, localResourceId, recipientEmails)` : construit payload snapshot local de Program ou WorkoutTemplate, appelle `POST /shares`; si réseau KO, met en queue.
|
|
- `refreshInbox()` : appelle `GET /shares/inbox`, cache les items.
|
|
- `acceptShare(shareId)` : appelle `POST /shares/{id}/accept`.
|
|
- `declineShare(shareId)`.
|
|
- `revokeShare(shareId)`.
|
|
|
|
Acceptation : OpenAPI indique que `AcceptShareResponse.createdResource` renvoie directement un `SyncedResourceItem`. Le client peut donc importer immédiatement la ressource créée dans le stockage local, puis le prochain pull sert de convergence. Si l'accusé serveur ne peut pas partir mais que le payload inbox est déjà local, l'app peut créer une copie locale indépendante et mettre l'action accept en queue selon le choix d'implémentation, avec message neutre.
|
|
|
|
Invariant : un partage accepté devient une copie locale normale, non liée dynamiquement à l'expéditeur. Les doublons de noms sont autorisés.
|
|
|
|
## Tickets créés
|
|
|
|
- #64 `[DevBackend] Client online : session compte, stockage sécurisé et adapter API`.
|
|
- #65 `[DevBackend] Sync client incrémentale LWW vers API serveur`, dépend de #64.
|
|
- #66 `[DevBackend] Partage client : use cases, inbox cache et import local`, dépend de #64 et #65.
|
|
- #67 `[DevFrontend] Profil et écrans auth optionnels`, dépend de #64.
|
|
- #68 `[DevFrontend] Statut de synchronisation discret et action manuelle`, dépend de #65 et #67.
|
|
- #69 `[DevFrontend] Partage sortant et boîte de réception`, dépend de #66 et #67.
|
|
- #70 `[QA] Validation client online offline-first`, dépend de #68 et #69.
|
|
|
|
Ordre recommandé : #64 -> #65 -> #66, en parallèle #67 après #64, puis #68 et #69, puis #70. |