# Operation Head (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** (OH = `all`, no GZ filter; the topbar selector is a convenience query param, not a security boundary), audit-row-on-write, pagination, `If-Match`/ETag → 409, multipart photo upload (view-only for OH), standard error envelope.
> `Roles` column lists the roles whose **resolved permission** allows the call — it is documentation of the seed matrix, NOT a hardcoded gate. The OH's effective access is "all Game Zones, final approval, user/role/GZ management".
> Base path: `/api/v1`. The Next.js app consumes these via a typed client (`packages/types` → OpenAPI → TS).
> **`?gameZoneId=` on OH list endpoints** is the topbar selector: omitted = all branches; set = one branch. The server still verifies OH scope = `all` (any GZ allowed), so it never widens a lower role.

---

## 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:"OH",...}, gameZoneScope:"all", gameZone:null, reportsTo:null, nav:[...8 sidebar items, 3 groups...], permissions:[...] }` — **nav + scope are server-resolved** |
| GET | `/me/profile` | any | — | `{ fullName, email, phone, role, gameZoneScope:"all" }` (backs `SH-PROFILE`) |
| GET | `/me/notifications` | any | — | `{ items:[{ id, type, title, body, at, read }] }` |
| POST | `/me/notifications/:id/read` | any | — | `204` |

## Dashboard (`OH-DASH`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/oh/dashboard` | OH (scope=all) | — | `{ kpis:{ pending, overdue, doneToday, compliancePct, rosterGaps }, pendingByFrequency:{ daily, weekly, monthly, quarterly, annual }, gameZones:[{ id, name, storeManager, compliancePct, pending, overdue, rosterGaps }] }` — aggregates all GZ; `?gameZoneId=` narrows to one |

## Game Zones (`OH-GZ-*`, `OH-RIDE-MASTER`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/game-zones?page=&pageSize=&q=&status=` | OH | — | `{ data:[{ id, name, location, capacity, storeManager, staffCount, rideCount, compliancePct, isActive }], page, pageSize, total }` |
| GET | `/game-zones/:id` | OH | — | `{ id, name, location, capacity, storeManager, rides:[...], staff:[...], rosterSummary, checklistSummary }` (backs `OH-GZ-DETAIL` tabs) |
| POST | `/game-zones` | OH (`gamezone.create`) | `{ name, location?, capacity?, storeManagerId? }` | `201 { id }` · 422 duplicate name · enforces `storeManagerId @unique` |
| PATCH | `/game-zones/:id` | OH (`gamezone.edit`) | `If-Match` · `{ name?, location?, capacity?, storeManagerId?, isActive? }` | `200 { id }` · 409 stale · reassign-SM frees the previous SM's scope |
| GET | `/users?role=SM&unassignedGz=true` | OH | — | `{ data:[{ id, fullName }] }` — SM-role users not already managing a GZ (assign-SM modal) |
| GET | `/game-zones/:id/rides` | OH | — | `{ rides:[{ id, name, code, isActive }] }` |
| POST | `/game-zones/:id/rides` | OH (`ride.create`) | `{ name, code }` | `201 { id }` · 422 duplicate code in GZ (`Ride @@unique([gameZoneId,code])`) |
| PATCH | `/rides/:id` | OH (`ride.edit`) | `{ name?, code?, isActive? }` | `200 { id }` (deactivate = soft) |

