feat(api): mobile API Milestones 5+6 — media upload, sync_logs idempotency, OpenAPI

Milestone 5 (media):
- POST /api/v1/media — multipart upload, attaches to feature/issue/project/
  phase/layer, idempotent by uuid, authz member + 'upload media'. Added
  uuid+client_updated_at to media.
- Bundle now includes a 'media' array (URLs) for the project's project/feature/
  issue attachments (delta-aware).

Milestone 6 (hardening + docs):
- sync_logs table/model: every applied op is logged; /sync short-circuits on a
  repeated op uuid -> 'duplicate' (true idempotency for updates too, not just
  creates).
- Rate limiting on login (10/min), sync (60/min), media (120/min).
- docs/openapi.yaml: OpenAPI 3 contract for the mobile team.

Tests: 18 passing (added media upload idempotency + sync_logs idempotency).
The mobile API (Milestones 1-6) is now feature-complete on the webapp side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 10:23:50 +02:00
parent 9d2b63c8f4
commit 14758136b6
10 changed files with 490 additions and 3 deletions
+198
View File
@@ -0,0 +1,198 @@
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, 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, 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 } }
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 } }