simpl-liste/spec-simpl-liste-web.md
le king fu 9cf507429a docs: archive spec-simpl-liste-web (milestone 12/12 done)
Design document for the Simpl-Liste Web frontend, Logto integration,
and hybrid mobile/web sync. Milestone spec-simpl-liste-web is fully
delivered — preserving the spec as historical reference.
2026-04-19 15:57:00 -04:00

34 KiB

Spec — Simpl-Liste Web

Date: 2026-04-06 Projet: simpl-liste Statut: Draft Dependance: Logto IdP (deploye et operationnel)

Contexte

Simpl-Liste est actuellement une app mobile React Native/Expo avec stockage SQLite local (offline-first, sans compte). L'objectif est de rendre l'application disponible via le web, integree au systeme d'authentification Compte Maximus (Logto), avec synchronisation bidirectionnelle entre le mobile et le web.

L'app mobile continue de fonctionner sans compte. Le login debloque la sync ; il reste optionnel.

Objectif

Permettre aux utilisateurs connectes avec un Compte Maximus de gerer leurs listes et taches depuis liste.lacompagniemaximus.com, avec sync hybride : temps reel sur le web (WebSocket), polling sur le mobile. Parite fonctionnelle complete avec le mobile (hors widgets, notifications push, et sync calendrier).

Scope

IN

  • Frontend web Next.js App Router dans simpl-liste/web/
  • API REST backend (route handlers Next.js) pour CRUD listes, taches, tags, sous-taches
  • WebSocket server pour notifications temps reel sur le web
  • Schema PostgreSQL (sl_ prefix) avec Drizzle ORM (drizzle-orm/pg-core)
  • Auth via Logto (Compte Maximus) — SDK @logto/next
  • Sync hybride : WebSocket web + polling mobile
  • Login optionnel dans l'app mobile avec choix de migration des donnees locales
  • Types TypeScript partages entre mobile et web (src/shared/)
  • i18n FR/EN (francais par defaut)
  • Dark mode (light/dark/system)
  • Responsive design (utilisable sur mobile)
  • Deploiement sur Coolify (liste.lacompagniemaximus.com)

OUT (explicitement exclu)

  • Widgets Android (specifique mobile)
  • Notifications push (specifique mobile)
  • Sync calendrier systeme (specifique mobile via expo-calendar)
  • Export ICS depuis le web (future phase)
  • Mode offline pour le web (connexion requise)
  • Partage de listes entre utilisateurs (future phase)
  • App iOS (future phase)

Design

Architecture globale

simpl-liste/
├── app/                    # App mobile React Native (existant)
├── src/                    # Code mobile (existant)
│   ├── shared/             # NOUVEAU — types, constantes, logique partagee
│   │   ├── types.ts        # Task, List, Tag, TaskFilters, etc.
│   │   ├── colors.ts       # Palette (extrait de theme/colors.ts)
│   │   ├── priority.ts     # Helpers priorite (extrait de lib/priority.ts)
│   │   └── recurrence.ts   # Calcul recurrence (extrait de lib/recurrence.ts)
│   └── ...
└── web/                    # NOUVEAU — App Next.js
    ├── package.json
    ├── next.config.ts
    ├── server.ts            # Custom server (Next.js + WebSocket)
    ├── Dockerfile
    ├── src/db/
    │   ├── schema.ts        # Schema PostgreSQL Drizzle (sl_ tables)
    │   ├── client.ts        # Connexion pg + drizzle instance
    │   └── migrations/      # Migrations SQL (drizzle-kit generate)
    ├── src/
    │   ├── app/             # App Router (pages, layouts, API routes)
    │   │   ├── layout.tsx
    │   │   ├── page.tsx     # Redirect vers /lists ou /auth
    │   │   ├── auth/        # Login via Logto
    │   │   ├── api/         # Route handlers REST
    │   │   │   ├── lists/
    │   │   │   ├── tasks/
    │   │   │   ├── tags/
    │   │   │   └── sync/
    │   │   └── (app)/       # Pages authentifiees
    │   │       ├── layout.tsx  # Sidebar listes + main area
    │   │       ├── inbox/
    │   │       └── lists/[id]/
    │   ├── lib/
    │   │   ├── auth.ts      # Logto middleware + session
    │   │   ├── db.ts        # Re-export drizzle client
    │   │   └── ws.ts        # WebSocket server + broadcast
    │   ├── components/      # Composants React web
    │   └── i18n/            # next-intl ou i18next
    └── tailwind.config.ts   # Palette partagee depuis src/shared/colors.ts