## Users & hierarchy (`OH-USERS`, `OH-USER-*`, `OH-HIER-TREE`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/users?page=&pageSize=&q=&gameZoneId=&role=` | OH (all GZ) | — | `{ data:[{ id, fullName, role, gameZone, reportsTo, certifiedRides:[...], isActive }], page, pageSize, total }` |
| GET | `/users/:id` | OH | — | `{ ...profile, role, gameZone, reportsTo, reports:[...], certifiedRides:[...], checklistHistory:[{ id, template, state, submittedAt }] }` (backs `OH-USER-DETAIL`) |
| POST | `/users` | OH (`user.manage`, scope=all) | `{ fullName, email, phone?, password, roleId, gameZoneId?, reportsToId?, certifiedRideIds:[...] }` | `201 { id }` · 422 (email/phone unique, invalid reports-to for role/GZ) |
| PATCH | `/users/:id` | OH (`user.manage`) | `If-Match` · `{ roleId?, reportsToId?, gameZoneId?, certifiedRideIds?, isActive? }` | `200 { id }` · 409 stale · reassign-manager + deactivate |
| GET | `/managers?roleId=&gameZoneId=` | OH | — | `{ data:[{ id, fullName, role }] }` — valid reports-to candidates for a role in a GZ (the §3 hierarchy filter for the onboarding picker) |
| GET | `/hierarchy?gameZoneId=` | OH | — | `{ tree:[{ id, fullName, role, gameZone, children:[...] }] }` — org-wide 7-level tree; `gameZoneId` filters to one branch |

## Roles & RBAC matrix (`OH-ROLES`) — dynamic, live-editable

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/roles` | OH | — | `{ roles:[{ id, code, name, level, isApprover }] }` |
| GET | `/permissions` | OH | — | `{ permissions:[{ id, key, module, action, description }] }` |
| GET | `/roles/matrix` | OH (`role.manage`) | — | `{ rows:[{ permission:{key,module,action}, cells:[{ roleId, allowed, scope }] }] }` — the full editable grid |
| PATCH | `/roles/:roleId/permissions` | OH (`role.manage`) | `{ changes:[{ permissionId, allowed, scope }] }` | `200 { updated }` · writes `RolePermission` (+ `updatedBy` audit); **effective on the next request, no redeploy** (`FOUNDATION_SPEC §2`, L4) |
| POST | `/roles/matrix/reset` | OH (`role.manage`) | — | `200 { ok }` — restores the `PROJECT_PLAN §3` seed matrix |

## Roster overview (`OH-ROSTER-ALL`) — read-only

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/roster?scope=all&date=YYYY-MM-DD&gameZoneId=` | OH (all GZ) | — | `{ entries:[{ id, date, gameZone, ride, shift:{name,start,end}, user, isOpener }], gaps:[{ gameZone, ride, shift, date }] }` — read across zones; `gaps` powers the gap-alert badge |
| GET | `/roster?scope=all&weekOf=YYYY-MM-DD&gameZoneId=` | OH (all GZ) | — | `{ entries:[...7 days...], gaps:[...] }` (day/week/month toggle) |

> Read-only for OH — no roster create/edit/publish endpoints here (those live in SM/TL; publish fires auto-assign). The resolver allows OH `roster.view` at `all` scope.

