From ba363e7e18c85f336524c25123beea390ff536ee Mon Sep 17 00:00:00 2001 From: javier Date: Wed, 17 Jun 2026 19:36:35 +0200 Subject: [PATCH] docs: mobile offline-first sync protocol (Sanctum API tokens) Approved plan/protocol for connecting a mobile app to the webapp: offline-first with device outbox, PULL (bundle/delta/versioned templates/tombstones), PUSH (/api/v1/sync idempotent by client uuid), media via multipart, conflict policy, schema additions, security, and phased webapp deliverables. Auth decided: Laravel Sanctum API tokens. No implementation yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/MOBILE_SYNC_PROTOCOL.md | 112 +++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/MOBILE_SYNC_PROTOCOL.md diff --git a/docs/MOBILE_SYNC_PROTOCOL.md b/docs/MOBILE_SYNC_PROTOCOL.md new file mode 100644 index 0000000..45b755e --- /dev/null +++ b/docs/MOBILE_SYNC_PROTOCOL.md @@ -0,0 +1,112 @@ +# Protocolo de sincronización móvil offline-first + +> Estado: **plan aprobado** (2026-06-17). Auth decidida: **Laravel Sanctum (API tokens)**. +> Alcance de este documento: lo necesario **en la webapp** para que una app móvil +> descargue plantillas/datos, trabaje sin conexión y sincronice al recuperar red. +> No cubre la implementación de la app móvil (la consume este contrato). + +## 1. Modelo general + +Offline-first con **cola en el dispositivo (outbox)** + sync bidireccional: + +- **PULL (descarga):** la app baja un "paquete" del proyecto (estructura + plantillas + registros) para trabajar sin red. +- **Trabajo offline:** cada cambio se guarda local con un **UUID generado en el móvil** y se encola. +- **PUSH (subida):** al volver la conexión, la app envía la cola; el servidor hace *upsert idempotente* por UUID y responde resultado por ítem. +- Sincronización **delta** por `updated_at` (solo lo cambiado desde el último sync). + +## 2. Autenticación — Laravel Sanctum (decidido) + +- Instalar `laravel/sanctum`. Tokens personales por dispositivo (no SPA-cookie; modo **API token**). +- Endpoints: + - `POST /api/v1/login` — `{ email, password, device_name }` → `{ token, user }`. + - `POST /api/v1/logout` — revoca el token actual. + - `GET /api/v1/me` — usuario + permisos efectivos. +- El móvil envía `Authorization: Bearer `. +- Token con **abilities** (p. ej. `mobile-sync`) y **registro de dispositivo** (tabla `devices`) para revocar/caducar. +- Caducidad de token configurable + endpoint de refresco o re-login. + +## 3. Cambios de esquema + +Añadir a las tablas sincronizables (`features`, `inspections`, `issues`, `progress_updates`, `media`): + +- `uuid` CHAR(36) único — **lo genera el móvil**; permite crear offline y *upsert* idempotente. +- `updated_at` (ya existe) — delta + last-write-wins. +- `client_updated_at` TIMESTAMP nullable — marca de tiempo del dispositivo (resolución de conflictos). +- Soft-deletes (ya existen) — se exponen como **tombstones** (ids/uuids borrados) en el PULL. + +Tablas nuevas: +- `devices` (id, user_id, name, token_id, last_seen_at, …). +- `sync_logs` (auditoría: device, operación, entidad, uuid, resultado, timestamp). + +## 4. API (`routes/api.php`, prefijo `/api/v1`, stateless + Sanctum) + +### Descarga / PULL +- `GET /api/v1/projects` → proyectos accesibles (reusa `Project::accessibleBy`). +- `GET /api/v1/projects/{id}/bundle?since=` → **paquete offline** (delta si viene `since`). +- `GET /api/v1/templates?since=` → plantillas de inspección con `version`/`hash` (descarga incremental). +- `GET /api/v1/media/{id}` o URLs firmadas dentro del bundle → adjuntos existentes. + +Ejemplo de respuesta `bundle`: +```json +{ + "server_time": "2026-06-17T20:00:00Z", + "project": { "id": 1, "uuid": "…", "name": "…", "updated_at": "…" }, + "phases": [ { "id": 4, "name": "…", "updated_at": "…" } ], + "layers": [ { "id": 4, "phase_id": 4, "name": "…", "updated_at": "…" } ], + "features": [ { "id": 5, "uuid": "…", "layer_id": 4, "geometry": {…}, "status": "in_progress", "progress": 40, "updated_at": "…" } ], + "templates":[ { "id": 1, "version": 3, "fields": [ … ] } ], + "inspections": [ … ], + "issues": [ … ], + "deleted": { "features": ["uuid…"], "inspections": ["uuid…"] } +} +``` + +### Subida / PUSH +- `POST /api/v1/sync` — lote de operaciones (idempotente por `uuid`): +```json +{ "operations": [ + { "entity": "progress_update", "op": "create", "uuid": "…", "client_updated_at": "…", "data": { "phase_id": 4, "progress": 60, "comment": "…", "location": {…} } }, + { "entity": "inspection", "op": "create", "uuid": "…", "client_updated_at": "…", "data": { "feature_id": 5, "template_id": 1, "data": {…}, "result": "pass" } }, + { "entity": "feature", "op": "update", "uuid": "…", "client_updated_at": "…", "data": { "status": "completed", "progress": 100 } }, + { "entity": "issue", "op": "create", "uuid": "…", "client_updated_at": "…", "data": { "feature_id": 5, "title": "…", "priority": "high" } } +] } +``` +Respuesta por operación: +```json +{ "results": [ + { "uuid": "…", "status": "applied", "server_id": 123 }, + { "uuid": "…", "status": "duplicate", "server_id": 124 }, + { "uuid": "…", "status": "conflict", "server": { "status": "verified", "updated_at": "…" } }, + { "uuid": "…", "status": "error", "error": "validation: …" } +] } +``` +- `POST /api/v1/media` — **subida de fotos por multipart** (no base64), referenciando al padre por `uuid` (`parent_entity`, `parent_uuid`, `file`). Soporta reintento; troceado si el archivo es grande. + +## 5. Idempotencia y conflictos + +- **Idempotencia:** el `uuid` evita duplicados si se reenvía la cola (re-sync seguro). +- **Append-only (sin conflicto):** `progress_updates`, `inspections` → siempre insertan. +- **Editables (con política):** `feature.status/progress`, `issue` → **last-write-wins** comparando `client_updated_at` vs `updated_at` del servidor. Si el servidor es más nuevo → `conflict` y se devuelve el valor del servidor para que el móvil decida/avise. + +## 6. Seguridad + +- **Nunca** `Model::create($payloadCliente)` crudo. Usar FormRequests/DTO; fijar `project_id`/`user_id` **en el servidor** desde el contexto autorizado; validar que `feature/phase` pertenece a un proyecto del usuario (anti-IDOR). +- Autorizar cada operación con permisos Spatie (`update progress`, `create inspections`, …) + pertenencia al proyecto (`accessibleBy`). +- Rate limiting, caducidad de token, `sync_logs` para auditoría. + +## 7. Versionado + +- Prefijo `/api/v1`; cabecera `X-App-Version`; el servidor responde versión mínima soportada (forzar update del móvil). +- Versión/hash por plantilla (descarga incremental). + +## 8. Qué reutilizar / retirar + +- `OfflineSyncController` + `PendingSync`: el **vocabulario de acciones** (progress_update, inspection, feature_create, media_upload, task_complete) es buena base para las operaciones de `/sync`. Pero hay que: pasar a API+token, añadir uuid/validación/autorización, y **mover la cola al dispositivo** (la `PendingSync` del servidor deja de ser necesaria para el móvil; se puede retirar o reaprovechar como `sync_logs`). + +## 9. Entregables en la webapp (por fases) + +- **Fase A — Auth & esqueleto API:** Sanctum, `routes/api.php`, `login`/`logout`/`me`, tabla `devices`, abilities. +- **Fase B — PULL:** `projects`, `bundle` + delta, `templates` versionadas, tombstones. +- **Fase C — PUSH:** `/sync` idempotente con validación/autorización/conflictos (recoge y endurece la lógica actual). +- **Fase D — Media:** subida multipart + descarga. +- **Fase E — Endurecimiento + Docs:** rate-limit, `sync_logs`, OpenAPI/Swagger como contrato para el equipo móvil.