# Cleaning Supervisor (Mobile) — API Contract

> Every endpoint this role consumes. **Near-identical surface to the anchor** (Staff Court Monitor) — same paths, same `/me`-driven nav, same submit-gate + cascade producer touchpoint. The behavioural deltas are: *which* templates auto-assign (cleaning/hygiene, not ride-safety), **plus the CL-5 `CS-ASSIGN` staff→area allocation endpoints** (gated by the scoped `cleaning:assign` right) that the anchor does not have. Consistent with `FOUNDATION_SPEC §2` conventions: Bearer JWT, **runtime RBAC resolver** (reads `RolePermission` per request — no hardcoded `if role==='X'`), **Game-Zone scoping** (CS = own GZ + own assignments), audit-row-on-write, pagination, `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 CS's effective access is "own roster / own assigned cleaning checklists / own Game Zone".
> 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:"CS",...}, gameZoneScope:"own", gameZone, reportsTo, cleaningAreas:[...], nav:[...5 bottom-nav slots; slot 2 = "Cleaning"...], permissions:[...] }` — **nav + scope are server-resolved** |
| GET | `/me/profile` | any | — | `{ fullName, email, phone, role, gameZone, reportsTo, cleaningAreas:[...] }` (backs `SH-PROFILE`) |

> The `/me.nav` for CS carries the slot-2 label **"Cleaning"** + icon `spray-can`, and (per CL-5) a **slot-3 "Assign" + icon `user-plus`** → `CS-ASSIGN`, with **Roster moved into the More cluster**; the anchor's MON carries "Checklists" + `list-checks` and has no Assign slot. `permissions` includes the scoped **`cleaning:assign`** right (CL-5, `FOUNDATION_SPEC §5`). **The client does not hardcode any of this — it renders what `/me` returns.**

## My roster (`CS-ROSTER-OWN`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/roster?date=YYYY-MM-DD` | CS (own) | — | `{ entries:[{ id, date, ride, shift:{name,start,end}, gameZone }] }` — own entries only (`ride` carries the cleaning area / zone) |
| GET | `/me/roster?weekOf=YYYY-MM-DD` | CS (own) | — | `{ entries:[...7 days...] }` (day/week toggle) |

> Read-only. CS has no roster create/edit permission — those endpoints (in SM/TL packs) reject with 403 for CS via the resolver.

## My assigned checklists for the shift (`CS-DASH`, `CS-CHK-TODAY`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/checklists/today` | CS (own) | — | `{ instances:[{ id, template:{name,frequency,category:"cleaning_hygiene"}, ride, itemCount, state, dueAt, photosNeeded:bool }] }` — own assignments, own GZ; `state` is the stored sub-state, status rendered client-side via the same rules |
| GET | `/me/dashboard` | CS (own) | — | `{ shift:{ ride, name, start, end }, counts:{ assigned, inProgress, submitted, done, overdue }, instances:[...] }` |

> The instances returned are the **cleaning/hygiene** templates the auto-assign engine matched for this shift (category `cleaning_hygiene`), not ride-safety templates. Same endpoint, different rows.

## Assign cleaning work — staff → area allocation (`CS-ASSIGN`) *(CL-5)*

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/cleaning-areas?date=YYYY-MM-DD` | CS (own GZ) | — | `{ areas:[{ id, name, cadence, assignee:{ id, name, initials }|null, state:"ASSIGNED"|"UNASSIGNED" }] }` — the CS's Game-Zone cleaning areas + current allocation for the shift |
| GET | `/me/cleaning-staff?date=YYYY-MM-DD` | CS (own team) | — | `{ staff:[{ id, name, initials, available:bool, currentArea? }] }` — cleaning staff rostered to the CS's team this shift (powers the assign-staff sheet; `available=false` ⇒ already on another area) |
| POST | `/me/cleaning-assignments` | CS (`cleaning:assign`) | `{ assignments:[{ areaId, staffId }], date }` | `200 { saved, assignments:[...] }` · 403 if out-of-scope area/staff · writes an `AuditLog` row |
| DELETE | `/me/cleaning-assignments/:areaId?date=YYYY-MM-DD` | CS (`cleaning:assign`) | — | `204` (clear an area's allocation → back to `UNASSIGNED`) |

> **Scope (CL-5):** the `cleaning:assign` right is **own Game Zone + own cleaning team** only — the resolver rejects any area/staff outside scope with 403. This right is **task-allocation only**: it grants **no** approve/verify, roster-create/edit, or template-assign capability, and it does **not** alter the approval cascade. The CS still **fills** the cleaning checklist himself (`/checklist-instances/:id/...`). No new entity is required if the allocation is modeled as a per-shift `RosterEntry.area` assignment; otherwise a lightweight `CleaningAssignment{ areaId, staffId, shiftDate, assignedBy }` row — **confirm the model at the client demo** (consistent with the auto-assign gap note below).

## Checklist instance + items detail (`CS-CHK-FILL`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/checklist-instances/:id` | CS (own filler) | — | `{ id, template, ride, gameZone, periodStart, periodEnd, dueAt, state, version, sections:[{ title, items:[{ id, text, testMethod, requiresPhotoOnA, response:{value,note,recordedAt,initials}|null, aPhoto:{id,url}|null }] }], completionPhotos:[{id,url,thumbUrl}] }` · 403 if not the assigned filler / out of GZ |