ORM unifie — Drizzle partout

Le projet utilise Drizzle ORM sur les deux plateformes :

  • Mobile : drizzle-orm/sqlite-core + expo-sqlite (existant)
  • Web/serveur : drizzle-orm/pg-core + pg (nouveau)

Un seul ORM a maitriser, un seul outil de migration (drizzle-kit), pas de codegen (contrairement a Prisma). Les schemas SQLite et PostgreSQL partagent la meme structure de colonnes — les types inferes par Drizzle ($inferSelect) sont directement compatibles.

Le dossier src/shared/types.ts exporte les types metier communs (filtres, tri, recurrence, priorites). Les types des entites (Task, List, Tag) sont inferes depuis les schemas Drizzle respectifs — pas besoin d'une couche de mapping manuelle.

// src/shared/types.ts — types partages (filtres, enums, helpers)
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
export type Priority = 0 | 1 | 2 | 3;
export type SortBy = 'position' | 'priority' | 'dueDate' | 'title' | 'createdAt';
export type SortOrder = 'asc' | 'desc';
export type FilterCompleted = 'all' | 'active' | 'completed';
export type FilterDueDate = 'all' | 'today' | 'week' | 'overdue' | 'noDate';
export interface TaskFilters { sortBy: SortBy; sortOrder: SortOrder; /* ... */ }

// Les types des entites sont inferes depuis les schemas Drizzle de chaque plateforme :
// Mobile : typeof tasks.$inferSelect (depuis src/db/schema.ts, sqlite-core)
// Web :    typeof slTasks.$inferSelect (depuis web/src/db/schema.ts, pg-core)

Identite utilisateur — Compte Maximus

Le userId stocke dans les tables PostgreSQL est le sub claim du JWT Logto. Ce choix est delibere : Logto est le systeme d'identite centralise de La Compagnie Maximus. Toutes les apps actuelles et futures (la-suite-booking, famille-website, etc.) migreront vers Logto comme IdP unique. Utiliser le sub comme identifiant commun garantit la compatibilite cross-app et permettra un futur dashboard Compte Maximus unifie.

Schema PostgreSQL (Drizzle pg-core)

Base sur le schema SQLite existant (src/db/schema.ts), avec ajout de userId (= sub Logto) pour l'isolation multi-utilisateur et deletedAt pour le soft-delete (sync). Structure quasi identique au schema mobile — meme ORM, memes conventions.

// web/src/db/schema.ts
import { pgTable, uuid, text, integer, boolean, timestamp, primaryKey, index } from 'drizzle-orm/pg-core';

export const slLists = pgTable('sl_lists', {
  id:        uuid('id').primaryKey().defaultRandom(),
  userId:    uuid('user_id').notNull(),
  name:      text('name').notNull(),
  color:     text('color'),
  icon:      text('icon'),
  position:  integer('position').notNull().default(0),
  isInbox:   boolean('is_inbox').notNull().default(false),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
  index('idx_sl_lists_user').on(table.userId),
]);

export const slTasks = pgTable('sl_tasks', {
  id:          uuid('id').primaryKey().defaultRandom(),
  userId:      uuid('user_id').notNull(),
  title:       text('title').notNull(),
  notes:       text('notes'),
  completed:   boolean('completed').notNull().default(false),
  completedAt: timestamp('completed_at', { withTimezone: true }),
  priority:    integer('priority').notNull().default(0), // 0=none, 1=low, 2=medium, 3=high
  dueDate:     timestamp('due_date', { withTimezone: true }),
  listId:      uuid('list_id').notNull().references(() => slLists.id, { onDelete: 'cascade' }),
  parentId:    uuid('parent_id').references((): any => slTasks.id, { onDelete: 'cascade' }),
  position:    integer('position').notNull().default(0),
  recurrence:  text('recurrence'), // 'daily'|'weekly'|'monthly'|'yearly'
  createdAt:   timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt:   timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  deletedAt:   timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
  index('idx_sl_tasks_user').on(table.userId),
  index('idx_sl_tasks_list').on(table.listId),
  index('idx_sl_tasks_parent').on(table.parentId),
]);

