feat: scaffold offline-first mobile app (RN+Expo, expo-sqlite)
Capa API tipada de los 8 endpoints, BD local espejo del bundle + outbox (operaciones y media) + cursor de sync, motor runSync (PUSH /sync -> PUSH /media -> PULL bundle?since) con idempotencia por uuid y last-write-wins, mutaciones de alto nivel (write local + encolar), sesion con token en SecureStore, conectividad NetInfo y UI minima (Login -> Proyectos -> Detalle). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3f454b59a5
commit
4e9c7d059f
@@ -0,0 +1,212 @@
|
||||
# ConstruProgress — Brief para la App Móvil
|
||||
|
||||
Documento único de traspaso para construir la app móvil que consume la API de
|
||||
ConstruProgress. La **fuente de verdad** del contrato es [`openapi.yaml`](openapi.yaml);
|
||||
el modelo offline está en [`MOBILE_SYNC_PROTOCOL.md`](MOBILE_SYNC_PROTOCOL.md). Este
|
||||
brief los resume y añade ejemplos de payloads reales y el modelo de datos.
|
||||
|
||||
> Para trabajar en el repo móvil con Claude Code viendo este backend:
|
||||
> `claude --add-dir C:\xampp\htdocs\construprogress`
|
||||
|
||||
---
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
App de **seguimiento de obra** que funciona **sin conexión** en campo: descarga los
|
||||
datos de un proyecto (estructura + plantillas), permite trabajar offline (actualizar
|
||||
progreso, registrar inspecciones, gestionar incidencias con tareas/comentarios/fotos)
|
||||
y **sincroniza cuando hay red**.
|
||||
|
||||
## 2. Autenticación (Laravel Sanctum)
|
||||
|
||||
- Token Bearer **por dispositivo**, con ability `mobile-sync`.
|
||||
- `POST /login` con `{ email, password, device_name, app_version? }` → `{ token, user }`.
|
||||
- En el resto de llamadas: cabecera `Authorization: Bearer <token>`.
|
||||
- `POST /logout` revoca el token del dispositivo actual.
|
||||
- Guarda el token en almacenamiento seguro (Expo SecureStore / flutter_secure_storage).
|
||||
|
||||
**Base URL:** `https://<host>/api/v1` (confirmar host de despliegue; en local XAMPP
|
||||
suele ser `http://localhost/construprogress/public/api/v1`).
|
||||
|
||||
```bash
|
||||
curl -X POST https://<host>/api/v1/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@mai.group","password":"secret","device_name":"Pixel-8"}'
|
||||
# → { "token": "12|abc...", "user": { "id":1, "name":"...", "roles":[...], "permissions":[...] } }
|
||||
```
|
||||
|
||||
## 3. Endpoints (8)
|
||||
|
||||
| Método | Ruta | Uso | Rate limit |
|
||||
|---|---|---|---|
|
||||
| POST | `/login` | Token de dispositivo | 10/min |
|
||||
| GET | `/me` | Usuario + permisos | — |
|
||||
| POST | `/logout` | Revocar token | — |
|
||||
| GET | `/projects` | Proyectos accesibles | — |
|
||||
| GET | `/projects/{id}/bundle?since=` | PULL: snapshot o delta + tombstones | — |
|
||||
| GET | `/templates?since=` | Plantillas de inspección (version+hash) | — |
|
||||
| POST | `/sync` | PUSH: lote de mutaciones offline | 60/min |
|
||||
| POST | `/media` | Subir fichero (multipart) | 120/min |
|
||||
|
||||
## 4. PULL — descarga de datos
|
||||
|
||||
### 4.1 Primera sincronización (snapshot completo)
|
||||
`GET /projects/{id}/bundle` devuelve:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"server_time": "2026-06-18T12:00:00+00:00", // úsalo como próximo `since`
|
||||
"project": { ... },
|
||||
"phases": [ ... ],
|
||||
"layers": [ ... ],
|
||||
"features": [ ... ],
|
||||
"inspections": [ ... ],
|
||||
"issues": [ ... ],
|
||||
"issue_tasks": [ ... ],
|
||||
"issue_comments": [ ... ],
|
||||
"templates": [ ... ],
|
||||
"media": [ ... ],
|
||||
"deleted": {} // vacío en snapshot completo
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Sincronizaciones siguientes (delta)
|
||||
`GET /projects/{id}/bundle?since=<ISO8601 URL-encoded>` → solo lo cambiado tras `since`
|
||||
y un objeto `deleted` con los **ids borrados** (tombstones) por entidad:
|
||||
|
||||
```jsonc
|
||||
"deleted": {
|
||||
"phases": [], "layers": [], "features": [], "inspections": [],
|
||||
"issues": [], "issue_tasks": [], "issue_comments": []
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **URL-encodea el `since`** (el `+` del offset horario). Guarda `server_time` de
|
||||
> cada respuesta y úsalo como el siguiente `since`.
|
||||
|
||||
### 4.3 Plantillas
|
||||
`GET /templates?since=` devuelve las plantillas de inspección de los proyectos
|
||||
accesibles, cada una con `version` (timestamp) y `hash` (para detectar cambios).
|
||||
|
||||
## 5. PUSH — `POST /sync`
|
||||
|
||||
Envía un lote. Cada operación lleva una **`uuid` generada en el cliente** (clave de
|
||||
idempotencia) y `client_updated_at`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"operations": [
|
||||
{
|
||||
"entity": "feature",
|
||||
"op": "update",
|
||||
"uuid": "0f8e2b6c-....", // único y estable por operación
|
||||
"client_updated_at": "2026-06-18T11:30:00+00:00",
|
||||
"data": { "id": 5, "status": "completed", "progress": 100 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Respuesta — **un resultado por operación**:
|
||||
```jsonc
|
||||
{ "results": [
|
||||
{ "uuid": "0f8e...", "status": "applied", "server_id": 5 }
|
||||
] }
|
||||
```
|
||||
`status` ∈ `applied | duplicate | conflict | error`.
|
||||
- `duplicate`: ya se había aplicado esa `uuid` (reintento seguro).
|
||||
- `conflict`: el servidor es más nuevo → trae `"server": {...}` con el valor actual
|
||||
(last-write-wins por `client_updated_at`). Resuélvelo en el cliente y reintenta.
|
||||
- `error`: trae `"error": "..."` (validación o permisos).
|
||||
|
||||
### 5.1 Operaciones soportadas (entity.op → data → permiso requerido)
|
||||
|
||||
| entity.op | `data` | Permiso |
|
||||
|---|---|---|
|
||||
| `progress_update.create` | `{ phase_id, progress(0-100), comment?, location? }` | `update progress` |
|
||||
| `feature.update` | `{ id, status?, progress?(0-100), responsible? }` | `update progress` |
|
||||
| `inspection.create` | `{ feature_id, template_id?, data?, status?, result?, notes? }` | `create inspections` |
|
||||
| `issue.create` | `{ project_id, feature_id?, title, description?, priority?, status?, type? }` | `create issues` |
|
||||
| `issue.update` | `{ id, title?, description?, priority?, status?, type?, assigned_to?, resolution_notes? }` | `edit issues` |
|
||||
| `issue_task.create` | `{ issue_id, title, assigned_to?, due_date?, is_done? }` | `edit issues` |
|
||||
| `issue_task.update` | `{ id, title?, assigned_to?, due_date?, is_done? }` | `edit issues` |
|
||||
| `issue_comment.create` | `{ issue_id, body }` | `view issues` |
|
||||
|
||||
Valores enum:
|
||||
- `issue.priority`: `low | medium | high | critical`
|
||||
- `issue.status`: `open | in_review | resolved | closed`
|
||||
- `issue.type`: `defect | safety | quality | documentation | other`
|
||||
|
||||
> El servidor SIEMPRE fija `user_id`/`reported_by`/`project_id` y valida permiso +
|
||||
> pertenencia al proyecto. El cliente nunca los manda.
|
||||
|
||||
## 6. Media — `POST /media` (multipart/form-data)
|
||||
|
||||
Campos: `uuid` (idempotencia), `parent_entity`, `parent_id`, `file`, `category?`
|
||||
(`image|document|other`), `description?`.
|
||||
|
||||
`parent_entity` ∈ `feature | issue | issue_task | issue_comment | project | phase | layer`.
|
||||
|
||||
```bash
|
||||
curl -X POST https://<host>/api/v1/media \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-F "uuid=4b1f...-uuid" \
|
||||
-F "parent_entity=issue" -F "parent_id=12" \
|
||||
-F "category=image" -F "file=@/path/defecto.jpg"
|
||||
# → { "status":"applied", "media": { "id":99, "url":"/storage/...", ... } }
|
||||
```
|
||||
Requiere permiso `upload media` + pertenencia al proyecto. Idempotente por `uuid`.
|
||||
|
||||
## 7. Modelo de datos (campos que devuelve el bundle)
|
||||
|
||||
```
|
||||
project : id, reference, name, address, lat, lng, status, updated_at
|
||||
phase : id, name, order, color, progress_percent, updated_at
|
||||
layer : id, phase_id, name, color, updated_at
|
||||
feature : id, layer_id, name, geometry(GeoJSON), status, progress,
|
||||
responsible, template_id, updated_at
|
||||
inspection : id, feature_id, layer_id, template_id, user_id, data(obj),
|
||||
status, result, notes, created_at, updated_at
|
||||
issue : id, feature_id, title, description, status, priority, type,
|
||||
reported_by, assigned_to, resolved_at, updated_at
|
||||
issue_task : id, issue_id, title, is_done, done_at, done_by, assigned_to,
|
||||
due_date, order, updated_at
|
||||
issue_comment : id, issue_id, user_id, body, created_at, updated_at
|
||||
template : id, project_id, phase_id, name, description, fields(array),
|
||||
version, hash, updated_at
|
||||
media : id, uuid, parent_entity, parent_id, url, name, file_type,
|
||||
category, updated_at
|
||||
```
|
||||
|
||||
## 8. Arquitectura cliente recomendada
|
||||
|
||||
**Stack:** React Native + Expo (alternativa: Flutter). Offline-first:
|
||||
|
||||
1. **BD local**: SQLite (expo-sqlite / WatermelonDB) — o Drift/Isar en Flutter.
|
||||
Refleja las entidades del bundle.
|
||||
2. **Sincronización PULL**: guarda `server_time`; en cada arranque/con red llama a
|
||||
`bundle?since=<último server_time>`, aplica upserts y borra los `deleted`.
|
||||
3. **Outbox (cola de salida)**: cada cambio offline genera una operación con `uuid`
|
||||
propio y se encola. Con red, envías el lote a `/sync` y procesas los resultados:
|
||||
`applied/duplicate` → marcar enviado; `conflict` → re-mergear; `error` → revisar.
|
||||
4. **Media**: sube los ficheros pendientes a `/media` (también con `uuid`) y referencia
|
||||
la `url` devuelta.
|
||||
5. **Token**: en almacenamiento seguro; si 401 → re-login.
|
||||
|
||||
### Flujo típico de sesión
|
||||
```
|
||||
login → guardar token
|
||||
GET /projects → elegir proyecto
|
||||
GET /projects/{id}/bundle (sin since) → poblar BD local
|
||||
... trabajo offline (encolar operaciones + fotos) ...
|
||||
con red: POST /sync (lote) → POST /media (ficheros) → GET bundle?since=server_time
|
||||
```
|
||||
|
||||
## 9. Checklist de arranque del repo móvil
|
||||
- [ ] Elegir stack (RN+Expo / Flutter) y crear el proyecto.
|
||||
- [ ] `claude --add-dir C:\xampp\htdocs\construprogress` para tener el contrato a mano.
|
||||
- [ ] Capa de API (login/me/logout, projects, bundle, templates, sync, media).
|
||||
- [ ] BD local + repositorios por entidad.
|
||||
- [ ] Motor de sync (PULL delta + outbox PUSH + media) con manejo de conflictos.
|
||||
- [ ] UI: lista de proyectos, mapa/fases, inspecciones, incidencias (checklist,
|
||||
comentarios, fotos), indicador de estado de sincronización.
|
||||
@@ -0,0 +1,126 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## Addendum (2026-06-18): Incidencias enriquecidas — tareas, comentarios y fotos
|
||||
|
||||
El detalle de una incidencia incluye ahora un **checklist de tareas** y un **hilo de comentarios**, ambos con fotos. Todo es sincronizable offline:
|
||||
|
||||
- **Nuevas entidades de PULL** en el `bundle` (y en `deleted`): `issue_tasks`, `issue_comments`.
|
||||
- **Nuevas operaciones de PUSH** en `/sync` (idempotentes por `uuid`):
|
||||
- `issue_task.create` — `data`: `issue_id`, `title`, `assigned_to?`, `due_date?`, `is_done?`. Requiere `edit issues`.
|
||||
- `issue_task.update` — `data`: `id`, y cualquiera de `title`/`assigned_to`/`due_date`/`is_done`. Last-write-wins por `client_updated_at`. Requiere `edit issues`.
|
||||
- `issue_comment.create` — `data`: `issue_id`, `body`. Requiere `view issues`.
|
||||
- **Fotos**: `POST /media` admite `parent_entity` = `issue_task` y `issue_comment` (además de `issue`). Requiere `upload media`.
|
||||
- El **% de avance** de la incidencia se deriva de las tareas completadas (no se almacena ni se sincroniza).
|
||||
@@ -0,0 +1,202 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: ConstruProgress Mobile API
|
||||
version: "1.0.0"
|
||||
description: >
|
||||
Offline-first sync API for the mobile app. Auth via Laravel Sanctum bearer
|
||||
tokens (ability `mobile-sync`). All protected endpoints require
|
||||
`Authorization: Bearer <token>`. See docs/MOBILE_SYNC_PROTOCOL.md.
|
||||
servers:
|
||||
- url: /api/v1
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/login:
|
||||
post:
|
||||
summary: Issue a device token
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email, password, device_name]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
password: { type: string }
|
||||
device_name: { type: string }
|
||||
app_version: { type: string, nullable: true }
|
||||
responses:
|
||||
"200":
|
||||
description: Token issued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token: { type: string }
|
||||
user: { $ref: '#/components/schemas/User' }
|
||||
"422": { description: Invalid credentials }
|
||||
/me:
|
||||
get:
|
||||
summary: Current user + effective permissions
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user: { $ref: '#/components/schemas/User' }
|
||||
"401": { description: Unauthenticated }
|
||||
/logout:
|
||||
post:
|
||||
summary: Revoke the current device token
|
||||
responses:
|
||||
"200": { description: Logged out }
|
||||
/projects:
|
||||
get:
|
||||
summary: Projects the user can access
|
||||
responses:
|
||||
"200": { description: OK }
|
||||
/projects/{project}/bundle:
|
||||
get:
|
||||
summary: Offline bundle (full, or delta when `since` is given)
|
||||
parameters:
|
||||
- name: project
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- name: since
|
||||
in: query
|
||||
required: false
|
||||
description: >
|
||||
ISO8601 timestamp. Returns only records changed after it, plus
|
||||
`deleted` tombstones. MUST be URL-encoded (the `+` offset).
|
||||
schema: { type: string, format: date-time }
|
||||
responses:
|
||||
"200":
|
||||
description: Bundle
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Bundle' }
|
||||
"403": { description: Not a member of the project }
|
||||
/templates:
|
||||
get:
|
||||
summary: Inspection templates for accessible projects (with version/hash)
|
||||
parameters:
|
||||
- name: since
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string, format: date-time }
|
||||
responses:
|
||||
"200": { description: OK }
|
||||
/sync:
|
||||
post:
|
||||
summary: Push a batch of offline mutations (idempotent by uuid)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [operations]
|
||||
properties:
|
||||
operations:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Operation' }
|
||||
responses:
|
||||
"200":
|
||||
description: Per-operation results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
results:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/OperationResult' }
|
||||
/media:
|
||||
post:
|
||||
summary: Upload a file (multipart) and attach it to a parent record
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [uuid, parent_entity, parent_id, file]
|
||||
properties:
|
||||
uuid: { type: string, format: uuid }
|
||||
parent_entity: { type: string, enum: [feature, issue, issue_task, issue_comment, project, phase, layer] }
|
||||
parent_id: { type: integer }
|
||||
file: { type: string, format: binary }
|
||||
category: { type: string, enum: [image, document, other] }
|
||||
description: { type: string }
|
||||
responses:
|
||||
"200": { description: applied | duplicate }
|
||||
"403": { description: Forbidden }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
schemas:
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
email: { type: string }
|
||||
roles: { type: array, items: { type: string } }
|
||||
permissions: { type: array, items: { type: string } }
|
||||
Operation:
|
||||
type: object
|
||||
required: [entity, op, uuid, data]
|
||||
properties:
|
||||
entity: { type: string, enum: [progress_update, inspection, issue, issue_task, issue_comment, feature] }
|
||||
op: { type: string, enum: [create, update] }
|
||||
uuid: { type: string, format: uuid, description: client-generated idempotency key }
|
||||
client_updated_at: { type: string, format: date-time }
|
||||
data: { type: object }
|
||||
example:
|
||||
entity: feature
|
||||
op: update
|
||||
uuid: 0f8e...-uuid
|
||||
client_updated_at: "2026-06-18T12:00:00+00:00"
|
||||
data: { id: 5, status: completed, progress: 100 }
|
||||
OperationResult:
|
||||
type: object
|
||||
properties:
|
||||
uuid: { type: string, format: uuid }
|
||||
status: { type: string, enum: [applied, duplicate, conflict, error] }
|
||||
server_id: { type: integer, nullable: true }
|
||||
error: { type: string, nullable: true }
|
||||
server: { type: object, nullable: true, description: current server value on conflict }
|
||||
Bundle:
|
||||
type: object
|
||||
properties:
|
||||
server_time: { type: string, format: date-time }
|
||||
project: { type: object }
|
||||
phases: { type: array, items: { type: object } }
|
||||
layers: { type: array, items: { type: object } }
|
||||
features: { type: array, items: { type: object } }
|
||||
inspections: { type: array, items: { type: object } }
|
||||
issues: { type: array, items: { type: object } }
|
||||
issue_tasks: { type: array, items: { type: object } }
|
||||
issue_comments: { type: array, items: { type: object } }
|
||||
templates: { type: array, items: { type: object } }
|
||||
media: { type: array, items: { type: object } }
|
||||
deleted:
|
||||
type: object
|
||||
description: tombstones (ids of soft-deleted records) when `since` is given
|
||||
properties:
|
||||
phases: { type: array, items: { type: integer } }
|
||||
layers: { type: array, items: { type: integer } }
|
||||
features: { type: array, items: { type: integer } }
|
||||
inspections: { type: array, items: { type: integer } }
|
||||
issues: { type: array, items: { type: integer } }
|
||||
issue_tasks: { type: array, items: { type: integer } }
|
||||
issue_comments: { type: array, items: { type: integer } }
|
||||
Reference in New Issue
Block a user