Files
GameTime/.ideai/memory/gametime-architecture-online-client.md
Blomios 53b4957bc6 docs(ideai): mémoire client online, clôture ticket #64, cadrage #65-#70
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>
2026-07-19 21:25:57 +02:00

8.8 KiB

name, description, metadata
name description metadata
gametime-architecture-online-client memory note gametime-architecture-online-client
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 :

{
  "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.