# Technician (Mobile) — API Contract

> Every endpoint this role consumes. Consistent with `FOUNDATION_SPEC §2` conventions: Bearer JWT, **runtime RBAC resolver** (reads `RolePermission` per request — no hardcoded `if role==='X'`), **Game-Zone + assignment scoping** (Technician = own GZ + WOs assigned to him), audit-row-on-write (`WorkOrderEvent`), `If-Match`/ETag → 409, multipart photo upload, standard error envelope.
> `Roles` column lists the roles whose **resolved permission** allows the call — documentation of the seed matrix, NOT a hardcoded gate. The Technician's effective access is "own assigned work-orders / own Game Zone / own maintenance team" (`FOUNDATION_SPEC §5`).
> Base path: `/api/v1`. The Flutter app consumes these via generated Dart models (`packages/types` → OpenAPI → Dart).

---

## Auth

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| POST | `/auth/login` | public | `{ identifier, password }` (email or phone) | `{ accessToken, refreshToken, expiresIn }` · 401 bad creds · 423 locked · 429 rate-limited |
| POST | `/auth/refresh` | public (valid refresh) | `{ refreshToken }` | `{ accessToken, refreshToken, expiresIn }` (rotated; reuse → 401 + revoke) |
| POST | `/auth/logout` | any | `{ refreshToken }` | `204` (refresh revoked) |
| POST | `/auth/forgot-password` | public | `{ identifier }` | `204` (always; no account enumeration) |
| POST | `/auth/reset-password` | public (valid token/OTP) | `{ token, newPassword }` | `204` · 422 weak password |
| POST | `/devices/register` | any | `{ platform, fcmToken }` | `201 { id }` (upserts `DeviceToken` for FCM push) |

## Bootstrap / identity

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me` | any | — | `{ user, role:{code:"TECH",...}, gameZoneScope:"own", gameZone, reportsTo, skills:[...], nav:[...5 bottom-nav slots...], permissions:[...] }` — **nav + scope are server-resolved** |
| GET | `/me/profile` | any | — | `{ fullName, email, phone, role, gameZone, team:"Maintenance", reportsTo, skills:[...] }` (backs `SH-PROFILE`) |

## My dashboard + work-order list (`TECH-DASH`, `TECH-WO-TODAY`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/dashboard` | TECH (own) | — | `{ counts:{ assigned, inProgress, returned, done, overdue, dueSoon }, workOrders:[...preview...] }` — own GZ + WOs assigned to me |
| GET | `/me/work-orders?state=` | TECH (own) | — | `{ workOrders:[{ id, code, state, priority, ride, sourceChecklist:{name}, aItemText, issuePhotoCount, sla:{dueAt,ageHrs,overdue}, assignedBy }] }` — own assignments only; `state` filter accepts `assigned`/`in_progress`/`returned`/`overdue` |

## Work-order detail (`TECH-WO-DETAIL`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/work-orders/:id` | TECH (own assignee) | — | `{ id, code, state, version, gameZone, ride, sourceChecklist:{ id, name }, aItem:{ id, text, note }, issuePhotos:[{id,url,thumbUrl}], instructions, routedBy, assignedBy, returnHistory:[...], timeline:[{ state, actor, role, at }] }` · 403 if not the assignee / out of GZ |

## Work-order transitions (`TECH-WO-DETAIL`, `TECH-WO-DONE`, `TECH-WO-RETURN`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| PATCH | `/work-orders/:id/start` | TECH (own assignee) | `If-Match: <version>` | `200 { state:"IN_PROGRESS", startedAt, version }` · 409 stale · 403 not assignee |
| PATCH | `/work-orders/:id/done` | TECH (own assignee) | `If-Match: <version>` · `{ fixNote? }` | `200 { state:"DONE", closedAt, version, parentReleased:bool }` · **422** `{ fields:{ fixPhoto } }` if no `WorkOrderPhoto` exists · closes at maintenance level; releases parent `HELD` if last open WO |
| PATCH | `/work-orders/:id/return` | TECH (own assignee) | `If-Match: <version>` · `{ reason }` | `200 { state:"RETURNED", version }` · **422** `{ fields:{ reason } }` if reason empty · notifies the Maintenance TL |

> **Done gate (server-enforced, §4a/L7, mirrored for WOs):** `/done` rejects with **422** unless ≥1 `WorkOrderPhoto` (`kind: COMPLETION`) exists for the WO. The UI mirrors this but never bypasses it.
> **Return gate:** `/return` rejects with **422** unless a non-empty `reason` is provided. Reason is stored on `WorkOrder.returnReason` + a `WorkOrderEvent`.

## Fix-photo upload (`TECH-WO-DONE`, optional on Return)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| POST | `/uploads/photos` | TECH (own assignee) | `multipart/form-data`: `file`, `workOrderId`, `kind:"COMPLETION"`, `device?` | `201 { id, url, thumbUrl, kind, workOrderId }` — stored to **local disk on VPS** as a `WorkOrderPhoto` |
| DELETE | `/uploads/photos/:id` | TECH (own uploader) | — | `204` (remove a staged photo before Done) |

## Notifications (`SH-NOTIF`, top-bar bell)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/notifications` | any | — | `{ items:[{ id, type:"WO_ASSIGNED"|"WO_REASSIGNED"|"WO_DUE_SOON"|"WO_OVERDUE", title, body, at, read }] }` |
| POST | `/me/notifications/:id/read` | any | — | `204` |

---

## Notes
- **No approve / route / assign / outsource endpoints** appear here — the Technician cannot approve a checklist or escalate (`§3` + CL-2). `POST /work-orders` (route, ops TL), `/assign` + `/outsource` (Maintenance TL) live in the operations / `maintenance-tl-web` packs; the resolver returns **403** for TECH.
- **Assignment is received, not made:** the Maintenance TL's `PATCH /work-orders/:id/assign { technicianId }` sets `assignedToId` + notifies the Technician — surfaced here via `/me/notifications` + `/me/work-orders` (`HANDOFFS.md`).
- **Done closes at maintenance:** only `OUTSOURCED` WOs reach the Store Manager (`SM-WO-OUTSOURCE`); a Technician `DONE` closes silently at the maintenance level.
- All write endpoints emit a `WorkOrderEvent` audit row (`FOUNDATION_SPEC §4a`).
- Every read/write is Game-Zone + assignee-scoped server-side; passing another technician's WO id → 403.
