# Maintenance Team Leader (Web) — 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 scoping** (MTL = `own`; the server forces `gameZoneId = me.gameZoneId` on every WO read/write — the topbar badge is an indicator, not a filter the client supplies), audit-row-on-write (`WorkOrderEvent`), pagination, `If-Match`/ETag → 409, view-only photo access, standard error envelope.
> `Roles` column lists the roles whose **resolved permission** allows the call — documentation of the seed matrix, NOT a hardcoded gate. The MTL's effective access is "own Game Zone, work-order assign / review / close-internal / outsource".
> Base path: `/api/v1`. The Next.js app consumes these via a typed client (`packages/types` → OpenAPI → TS).
> **Work-Order flow + endpoints are defined in `FOUNDATION_SPEC §4a`** — this contract restates the MTL-facing subset plus the reads the screens need.

---

## 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 |

## Bootstrap / identity

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me` | any | — | `{ user, role:{code:"MTL",...}, gameZoneScope:"own", gameZone:{id,name:"Ahmedabad-1"}, reportsTo:{role:"SM",...}, nav:[...5 sidebar items, 2 groups...], permissions:[...] }` — **nav + scope server-resolved** |
| GET | `/me/profile` | any | — | `{ fullName, email, phone, role, gameZone, reportsTo }` (backs `SH-PROFILE`) |
| GET | `/me/notifications` | any | — | `{ items:[{ id, type, title, body, at, read }] }` — WO routed / returned / over-SLA alerts |
| POST | `/me/notifications/:id/read` | any | — | `204` |

## Dashboard (`MTL-DASH`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/mtl/dashboard` | MTL (scope=own) | — | `{ kpis:{ routed, assigned, inProgress, heldSourceChecklists, returned, closedToday, overdue }, queuePreview:[{ id, code, sourceTemplate, ride, aItemText, state, technician, ageMinutes, overSla }] }` — aggregates WOs for own GZ |

## Work-Order queue + detail (`MTL-WO-QUEUE`, `MTL-WO-DETAIL`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/work-orders?scope=own&state=&rideId=&technicianId=&priority=&page=&pageSize=` | MTL (own GZ) | — | `{ data:[{ id, code, sourceInstanceId, sourceTemplate, ride, aItemText, state, assignedTo, routedBy, createdAt, ageMinutes, overSla }], page, pageSize, total }` — `state` filter accepts the `WorkOrderState` enum + derived `overdue` |
| GET | `/work-orders/:id` | MTL (own GZ) | — | `{ id, code, state, version, sourceInstance:{ id, template, ride, shift, filler, isHeld, openWoCount }, aItem:{ id, text, note, recordedAt, initials }, issuePhotos:[{id,url,thumbUrl}], returnReason?, outsourceNote?, assignedTo?, routedBy }` — A-item/time/initials/photos **read-only** for MTL |
| GET | `/work-orders/:id/timeline` | MTL (own GZ) | — | `{ steps:[{ stage, actor, role, at, state, note? }], variant:"workorder" }` — backs the WorkOrder-variant `ApprovalTimeline` (Routed → Maintenance TL → Technician → Closed/Outsourced) |
| GET | `/work-orders/:id/photos` | MTL (own GZ) | — | `{ issue:[{id,url,thumbUrl,uploader,takenAt}], fix:[{id,url,thumbUrl,uploader,takenAt}], return:[{id,url,thumbUrl,uploader,takenAt}] }` — local-disk URLs; backs the photo-lightbox |