> `response.recordedAt` is set **server-side** and `response.initials` is **server-derived from the user** (§9 #9) — the client sends neither; it renders both read-only.

## Save / submit responses (`CS-CHK-FILL`, `CS-CHK-SUBMIT`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| PATCH | `/checklist-instances/:id/responses` | CS (own filler) | `If-Match: <version>` · `{ responses:[{ itemId, value:"G"|"A", note? }] }` | `200 { saved, state:"IN_PROGRESS", version }` · 409 stale version · sets `fillerId` on first save · server stamps `recordedAt` + `initials` |
| POST | `/checklist-instances/:id/submit` | CS (own filler) | `If-Match: <version>` | `200 { state:"SUBMITTED", submittedAt, approvalSteps:[TL,SM,OH] }` · **422** `{ fields:{ completionPhoto?, items:[...untouched/missing-A-photo...] } }` if the photo/answer gate fails · fires TL notification |

> **Submit gate (server-enforced, §4a/§9 #7/L7):** rejects with 422 unless every item has a response, **≥1 `COMPLETION` photo** exists, and every `A` item has a `NEGATIVE` photo. The UI mirrors this but never bypasses it.
> Note: the client **omits** `recordedAt`/`initials` from the body — server time + auto-derived initials are authoritative (§9 #9).

## Photo upload (completion + per-A-item) (`CS-CHK-FILL`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| POST | `/uploads/photos` | CS (own filler) | `multipart/form-data`: `file`, `instanceId`, `itemId?` (set ⇒ per-A-item), `kind:"COMPLETION"|"NEGATIVE"`, `device?` | `201 { id, url, thumbUrl, kind, itemId? }` — stored to **local disk on VPS** |
| DELETE | `/uploads/photos/:id` | CS (own uploader) | — | `204` (remove a staged photo before submit) |

## My submissions + approval status (`CS-CHK-STATUS`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/submissions?status=` | CS (own) | — | `{ instances:[{ id, template:{name}, ride, state, submittedAt, currentStep, sentBackReason? }] }` — own submissions; `status` filter accepts `active`/`sent_back`/`done` |
| GET | `/checklist-instances/:id/timeline` | CS (own) | — | `{ steps:[{ level, role, actor, at, state }], sentBackReason? }` — backs the ApprovalTimeline; CS sees real sub-states, never "Pending" |

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

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

---

## Notes
- **No approve/verify endpoints** appear here — CS cannot approve (`§3`). `POST /checklist-instances/:id/approve` and `/sendback` live in the TL/SM/OH packs; the resolver returns 403 for CS.
- **Send-back is received, not sent:** when a TL/SM/OH sends back, the cascade engine sets the instance to `SENT_BACK` and notifies the **original filler (Suresh)** — surfaced via `/me/notifications` + `/me/submissions` (`HANDOFFS.md`).
- All write endpoints emit an `AuditLog` row (`FOUNDATION_SPEC §2`).
- Every read is Game-Zone + own-assignment scoped server-side; passing another user's instance id → 403.

## ⚠ FOUNDATION_SPEC gap flagged (auto-assign for CS)
- `FOUNDATION_SPEC §3` auto-assign gates ride-safety templates on a **per-user×ride `Certification`** (`certificationExists(userId, rideId)`). The Cleaning Supervisor is **not ride-certified** — cleaning is park-wide / area-based, so this gate would yield **zero** templates for CS and the auto-assign would never land a cleaning checklist.
- **What's needed (engine, not a new design decision):** the engine must also match **category `cleaning_hygiene` templates by role + cleaning area + shift**, independent of the `Certification` ride gate. Options to confirm at the client demo (`§9 #2/#12`): (a) treat the cleaning area as a `Ride` row (e.g. "Washrooms", "Café") and certify CS on it, reusing the existing gate; or (b) add a role/category rule so CS auto-gets all active `cleaning_hygiene` templates for their Game Zone + shift. The HTML/flows assume cleaning templates **do** land (option a or b resolved) — the schema already carries `ChecklistTemplate.category = "cleaning_hygiene"`, so no schema change is required, only the engine branch + seed mapping. **Confirm which option at the demo before BE builds the CS branch.**