## Checklist overview + cascade (`OH-CHK-OVERVIEW`, `OH-CHK-APPROVE`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/checklist-instances?scope=all&gameZoneId=&rideId=&shiftId=&period=&status=&page=&pageSize=` | OH (all GZ) | — | `{ data:[{ id, gameZone, template:{name,frequency}, ride, shift, filler, state, dueAt, statusForViewer }], page, pageSize, total }` — `statusForViewer` is **Pending** for anything not yet OH-approved (`renderStatusForViewer()`, L8); `status=pending`/`overdue` filters accept the derived values |
| GET | `/checklist-instances/:id` | OH (all GZ) | — | `{ id, gameZone, template, ride, periodStart, periodEnd, dueAt, state, version, sections:[{ title, items:[{ id, text, testMethod, response:{value,note,recordedAt,initials}, aPhoto:{id,url,thumbUrl} }] }], completionPhotos:[{id,url,thumbUrl}] }` — items/time/initials/photos **read-only** for OH |
| GET | `/checklist-instances/:id/timeline` | OH (all GZ) | — | `{ steps:[{ level, role, actor, at, state }], sentBackReason? }` — backs the `ApprovalTimeline` (OH sees the full real cascade) |
| POST | `/checklist-instances/:id/approve` | OH (`checklist.approve`, level 3) | `If-Match: <version>` | `200 { state:"OH_APPROVED", decidedAt }` — **terminal = Done**; writes `ApprovalLog` + `ApprovalStep[3].APPROVE`; drops off Pending · 409 stale · 422 if not at the OH step (`SM_APPROVED`) |
| POST | `/checklist-instances/:id/sendback` | OH (`checklist.approve`) | `If-Match: <version>` · `{ reason }` | `200 { state:"SENT_BACK" }` — returns to the **original filler** (§9 #3); notifies them; re-submit restarts from TL (§9 #8) · **422** if `reason` blank · 409 stale |

> **Approve is terminal:** OH approval sets `OH_APPROVED` (= Done). There is no level above OH. Send-back at the OH level behaves exactly like TL/SM send-back — to the original filler, not the previous approver (the cascade producer/handoff contract is in `HANDOFFS.md` + `FOUNDATION_SPEC §4`).

## Checklist templates — Upload Check List (CRUD + assign) (`OH-CHK-UPLOAD`, `OH-CHK-TPL-ASSIGN`, `OH-CHK-TPL-EDIT`) *(CL-1)*

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/checklist-templates?scope=all&gameZoneId=&frequency=&rideId=&source=&page=&pageSize=` | OH (all GZ) | — | `{ data:[{ id, name, code, frequency, ride, fillRoleCode, gameZoneId, source:"seeded"|"custom", isActive, version }], page, pageSize, total }` — seeded (`gameZoneId=null`) + custom across all GZ |
| GET | `/checklist-templates/:id` | OH (all GZ) | — | `{ id, name, code, category, frequency, rideId, fillRoleCode, gameZoneId, isOpenerOnly, isActive, version, sections:[{ id, title, order, items:[{ id, text, testMethod, inputType:"GA"|"TEXT", requiresPhotoOnA, order }] }] }` — backs the builder |
| POST | `/checklist-templates` | OH (`checklist.template.manage`, scope=all) | `{ name, frequency, rideId?, fillRoleCode, gameZoneId?, sections:[{ title, items:[{ text, testMethod?, inputType, requiresPhotoOnA }] }], isActive? }` | `201 { id }` · 422 (name/code unique, ≥1 item, valid frequency incl. `OPENING`/`CLOSING`) — `gameZoneId=null` = org-wide/seeded; set = one branch |
| PATCH | `/checklist-templates/:id` | OH (`checklist.template.manage`) | `If-Match: <version>` · `{ name?, frequency?, rideId?, fillRoleCode?, gameZoneId?, sections?, isActive? }` | `200 { id, version }` · 409 stale — **versioned**: edits apply to the **next** auto-assign cycle; already-generated instances keep their items (change-items-confirm) |
| POST | `/checklist-templates/:id/assign` | OH (`checklist.template.manage`) | `{ fillRoleCode, rideId?, shiftId?, gameZoneId? }` | `200 { id }` — writes the role/ride/shift/GZ mapping that **feeds auto-assign** (`FOUNDATION_SPEC §3`, matched on `fillRoleCode` + `rideId` + `gameZoneId` at roster-publish / period-rollover) |
| GET | `/checklist-templates/:id/assignments` | OH (all GZ) | — | `{ assignments:[{ fillRoleCode, ride, shift, gameZone }] }` — current mappings (the Assign step) |

> **Template generation is server-side, not from these endpoints.** Assign records the *definition*; the auto-assign engine creates instances when a roster publishes or a period rolls over (`FOUNDATION_SPEC §3`). OH authoring is `scope=all`; SM = own GZ, TL = own team (their packs) — the resolver scopes the same endpoints down. Frequency includes the CL-1 first-class `OPENING` / `CLOSING` values.

## Action / Work-Orders — overview (read/monitor only) (`OH-WO-OVERVIEW`) *(CL-2)*

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/work-orders?scope=all&gameZoneId=&rideId=&state=&priority=&page=&pageSize=` | OH (all GZ, `workorder.view`) | — | `{ data:[{ id, code, gameZone, ride, aItemText, sourceInstance:{id,template}, state, priority, assignedTo, ageMinutes, slaBreached }], stateCounts:{ open, routed, assigned, in_progress, returned, done, outsourced, overdue }, heldChecklists, page, pageSize, total }` — every WO across all GZ; `state` uses the `wo-*` machine |
| GET | `/work-orders/:id` | OH (all GZ) | — | `{ id, code, gameZone, ride, state, priority, sourceInstance, aItem:{ text, note, photos:[{id,url,thumbUrl}] }, routedBy, assignedTo, returnReason?, outsourceNote?, timeline:[{ from, to, actor, at, note }] }` — read-only drilldown drawer (WorkOrder `ApprovalTimeline`) |

> **OH is read-only on Work-Orders.** No route / assign / start / done / return / outsource endpoints appear here — those live in `maintenance-tl-web` (`POST /work-orders/:id/route|assign|review|outsource`) + `technician-mobile` (`…/start|done|return`). The resolver grants OH `workorder.view` at `all` scope only. **Single-track hold:** the parent `ChecklistInstance.state = HELD` while any WO is open; **only `OUTSOURCED` WOs surface to SM** (`SM-WO-OUTSOURCE`). `state=overdue` is derived (`now() > sla`, `FOUNDATION_SPEC §4`).

## Reports (`OH-REP-POSITIVE`, `OH-REP-NEGATIVE`, `OH-REP-OVERDUE`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/reports/positive?scope=all&gameZoneId=&rideId=&shiftId=&from=&to=&page=&pageSize=` | OH (all GZ) | — | `{ data:[{ gameZone, ride, shift, template, period, allGood:true }], page, pageSize, total }` |
| GET | `/reports/negative?scope=all&gameZoneId=&from=&to=&page=&pageSize=` | OH (all GZ) | — | `{ data:[{ instanceId, gameZone, ride, shift, itemText, note, filler, recordedAt, photo:{id,url,thumbUrl} }], page, pageSize, total }` — every A-item + its issue photo (§4a) |
| GET | `/reports/overdue?scope=all&gameZoneId=&page=&pageSize=` | OH (all GZ) | — | `{ data:[{ instanceId, gameZone, ride, shift, template, dueAt, lateBy, state }], page, pageSize, total }` |
| GET | `/reports/:type/export?format=pdf|excel&...filters` | OH | — | `200` binary (PDF/Excel) — Export ▾ on each report |

## Photo evidence (viewer only) (`OH-CHK-APPROVE`, `OH-REP-NEGATIVE`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/checklist-instances/:id/photos` | OH (all GZ) | — | `{ completion:[{id,url,thumbUrl,uploader,takenAt}], negatives:[{id,url,thumbUrl,itemId,uploader,takenAt}] }` — local-disk URLs; backs the photo-lightbox |

> OH **does not upload or delete** photos — `POST /uploads/photos` / `DELETE` are filler-only (MON/SCM/CS/TL packs). OH is view-only on evidence.

---

## Notes
- **No fill endpoints** appear here — OH does not fill checklists (`PROJECT_PLAN §3`). `PATCH …/responses` and `POST …/submit` live in the filler packs; the resolver does not grant OH `checklist.fill`.
- **OH is the terminal approver:** `approve` → `OH_APPROVED`/Done; there is no level-4. TL/SM approve endpoints exist in their packs (levels 1, 2); the cascade engine is shared (`FOUNDATION_SPEC §4`).
- **Derived Pending is read-only data:** OH never writes "Pending" — it is computed by `renderStatusForViewer()` for `OH-DASH` + `OH-CHK-OVERVIEW` from the same stored sub-state.
- All write endpoints emit an `AuditLog` row (`FOUNDATION_SPEC §2`). RBAC matrix edits additionally stamp `RolePermission.updatedBy`.
- **OH scope = all:** every read is unfiltered by Game Zone (the `?gameZoneId=` selector is a convenience narrow, never a widen). Lower roles get the same endpoints with a server-forced `own`/`own_team` filter.

---

## Gaps flagged against `FOUNDATION_SPEC.md`

> Per the pack rule "do NOT invent new fields/endpoints; if something is missing, flag it." The following are **needed by OH screens but not explicitly enumerated** in `FOUNDATION_SPEC`. None require new schema fields — they are read aggregations / list endpoints over existing models. Foundation Squad to confirm/add before fan-out:

1. **`GET /oh/dashboard` aggregation** — `FOUNDATION_SPEC §4` gives the derived-Pending SQL and `renderStatusForViewer()`, but no named dashboard-aggregate endpoint. The per-GZ tile + KPI roll-up (compliance %, pending-by-frequency, roster gaps) needs a defined response shape. **No new fields** — pure aggregation over `ChecklistInstance` + `GameZone` + `RosterEntry`. Flagged.
2. **`GET /roles/matrix` + `PATCH /roles/:roleId/permissions` + `/roles/matrix/reset`** — the schema (`Role`, `Permission`, `RolePermission`) and the "editable at runtime" convention exist, but the **matrix read/write endpoints are not enumerated**. `OH-ROLES` is the only screen that edits RBAC, so this contract must be locked. **No new fields** (`RolePermission.updatedBy` already exists). Flagged.
3. **Roster-gap detection (`gaps[]` in `/roster?scope=all`)** — `RosterEntry` exists but there is **no defined "required ride/shift" master** to compute an unstaffed gap against. The gap-alert badge (`OH-DASH`, `OH-ROSTER-ALL`) needs a rule/source for "expected coverage" (e.g. which rides must be staffed per shift). **Possible gap** — flag for the demo (may need a `RideStaffingRule` or a config), or compute gaps only against published-but-unfilled expectations. Flagged as a **design question**, not assumed.
4. **Report export endpoints (`/reports/:type/export`)** — PDF/Excel export is in `PROJECT_PLAN §4/§8` and the DataTable `Export ▾`, but the export endpoint + format contract is not in `FOUNDATION_SPEC`. **No new fields** — serializes existing report data. Flagged.
5. **`reassign-manager sub-tree impact`** — the reassign-manager modal previews the moved sub-tree; `User.reportsTo`/`reports` supports it, but no endpoint returns the impact set. Minor — can be derived client-side from `/users/:id`. Noted, not blocking.

6. **Checklist-template CRUD + assign (`/checklist-templates*`, CL-1)** — `FOUNDATION_SPEC` defines the `ChecklistTemplate` model with the CL-1 builder fields (`frequency` incl. `OPENING`/`CLOSING`, `fillRoleCode`, `gameZoneId`, `createdById`, `requiresPhotoOnA` on items) and §3 template-selection, but the **list / create / patch / assign endpoints are not enumerated**. The Upload Check List module needs these locked. **No new fields** — all map onto existing `ChecklistTemplate` / `ChecklistSection` / `ChecklistItem` columns. Flagged.
7. **Work-Order read endpoints (`/work-orders*`, CL-2)** — the `WorkOrder` + `WorkOrderEvent` + `WorkOrderPhoto` models + state machine exist in `FOUNDATION_SPEC` (CL-2), and the mutation endpoints belong to `maintenance-tl-web` / `technician-mobile`; the **OH read/aggregate list + detail (`workorder.view`, scope=all)** are not separately enumerated. The org-wide overview + state-count strip need a defined response shape. **No new fields** — pure read over existing WO models. Flagged.

> Everything else (auth, `/me`, users CRUD, game-zone CRUD, rides, instance read, approve/sendback, timeline, photos) maps directly onto `FOUNDATION_SPEC` §1/§2/§4/§5 with no invented fields.