## Work-Order actions (`MTL-WO-ASSIGN`, `MTL-WO-REVIEW`, `MTL-WO-ESCALATE`) — `FOUNDATION_SPEC §4a`

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/technicians?scope=own` | MTL (own GZ) | — | `{ data:[{ id, fullName, openWoCount }] }` — technicians on this MTL's team in own GZ (`reportsToId = me`, `gameZoneId = me.gameZoneId`); powers the assign picker + load badges |
| PATCH | `/work-orders/:id/assign` | MTL (`workorder.assign`) | `If-Match: <version>` · `{ technicianId }` | `200 { state:"ASSIGNED" }` — `ROUTED → ASSIGNED` (or `RETURNED → ASSIGNED` for reassign); notifies the technician; writes `WorkOrderEvent` · 409 stale · 422 not assignable / tech not on team |
| PATCH | `/work-orders/:id/close` | MTL (`workorder.close`) | `If-Match: <version>` | `200 { state:"DONE", closedAt }` — MTL **closes internally** (e.g. a returned WO judged resolved); closes at maintenance level, **SM not notified**; if last open WO, release parent `HELD → TL_APPROVED` · 409 stale |
| PATCH | `/work-orders/:id/outsource` | MTL (`workorder.outsource`) | `If-Match: <version>` · `{ note }` | `200 { state:"OUTSOURCED", closedAt }` — `RETURNED → OUTSOURCED`; **the only state that reaches the SM** — surfaces on `SM-WO-OUTSOURCE`; if last open WO, release parent; writes `WorkOrderEvent` · **422** if `note` blank · 409 stale |

> **Locked rules (CL-2, `FOUNDATION_SPEC §4a`):** (a) **only `OUTSOURCED`** WOs reach the Store Manager — `DONE`/`close` closes silently at maintenance; (b) the parent checklist's `HELD`→release is computed from `WorkOrder.@@unique(responseId)` counts (no WO left in a non-terminal state); (c) `If-Match` guards against a tech moving the WO (e.g. returning it) while the MTL drawer is open.

## Endpoints the MTL consumes indirectly (state it depends on, owned elsewhere)

| Method | Path | Owner pack | Why the MTL cares |
|---|---|---|---|
| POST | `/work-orders` (one per A-response) | operations TL (`TL-CHK-VERIFY`) | **creates** the WOs the MTL queue lists; sets `ROUTED` + parent `HELD`. The MTL does **not** call this. |
| PATCH | `/work-orders/:id/start` | Technician (`technician-mobile`) | `ASSIGNED → IN_PROGRESS` — moves the WO out of the MTL's "to assign" bucket |
| PATCH | `/work-orders/:id/done` (+ `WorkOrderPhoto`, ≥1) | Technician | `IN_PROGRESS → DONE` — **photo-gated**; closes internally; MTL views the fix photo on review |
| PATCH | `/work-orders/:id/return` | Technician | `IN_PROGRESS → RETURNED { reason }` — lands in the MTL's Review queue |

## Team (`MTL-TEAM`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/technicians?scope=own&withLoad=true` | MTL (own GZ) | — | `{ data:[{ id, fullName, assigned, inProgress, returned, closedThisWeek, oldestOpenMinutes, overSla, available }] }` — read-only load view; no staffing actions |

---

## Notes
- **No fill / no approval endpoints** — the MTL is not in the G/A approval cascade (that's TL→SM→OH) and does not fill checklists. The resolver does not grant the MTL `checklist.fill` / `checklist.approve`.
- **No photo upload** — `POST /uploads/photos` / `WorkOrderPhoto` writes are Technician/filler-only. The MTL is view-only on evidence (`workorder.viewPhotos`).
- **Outsource is the single escalation channel:** `close` → `DONE`/Done (silent); `outsource` → `OUTSOURCED` → SM. There is no other MTL→SM WO write.
- All write endpoints emit a `WorkOrderEvent` audit row (`from`, `to`, `actor`, `note?`, `FOUNDATION_SPEC §1`).
- **MTL scope = own:** every read/write is server-forced to `me.gameZoneId`; an out-of-GZ WO id returns 403/empty (`FOUNDATION_SPEC §5`).

---

## Gaps flagged against `FOUNDATION_SPEC.md`

> Per the pack rule "do NOT invent new fields/endpoints; if something is missing, flag it." `FOUNDATION_SPEC §4a` enumerates the core WO transitions (`assign`, `start`, `done`, `return`, `outsource`). The following are **needed by MTL screens but not explicitly enumerated** there. None require new schema fields — they are reads/aggregations or a re-labelled transition. Foundation Squad to confirm/add before fan-out:

1. **`GET /mtl/dashboard` aggregation** — the WO state-machine KPIs (routed / assigned / in-progress / held-source / returned / closed-today / overdue) + queue preview. Pure aggregation over `WorkOrder` (+ `ChecklistInstance.state = HELD` for the held-source count). **No new fields.** Flagged.
2. **`PATCH /work-orders/:id/close` (MTL internal close)** — `§4a` lists Technician `done` (`IN_PROGRESS → DONE`) but the **MTL closing a *returned* WO internally** (`RETURNED → DONE`) is in the SCREEN_LIST review flow ("close internally") and `CL-2` ("internally-fixed actions close at the maintenance level") without a named MTL endpoint. Reuses the `DONE` terminal state + `WorkOrderEvent`; **no new fields**. Confirm whether this is a distinct `close` route or the same `done` handler with an MTL actor. Flagged.
3. **`GET /work-orders/:id/photos` split (issue / fix / return)** — `WorkOrderPhoto` exists; the review/escalate screens want issue vs fix vs return photos grouped. Derivable from `WorkOrderPhoto.kind` + the source A-item's `Photo{kind:NEGATIVE}`; confirm the grouping shape. **No new fields.** Flagged.
4. **WO `overSla` / age** — the queue + dashboard show age/SLA and an over-SLA flag. `WorkOrder.createdAt`/`routedAt` exist; there is **no defined SLA duration master** to compare against. **Possible config gap** — flag for the demo (a per-priority SLA config, or compute age only). Flagged as a **design question**, not assumed.

> Everything else (auth, `/me`, WO list/detail/timeline, assign/outsource, technicians list, photo viewer) maps onto `FOUNDATION_SPEC §1/§2/§4/§4a/§5` with no invented fields.