export const slTags = pgTable('sl_tags', {
  id:        uuid('id').primaryKey().defaultRandom(),
  userId:    uuid('user_id').notNull(),
  name:      text('name').notNull(),
  color:     text('color').notNull().default('#4A90A4'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
  index('idx_sl_tags_user').on(table.userId),
]);

export const slTaskTags = pgTable('sl_task_tags', {
  taskId: uuid('task_id').notNull().references(() => slTasks.id, { onDelete: 'cascade' }),
  tagId:  uuid('tag_id').notNull().references(() => slTags.id, { onDelete: 'cascade' }),
}, (table) => [
  primaryKey({ columns: [table.taskId, table.tagId] }),
]);

Notes :

  • deletedAt (soft-delete) sur lists, tasks, tags — necessaire pour la sync (les suppressions doivent etre propagees aux clients)
  • calendar_event_id omis (specifique mobile)
  • updatedAt sert de vecteur de version pour le last-write-wins
  • La table sl_tags n'a pas de updatedAt dans le schema SQLite mobile — a ajouter cote mobile pour la sync
  • Le schema PostgreSQL est structurellement identique au schema SQLite mobile, avec userId et deletedAt en plus — memes noms de colonnes, memes types logiques

API REST

Base URL: https://liste.lacompagniemaximus.com/api Auth: Session Logto (cookie) pour le web, Bearer JWT pour le mobile

Listes

Methode Endpoint Description
GET /api/lists Toutes les listes de l'utilisateur
POST /api/lists Creer une liste
PUT /api/lists/:id Modifier une liste
DELETE /api/lists/:id Soft-delete (cascade taches)
PUT /api/lists/reorder Reordonner (batch positions)

Taches

Methode Endpoint Description
GET /api/lists/:listId/tasks Taches d'une liste (filtres: completed, priority, dueDate, tags)
GET /api/tasks/:id/subtasks Sous-taches
POST /api/tasks Creer une tache
PUT /api/tasks/:id Modifier (titre, notes, completed, priorite, dueDate, listId, position, recurrence)
DELETE /api/tasks/:id Soft-delete (cascade sous-taches)
PUT /api/tasks/reorder Reordonner dans une liste

Tags

Methode Endpoint Description
GET /api/tags Tous les tags de l'utilisateur
POST /api/tags Creer un tag
PUT /api/tags/:id Modifier un tag
DELETE /api/tags/:id Soft-delete
POST /api/tasks/:id/tags Assigner des tags a une tache
DELETE /api/tasks/:taskId/tags/:tagId Retirer un tag

Sync (mobile)

Methode Endpoint Description
GET /api/sync?since=<ISO timestamp> Changements serveur depuis timestamp (inclut soft-deletes)
POST /api/sync Push batch de changements locaux (avec idempotency keys)

WebSocket (web)

Methode Endpoint Description
POST /api/ws-ticket Generer un ticket ephemere (nonce, TTL 30s) pour le handshake WS
WS /ws?ticket=<nonce> Connexion WebSocket (ticket a usage unique)

Sync Strategy — Hybride

Web : WebSocket temps reel

  • Custom server Next.js (server.ts) qui attache un serveur ws sur le meme port HTTP (path /ws)
  • A chaque mutation via l'API, le serveur broadcast un message aux WebSocket connectes du meme userId
  • Le client web ecoute et rafraichit les donnees affectees (pas de re-fetch complet, notification granulaire par entity type + id)
  • Reconnexion automatique avec backoff exponentiel cote client
  • Heartbeat toutes les 30s pour detecter les connexions mortes
// Message WebSocket — notification seulement, pas de payload (securite)
type WsMessage =
  | { type: 'sync'; entity: 'list' | 'task' | 'tag'; action: 'create' | 'update' | 'delete'; id: string }
  | { type: 'auth_expired' } // session expiree, le client doit se reconnecter

Mobile : polling periodique

  • Sync au lancement de l'app (si connecte)
  • Sync toutes les 2 minutes en foreground
  • Sync au retour du background (app state change)
  • Indicateur visuel de statut sync (icone dans le header)

Resolution de conflits

  • Last-write-wins base sur updatedAt — le timestamp le plus recent gagne
  • En cas d'egalite exacte (improbable), le serveur gagne
  • Les suppressions (soft-delete) sont prioritaires : si un client supprime et l'autre modifie, la suppression gagne

Flux de sync mobile

  1. Le mobile envoie POST /api/sync avec tous les changements locaux depuis lastSyncAt
  2. Le serveur applique les changements (LWW), retourne les conflits resolus
  3. Le mobile fait GET /api/sync?since=lastSyncAt pour recuperer les changements serveur
  4. Le mobile applique les changements serveur dans SQLite
  5. lastSyncAt est mis a jour dans AsyncStorage

Premiere connexion — Migration des donnees locales

Au premier login dans l'app mobile, une alerte propose :

  1. "Fusionner mes taches" — Upload toutes les donnees locales vers le serveur, puis sync bidirectionnel
  2. "Repartir du serveur" — Ecrase les donnees locales avec celles du serveur (si le compte a deja des donnees web)

Reconciliation de l'Inbox : L'Inbox mobile a un ID hardcode (00000000-0000-0000-0000-000000000001). Le serveur cree une Inbox avec un UUID genere. Lors du merge :

  1. Le serveur cree l'Inbox du user si elle n'existe pas
  2. Le mobile remap l'ID hardcode vers l'ID Inbox serveur dans toutes les taches locales
  3. Push les taches avec le nouvel ID
  4. Mettre a jour l'ID Inbox local pour matcher le serveur

Outbox pattern — file d'attente locale

Les changements locaux sont d'abord ecrits dans une table SQLite sync_outbox avant d'etre envoyes au serveur :

CREATE TABLE sync_outbox (
  id TEXT PRIMARY KEY,           -- UUID (= idempotency key)
  entity_type TEXT NOT NULL,     -- 'list' | 'task' | 'tag' | 'task_tag'
  entity_id TEXT NOT NULL,       -- ID de l'entite modifiee
  action TEXT NOT NULL,          -- 'create' | 'update' | 'delete'
  payload TEXT NOT NULL,         -- JSON de l'operation
  created_at TEXT NOT NULL,      -- ISO 8601
  synced_at TEXT                 -- NULL tant que pas sync, ISO 8601 apres
);

Flux :

  1. Chaque mutation locale ecrit dans la table metier ET dans sync_outbox
  2. Le sync service lit les entries WHERE synced_at IS NULL, les envoie en batch via POST /api/sync
  3. Les entries synced sont marquees (synced_at = now())
  4. Les entries de plus de 7 jours sont purgees
  5. Si le serveur est inaccessible, l'outbox s'accumule — les changements ne sont jamais perdus

Auth

Web (Next.js)

  • SDK @logto/next pour l'auth server-side
  • Middleware Next.js qui protege toutes les routes sous /(app)/
  • Session cookie securisee (HttpOnly, SameSite=Strict)
  • Extraction du userId depuis les claims JWT Logto
  • Verification optionnelle du claim apps.simpl-liste pour l'acces

Mobile (React Native)

  • SDK @logto/rn pour le flow OAuth2/OIDC
  • Bouton "Se connecter" dans Settings > Compte
  • Token JWT stocke securise (expo-secure-store)
  • Le token est envoye en header Authorization: Bearer <jwt> pour les appels API
  • Mode deconnecte : l'app fonctionne normalement sans token, la sync est desactivee

Frontend Web — UX

Layout principal

┌─────────────────────────────────────────────────┐
│  Simpl-Liste                        [FR] [🌙] [👤] │
├──────────┬──────────────────────────────────────┤
│          │                                      │
│  📥 Inbox │  ☐ Acheter du lait          ⚡ Haute │
│  📋 Courses│  ☐ Appeler le dentiste      📅 Lun  │
│  🏠 Maison │  ☑ Faire le menage         ✓ Fait  │
│  + Liste  │  ☐ Reparer le robinet              │
│          │                                      │
│  Tags    │  [+ Nouvelle tache]                  │
│  🔵 Urgent│                                      │
│  🟢 Perso │  Filtres: [Actives ▾] [Priorite ▾]  │
│          │  Tri: [Position ▾]                   │
├──────────┴──────────────────────────────────────┤
│  ● Connecte — Sync OK                          │
└─────────────────────────────────────────────────┘
  • Sidebar gauche : listes (avec couleur/icone), section tags, bouton + pour creer
  • Zone principale : taches de la liste selectionnee, avec filtres et tri en haut
  • Barre d'etat : statut de connexion et derniere sync
  • Edition inline des taches (titre, completion, priorite) — modal pour les details complets (notes, date, tags, sous-taches, recurrence)
  • Responsive : sidebar se replie en hamburger menu sur mobile web

Securite

Transport et authentification

  • TLS obligatoire (HTTPS/WSS via Caddy) — jamais de connexion non chiffree
  • Toutes les requetes API authentifiees (session cookie web / JWT Bearer mobile)
  • Session cookie web : HttpOnly, Secure, SameSite=Strict
  • JWT mobile : access token court (15 min) + refresh token avec rotation (chaque refresh invalide l'ancien)
  • Validation JWT systematique sur chaque requete : signature, exp, aud, iss

Stockage des tokens mobile

  • Adaptateur de stockage custom pour @logto/rn basé sur expo-secure-store (Keychain iOS / Keystore Android)
  • Ne JAMAIS stocker de tokens dans AsyncStorage (non chiffre, lisible sur appareil roote)
  • Ne pas inclure de donnees sensibles dans le payload JWT (le payload est encode en base64, pas chiffre)

WebSocket — Anti-CSWSH (Cross-Site WebSocket Hijacking)

  • Authentification par ticket ephemere (pas par cookie) :
    1. Le client appelle POST /api/ws-ticket (authentifie par session)
    2. Le serveur genere un nonce a usage unique (TTL 30s), stocke en memoire
    3. Le client se connecte a wss://host/ws?ticket=<nonce>
    4. Le serveur valide et invalide le ticket au handshake (usage unique)
  • Validation du header Origin contre une allowlist (liste.lacompagniemaximus.com)
  • Re-validation de la session toutes les 15 minutes — fermer la connexion si expiree, envoyer { type: 'auth_expired' } au client
  • Ne pas logger les query params sur la route /ws (eviter fuite du ticket dans les access logs)

Isolation des donnees et autorisation

  • Isolation par userId sur chaque requete (WHERE userId = ?)
  • BOLA prevention sur le sync batch : pour chaque operation dans POST /api/sync, verifier que l'entite appartient au userId (WHERE id = ? AND userId = ?). Rejeter le batch entier si une seule verification echoue.
  • Schemas Zod strict sur chaque endpoint — whitelist explicite des champs modifiables
  • Ne jamais permettre au client de modifier userId, createdAt, deletedAt via l'API

Anti-replay sur les endpoints sync

  • Idempotency keys : chaque operation dans le batch porte un UUID genere par le client. Le serveur stocke les IDs traites (TTL 24h) et retourne le meme resultat si resoumis.
  • Fenetre temporelle : rejeter les requetes dont le timestamp depasse +/- 5 minutes

Rate limiting granulaire

Endpoint Limite par user Raison
POST /api/sync 10/min Operation lourde (batch)
POST /api/ws-ticket 10/min Prevenir brute-force de tickets
POST /api/lists, POST /api/tasks, POST /api/tags 30/min Creation
GET /api/* 200/min Lecture
Connexions WebSocket 5/min Prevenir flood de reconnexions

Messages WebSocket

  • Envoyer seulement le type d'entite + id dans les messages WS (pas le contenu des taches)
  • Le client re-fetch les donnees modifiees via l'API REST (securisee)
  • Evite la fuite de donnees si la connexion WS est compromise

Soft-delete et retention

  • Purge automatique des enregistrements soft-deleted apres 30 jours (cron job)
  • Les clients qui n'ont pas sync depuis 30+ jours effectuent un full sync au lieu d'un delta
  • Politique de retention documentee dans les conditions d'utilisation

Input validation

  • Validation/sanitization (zod) sur tous les champs texte
  • Protection XSS : echapper le contenu utilisateur a l'affichage (React le fait par defaut)
  • CSRF protection sur les mutations (cookie SameSite=Strict)

Plan de travail

Issue 1 — Types partages et refactoring mobile [type:task]

Dependances : aucune

  • Creer src/shared/types.ts — exporter Task, List, Tag, TaskFilters, SortBy, etc.
  • Creer src/shared/colors.ts — extraire la palette de theme/colors.ts
  • Creer src/shared/priority.ts — extraire de lib/priority.ts
  • Creer src/shared/recurrence.ts — extraire de lib/recurrence.ts
  • Refactorer le code mobile pour importer depuis shared/
  • Ajouter updatedAt au schema tags dans le mobile (migration Drizzle)

Issue 2 — Setup projet web + schema PostgreSQL [type:task]

Dependances : aucune (parallele avec Issue 1)

  • Init Next.js App Router dans web/ avec TypeScript, Tailwind, Drizzle ORM (drizzle-orm/pg-core + pg)
  • Schema Drizzle PostgreSQL (sl_lists, sl_tasks, sl_tags, sl_task_tags) — userId = sub Logto
  • Migration initiale + seed (inbox par defaut par user)
  • Endpoint /api/health (status API, latence DB, connexions WS actives)
  • Dockerfile + config Coolify pour liste.lacompagniemaximus.com (verifier support WS dans Caddy)
  • Configurer Logto app pour le web (redirect URIs, etc.)

Issue 3 — Auth web + middleware [type:feature]

Dependances : Issue 2

  • Integrer @logto/next (sign-in, sign-out, callback)
  • Middleware Next.js : proteger /(app)/, extraire userId
  • Page de login (/auth) avec redirect vers Logto
  • Gestion de session (cookie securise)

Issue 4 — API REST backend [type:feature]

Dependances : Issue 2, Issue 3

  • Middleware auth sur /api/* (session cookie + Bearer JWT avec validation signature/exp/aud/iss)
  • Endpoints CRUD listes (avec soft-delete)
  • Endpoints CRUD taches (avec filtres, tri, sous-taches, soft-delete)
  • Endpoints CRUD tags + assignation (avec soft-delete)
  • Endpoints sync (GET since + POST batch avec idempotency keys)
  • Verification BOLA par entite sur le batch sync (WHERE id = ? AND userId = ?)
  • Schemas Zod strict sur chaque endpoint (whitelist de champs, rejeter champs inconnus)
  • Rate limiting granulaire (sync 10/min, creation 30/min, lecture 200/min)
  • Endpoint POST /api/ws-ticket (nonce ephemere TTL 30s, usage unique)
  • Tests API (optionnel mais recommande)

Issue 5 — WebSocket server [type:feature]

Dependances : Issue 4

  • Custom server (server.ts) : Next.js + ws sur le meme port
  • Auth WebSocket par ticket ephemere (valider et invalider le nonce au handshake)
  • Validation du header Origin contre l'allowlist
  • Broadcast par userId sur chaque mutation API (type + id seulement, pas de payload)
  • Re-validation de session toutes les 15 min (fermer si expiree + message auth_expired)
  • Heartbeat 30s + reconnexion auto cote client avec backoff exponentiel
  • Ne pas logger les query params sur la route /ws
  • Mettre a jour le Dockerfile pour utiliser le custom server

Issue 6 — Frontend web [type:feature]

Dependances : Issue 4, Issue 5

  • Layout principal (sidebar listes + zone taches)
  • CRUD listes (creer, renommer, supprimer, couleur/icone, reordonner)
  • CRUD taches (creer, editer inline, completer, supprimer, priorite, date, notes)
  • Sous-taches (affichage imbrique, creer/editer)
  • Tags (creer, assigner, filtrer)
  • Filtres et tri
  • Recurrence (affichage, creation, auto-recurrence au complete)
  • Integration WebSocket (mise a jour temps reel)
  • Dark mode (light/dark/system)
  • i18n FR/EN
  • Responsive design
  • Barre de statut sync

Issue 7 — Sync mobile [type:feature]

Dependances : Issue 1, Issue 4

  • Integrer @logto/rn avec adaptateur expo-secure-store (ne pas utiliser AsyncStorage pour les tokens)
  • Bouton login dans Settings > Compte
  • Access token court (15 min) + refresh token avec rotation automatique
  • Intercepteur HTTP pour refresh transparent (capturer 401, rafraichir, relancer)
  • Table sync_outbox dans SQLite (migration Drizzle) — outbox pattern
  • Client sync : push outbox entries + pull changes avec idempotency keys
  • Reconciliation Inbox au premier merge (remap ID hardcode → ID serveur)
  • Polling periodique (2 min) + sync au lancement + sync au retour foreground
  • Gestion des conflits LWW
  • Ecran de premiere connexion : choix merge local ou reset serveur
  • Indicateur visuel de statut sync (header)
  • Mode degrade si serveur inaccessible (outbox s'accumule, sync au retour)

Ordre d'execution

Issue 1 (Types partages) ──┐
                            ├── Issue 4 (API REST) ── Issue 5 (WebSocket) ─┐
Issue 2 (Setup web + DB) ──┤                                                ├── Issue 6 (Frontend)
                            └── Issue 3 (Auth web) ─┘                      │
Issue 1 ── Issue 4 ── Issue 7 (Sync mobile)                                │

Fichiers concernes

Fichier Action Raison
web/ Creer Nouveau projet Next.js complet
src/shared/types.ts Creer Types TypeScript partages mobile/web
src/shared/colors.ts Creer Palette centralisee
src/shared/priority.ts Creer Helpers priorite partages
src/shared/recurrence.ts Creer Logique recurrence partagee
src/theme/colors.ts Modifier Importer depuis shared/
src/lib/priority.ts Modifier Importer depuis shared/
src/lib/recurrence.ts Modifier Importer depuis shared/
src/db/schema.ts Modifier Ajouter updatedAt aux tags
src/db/schema.ts Modifier Ajouter table sync_outbox
src/db/repository/tasks.ts Modifier Ecrire dans l'outbox a chaque mutation (si connecte)
src/services/syncClient.ts Creer Client sync (push outbox, pull changes, reconciliation Inbox)
src/stores/useSettingsStore.ts Modifier Ajouter syncEnabled, lastSyncAt, userId
app/(tabs)/settings.tsx Modifier Ajouter section Compte (login/logout/sync)
app/_layout.tsx Modifier Init sync polling si connecte
package.json Modifier Ajouter deps auth mobile (@logto/rn, expo-secure-store)

Criteres d'acceptation

  • Un utilisateur connecte peut acceder a ses listes depuis liste.lacompagniemaximus.com
  • Les operations CRUD fonctionnent pour les listes, taches, tags et sous-taches sur le web
  • Les modifications sur le web apparaissent en temps reel si deux onglets sont ouverts (WebSocket)
  • Les modifications sur le web apparaissent sur le mobile apres sync polling (et vice versa)
  • L'app mobile continue de fonctionner sans compte (offline-first preserve)
  • Au premier login mobile, l'utilisateur choisit de fusionner ou remplacer ses donnees locales
  • Le site fonctionne en FR et EN, en light et dark mode
  • Le site est responsive (utilisable sur mobile web)
  • Les suppressions se propagent correctement (soft-delete + sync)
  • Le WebSocket se reconnecte automatiquement apres une deconnexion
  • Les tokens mobile sont stockes dans expo-secure-store (pas AsyncStorage)
  • Le handshake WebSocket utilise un ticket ephemere (pas de cookie seul)
  • Les endpoints API rejettent les champs non autorises (userId, createdAt, deletedAt)
  • Le batch sync verifie l'ownership de chaque entite individuellement
  • Les soft-deletes sont purges automatiquement apres 30 jours

Edge cases et risques

Cas Mitigation
Conflit de modification simultanee (web + mobile) Last-write-wins sur updatedAt. Acceptable pour usage personnel.
Suppression sur un appareil, modification sur l'autre La suppression gagne (soft-delete prioritaire)
Serveur inaccessible depuis le mobile Mode degrade : l'app fonctionne en local, les changements sont queued et sync au retour
WebSocket deconnecte Reconnexion auto avec backoff exponentiel. Fallback : refresh manuel de la page
Premiere sync avec beaucoup de donnees locales Batch l'upload en chunks de 50 entites pour eviter les timeouts
Token JWT expire pendant une session mobile Refresh token avec rotation via Logto SDK. Si echec, re-login requis
Deux listes "Inbox" apres merge (locale + serveur) Remap ID hardcode mobile vers ID Inbox serveur dans toutes les taches locales avant push
Utilisateur supprime son compte Cascade delete de toutes les donnees sl_* du user dans PostgreSQL
Custom server Next.js + Coolify S'assurer que le Dockerfile utilise node server.ts et non next start. Verifier le support WebSocket dans Caddy.
Changements offline perdus (crash app) Outbox pattern : les mutations sont persistees dans sync_outbox SQLite avant envoi
Broadcast WS multi-instance Architecture single-instance pour la v1. Si scaling necessaire, ajouter Redis pub/sub
VPS memoire insuffisante Surveiller via /api/health. Prevoir upgrade 8 Go si migration famille-website en parallele
CSWSH (Cross-Site WebSocket Hijacking) Ticket ephemere a usage unique + validation Origin + cookies SameSite=Strict
BOLA sur le batch sync Verification d'ownership par entite dans chaque operation du batch
Replay attack sur POST /api/sync Idempotency keys par operation + fenetre temporelle +/- 5 min
Token mobile sur appareil roote Stockage dans expo-secure-store (Keychain/Keystore), jamais AsyncStorage
Session WS survit a l'expiration auth Re-validation serveur toutes les 15 min, fermeture si expiree
Fuite de donnees via messages WS Messages WS ne contiennent que type + id, pas le contenu des taches
Soft-deletes accumules indefiniment Purge automatique apres 30 jours, full sync pour clients inactifs
Mass assignment via l'API Schemas Zod strict, whitelist de champs modifiables, userId/createdAt/deletedAt non modifiables

Decisions prises

Question Decision Raison
Stack web Next.js App Router + Drizzle + PostgreSQL Fullstack dans un seul projet. Drizzle unifie mobile et serveur.
Emplacement du code simpl-liste/web/ dans le repo existant Partage des types via src/shared/. Un seul repo a maintenir.
ORM serveur Drizzle (pg-core) Meme ORM que le mobile (sqlite-core). Un seul outil a maitriser, schemas compatibles, pas de codegen.
Auth Logto (deploye) Systeme d'auth centralise La Compagnie Maximus.
Sync web WebSocket (via ws + custom server) Temps reel pour le web. Le VPS a largement les ressources (4 cores, 4 Go libres).
Sync mobile Polling periodique (2 min) React Native ne maintient pas de WebSocket en background. Polling simple et fiable.
Conflits Last-write-wins sur updatedAt Simple, suffisant pour un usage personnel avec peu d'appareils.
Suppressions Soft-delete (deletedAt) Necessaire pour propager les suppressions aux autres clients via sync.
Compte requis Non, optionnel Preserver l'experience offline-first sans friction. Le login debloque la sync.
Migration donnees locales Choix utilisateur (merge ou reset) Eviter la perte de donnees et laisser le controle a l'utilisateur.
Identite utilisateur sub Logto comme userId Compte Maximus unifie cross-app. Toutes les apps actuelles et futures utilisent le meme identifiant.
ORM unifie Drizzle partout (sqlite-core mobile + pg-core serveur) Un seul ORM, schemas compatibles, types inferes directement, pas de couche de mapping.
Reverse proxy Caddy (pas Traefik) Caddy est le reverse proxy en place sur le VPS. Support natif WebSocket.
Outbox pattern Table sync_outbox dans SQLite mobile Les changements offline survivent aux crashes. Idempotency keys integrees.
Scaling WS Single-instance, pas de Redis Suffisant pour l'usage actuel. Redis pub/sub a ajouter si multi-instance futur.
Monitoring Endpoint /api/health Metriques applicatives (DB, WS, API) en complement de vps-health-api (hardware).

References

Source Pertinence
Socket.IO + Next.js guide Pattern custom server pour WebSocket avec Next.js. On utilise ws directement (plus leger).
Next.js WebSocket discussion #58698 Confirme que les route handlers App Router ne supportent pas les WS — custom server necessaire.
PowerSync React Native SDK Alternative evaluee pour la sync. Trop lourd pour notre cas (single user). DIY LWW + tombstones suffit.
Expo local-first guide Patterns de sync offline-first avec expo-sqlite. Valide notre approche outbox + polling.
OWASP WebSocket Security Cheat Sheet CSWSH, auth WS, validation Origin — base de notre strategie de ticket ephemere.
OWASP API Security Top 10 BOLA (#1), Mass Assignment (#3) — guides les controles d'acces sur le batch sync.
CSWSH Exploitation in 2025 — Include Security Confirme que SameSite=Lax ne suffit pas si un sous-domaine est compromis.
Logto Security docs Rotation des cles, logout centralise, stockage securise des tokens.
Expo SecureStore API pour le stockage chiffre (Keychain iOS / Keystore Android) — adaptateur pour @logto/rn.

Estimation

7-10 sessions de travail, decoupees ainsi :

  • Issues 1-2 (setup) : 1-2 sessions
  • Issue 3 (auth) : 1 session
  • Issue 4 (API) : 2 sessions
  • Issue 5 (WebSocket) : 1 session
  • Issue 6 (frontend) : 2-3 sessions
  • Issue 7 (sync mobile) : 1-2 sessions