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 `. 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 } }