Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba363e7e18 |
@@ -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>`.
|
||||
- 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=<ISO8601>` → **paquete offline** (delta si viene `since`).
|
||||
- `GET /api/v1/templates?since=<ISO8601>` → 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.
|
||||
Reference in New Issue
Block a user