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:
@@ -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 } }
|
||||
Reference in New Issue
Block a user