# Store Manager (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** (SM = own GZ, `gameZoneId = me.gameZoneId`), audit-row-on-write, pagination, `If-Match`/ETag → 409, multipart photo upload (viewer-only for SM), standard error envelope.
> `Roles` column lists the roles whose **resolved permission** allows the call — documentation of the seed matrix, NOT a hardcoded gate. The SM's effective access is "own Game Zone: staff (≤ SM), roster build/upload/publish, checklist assign + **2nd-level approve**, **own-GZ check-list authoring (CL-1)**, **outsource work-order decisions (CL-2)**, own-zone reports".
> Base path: `/api/v1`. The Next.js app consumes these via generated TS models (`packages/types` → OpenAPI → TS).

---

## 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:"web", fcmToken }` | `201 { id }` (web push, optional) |

## Bootstrap / identity

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me` | any | — | `{ user, role:{code:"SM",...}, gameZoneScope:"own", gameZone:{id,name:"Air Maniax Ahmedabad-1"}, reportsTo:{OH}, nav:[...8 sidebar items: Dashboard, Hierarchy, Team/Users, Roster, Checklists, Upload Check List (CL-1), Outsource Inbox (CL-2), Reports...], permissions:[...] }` — **nav + scope server-resolved**; SM badge is read-only |
| GET | `/me/profile` | any | — | `{ fullName, email, phone, role, gameZone, reportsTo }` (backs `SH-PROFILE`) |
| GET | `/me/dashboard` | SM (own) | — | `{ counts:{ awaitingMyApproval, compliantPct, overdue, rosterGaps }, awaitingApproval:[instance...], gaps:[{ ride, shift, date }] }` — own GZ |

## People / users (`SM-USERS`, `SM-USER-NEW`, `SM-USER-EDIT`, `SM-USER-DETAIL`, `SM-HIER-TREE`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/users?role=&rideId=&q=&page=` | SM (own) | — | `{ data:[{ id, fullName, role, reportsTo, certifiedRides:[...], isActive }], page, pageSize, total }` — **own GZ only** |
| POST | `/users` | SM (own, Limited) | `{ fullName, email, phone?, password?, roleCode, reportsToId, certifiedRideIds:[...] }` | `201 { id }` · **422** if `roleCode` ∉ {TL,SCM,MON,CS} (SM cannot create OH/SM) · `gameZoneId` server-forced to me.gameZoneId |
| GET | `/users/:id` | SM (own) | — | `{ user, reports:[...subtree], history:[{ instanceId, template, state, at }] }` · 403 out-of-GZ (backs `SM-USER-DETAIL`) |
| PATCH | `/users/:id` | SM (own, Limited) | `If-Match` · `{ roleCode?, reportsToId?, certifiedRideIds?, isActive? }` | `200 { id, version }` · 422 role>SM · 409 stale · audit (reassign-manager) |
| GET | `/me/hierarchy` | SM (own) | — | `{ root:{ user:SM, children:[ TL → { children:[MON,SCM,CS] } ] } }` — own GZ tree (`SM-HIER-TREE`) |
| GET | `/rides` | SM (own) | — | `{ rides:[{ id, name, code }] }` — own GZ rides (for cert-multiselect, roster, filters). Read-only for SM; ride master is OH (`OH-RIDE-MASTER`). |

## Roster build + templates + leave (`SM-ROSTER`, `SM-ROSTER-TEMPLATES`, `SM-ROSTER-LEAVE`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/roster?weekOf=&date=&view=week\|month\|day` | SM (own) | — | `{ entries:[{ id, date, ride, shift, user, isOpener, status }], gaps:[...] }` — own GZ |
| POST | `/roster/entries` | SM (own) | `{ userId, rideId, shiftId, date, isOpener? }` | `201 { id, status:"DRAFT" }` · 422 conflict (dup person/ride/shift/day) · 403 out-of-GZ |
| PATCH | `/roster/entries/:id` | SM (own) | `If-Match` · `{ rideId?, shiftId?, isOpener? }` | `200 { id, version }` · 409 stale |
| DELETE | `/roster/entries/:id` | SM (own) | — | `204` (remove a DRAFT entry) |
| GET | `/shift-templates` | SM (own) | — | `{ templates:[{ id, name, startTime, endTime, isActive }] }` |
| POST | `/shift-templates` | SM (own) | `{ name, startTime, endTime }` | `201 { id }` · 422 duplicate (name+start+end) |
| PATCH | `/shift-templates/:id` | SM (own) | `{ name?, startTime?, endTime?, isActive? }` | `200 { id }` |
| GET | `/leave?date=` | SM (own) | — | `{ items:[{ id, user, date, reason, approved }] }` — own GZ |
| POST | `/leave/:id/approve` | SM (own) | `{ approved:boolean }` | `200 { id, approved }` (deny = `false`); fires gap-alert recompute |

## Roster bulk upload (`SM-ROSTER-UPLOAD`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| POST | `/roster/upload?dryRun=true` | SM (own) | `multipart/form-data`: `file` (xlsx/csv) | `200 { rows:N, valid:M, errors:[{ row, field, message }] }` — **dry-run, no writes**; out-of-GZ email / unknown ride_code → row error, never a silent insert |
| POST | `/roster/upload` | SM (own) | `multipart/form-data`: `file`, `mode:"all_or_nothing"\|"valid_only"` | `201 { created:M, skipped:K, entries:[...DRAFT...] }` · **422** with the same `errors[]` if `all_or_nothing` and any row fails |
| GET | `/roster/upload/template` | SM (own) | — | `200` xlsx template (`email, ride_code, shift_name, date, is_opener`) |

## Publish roster → auto-assign (`SM-ROSTER-PUBLISH`, `SM-ROSTER`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/roster/publish/preview?weekOf=` | SM (own) | — | `{ entries:N, ridesCovered:R, gaps:[...], alreadyPublished:bool }` — backs the publish summary |
| POST | `/roster/publish` | SM (own) | `If-Match: <rosterVersion>` · `{ weekOf, date? }` | `200 { published:N, instancesCreated:I }` — entries → `PUBLISHED`; **fires auto-assign** (`ChecklistAssignment`+`ChecklistInstance`, idempotent via `@@unique`); emits `ROSTER_PUBLISHED` audit + notifications · 409 stale |

> **Auto-assign is server-side** (`FOUNDATION §3`): `role + certified ride + shift → templates`; 114-pt Opening → the one `isOpener` entry. Re-publish does not duplicate instances. The SM UI only triggers it via this endpoint.

## Checklist overview + assign/override (`SM-CHK-OVERVIEW`, `SM-CHK-ASSIGN`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/checklists?rideId=&shiftId=&frequency=&status=&tab=active\|overdue&page=` | SM (own) | — | `{ data:[{ id, template, ride, shift, filler, state, statusForViewer, dueAt, cascade:[{level,role,actor,state}] }], page, total }` — `statusForViewer` is `renderStatusForViewer(SM)` (Pending until SM-approved) |
| GET | `/checklist-instances/:id` | SM (own) | — | `{ id, template, ride, gameZone, state, version, sections:[{ title, items:[{ text, response:{value,note,recordedAt,initials}, aPhoto:{id,url,thumbUrl}|null }] }], completionPhotos:[{id,url,thumbUrl}], timeline:[...] }` · 403 out-of-GZ — **read/viewer** for SM |
| GET | `/checklist-assignments?rosterEntryId=` | SM (own) | — | `{ data:[{ id, template, rosterEntry, reason }] }` — the auto-assign trace |
| POST | `/checklist-assignments` | SM (own) | `{ templateId, target:{ rideId?\|userId?\|shiftId?\|roleCode? }, date }` | `201 { assignmentId, instanceId }` — manual assign/override (own GZ); 422 cross-GZ target |
| PATCH | `/checklist-instances/:id/reassign` | SM (own) | `If-Match` · `{ fillerId }` | `200 { id, version }` — reassign within the team; 403 out-of-team/GZ |

## Approval cascade — SM level (`SM-CHK-APPROVE`) — the producer touchpoint

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/approvals?level=SM&status=pending&page=` | SM (own) | — | `{ data:[{ id, template, ride, filler, submittedAt, state:"TL_APPROVED" }] }` — the SM queue (TL-approved, awaiting SM) |
| GET | `/checklist-instances/:id/timeline` | SM (own) | — | `{ steps:[{ level, role, actor, at, state }], sentBackReason? }` — backs the `ApprovalTimeline` |
| POST | `/checklist-instances/:id/approve` | **SM** (own, level 2) | `If-Match` · `{ level:"SM" }` | `200 { state:"SM_APPROVED" }` — `ApprovalLog`; `ApprovalStep[2].APPROVE`; **notify OH (Anjali)**; leaves SM queue · 409 stale · 403 not-SM-level/out-of-GZ |
| POST | `/checklist-instances/:id/sendback` | **SM** (own, level 2) | `If-Match` · `{ level:"SM", reason }` (**reason required**) | `200 { state:"SENT_BACK" }` — `ApprovalLog{reason}`; returns to **original `fillerId`** (`§9 #3`), NOT the TL; re-fill restarts cascade from TL (`§9 #8`); **notify filler** · 422 empty reason |

> **SM is the 2nd cascade level only.** It consumes `approve`/`sendback` at `level:"SM"`; the resolver rejects an SM call at TL or OH level (403). TL verify (level 1) lives in the TL pack; OH final-approve (level 3) in the OH pack. Pending is derived, never written.

## Photo viewer (read-only for SM) (`SM-CHK-APPROVE`, `SM-REP-NEGATIVE`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/checklist-instances/:id/photos` | SM (own) | — | `{ photos:[{ id, kind:"COMPLETION"\|"NEGATIVE", itemId?, url, thumbUrl, uploader, takenAt, device }] }` — local-disk URLs; **viewer only**, SM does not upload |

## Upload Check List — template CRUD (own GZ) (`SM-CHK-UPLOAD`, `SM-CHK-TPL-ASSIGN`, `SM-CHK-TPL-EDIT`) — CL-1

> The SM now **authors** check-list templates scoped to its **own Game Zone** (CL-1). Seeded/global templates (`gameZoneId = null`) are **read-only** to the SM; only own-GZ templates (`gameZoneId = me.gameZoneId`) are writable. The resolver forces `gameZoneId` on write — a cross-GZ scope attempt → 422/403.

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/checklist-templates?scope=seeded\|own&category=&frequency=` | SM (own) | — | `{ templates:[{ id, name, code, category, frequency, ride, fillRole, gameZoneId, isOpenerOnly, isSeeded, version, isActive }] }` — own-GZ + seeded; `isSeeded=true` rows are read-only |
| GET | `/checklist-templates/:id` | SM (own) | — | `{ template, sections:[{ title, items:[{ text, testMethod, inputType:"G_A", requiresPhotoOnA }] }], mappings:[...] }` · 403 out-of-GZ |
| POST | `/checklist-templates` | SM (own, author) | `{ name, frequency:"daily\|weekly\|monthly\|quarterly\|yearly\|opening\|closing", rideId?, fillRoleCode, sections:[{ title, items:[{ text, testMethod, inputType:"G_A", requiresPhotoOnA }] }] }` | `201 { id, version }` — **`gameZoneId` server-forced to me.gameZoneId**; `authorId=me`; audit · 422 invalid frequency/role |
| PATCH | `/checklist-templates/:id` | SM (own, author) | `If-Match` · `{ name?, frequency?, rideId?, fillRoleCode?, sections? }` | `200 { id, version }` — creates a new **version**; in-progress instances keep the old version, new applies on next auto-assign · 403 seeded/out-of-GZ · 409 stale |
| POST | `/checklist-templates/:id/duplicate` | SM (own) | `{ name }` | `201 { id }` — clone (incl. a seeded one) into the SM's GZ as an editable own template |
| PATCH | `/checklist-templates/:id/active` | SM (own, author) | `{ isActive }` | `200 { id }` — deactivate/reactivate an own-GZ template (seeded → 403) |
| GET | `/checklist-templates/:id/mappings` | SM (own) | — | `{ mappings:[{ id, fillRoleCode, rideId?, shiftId?, isOpenerOnly }] }` — the assign mappings (`SM-CHK-TPL-ASSIGN`) |
| POST | `/checklist-templates/:id/mappings` | SM (own) | `{ fillRoleCode, rideId?, shiftId?, isOpenerOnly? }` | `201 { id }` — map template → role/ride/shift (feeds auto-assign §3); 422 cross-GZ ride/role |
| DELETE | `/checklist-templates/:id/mappings/:mappingId` | SM (own) | — | `204` — remove a mapping |

> **Auto-assign consumes these mappings** (`FOUNDATION §3`): on `POST /roster/publish`, `role + certified ride + shift → templates` resolves through the template mappings above (own GZ). Editing a template is **versioned** — filled instances are never retro-edited.

## Outsource / Action-needed work-orders (own GZ) (`SM-WO-OUTSOURCE`) — CL-2

> **Locked (CL-2):** only **`OUTSOURCED`** work-orders reach the SM. Internally-fixed actions close at the maintenance level (`maintenance-tl-web` / `technician-mobile`) and are never visible here. The SM **decides outsource** or **returns** to the Maintenance TL; the SM never assigns a Technician. The parent `ChecklistInstance` stays **HELD** until every A-item WO is closed (single-track hold).

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/work-orders?state=OUTSOURCED&tab=awaiting\|outsourced\|closed&rideId=&page=` | SM (own) | — | `{ data:[{ id, sourceItem, ride, template, maintenanceTl, state:"OUTSOURCED", sla, overdue }], total }` — **own GZ, `OUTSOURCED` only**; any other state → not returned (403 if requested by id) |
| GET | `/work-orders/:id` | SM (own) | — | `{ id, state, sourceInstance, sourceItem:{ text, note, photos:[{url,thumbUrl}] }, returnReason, escalationNote, timeline:[{ stage, actor, at, state }], heldInstanceId }` · 403 out-of-GZ or non-`OUTSOURCED` |
| POST | `/work-orders/:id/outsource` | **SM** (own) | `If-Match` · `{ vendor?, note }` (**note required**) | `200 { id, state:"OUTSOURCED", closedAt }` — records the outsource decision; `WorkOrderLog`; **notify Maintenance TL**; parent instance stays `HELD` until all WOs closed · 422 empty note · 409 stale |
| POST | `/work-orders/:id/return-to-maintenance` | SM (own) | `If-Match` · `{ note }` | `200 { id, state:"RETURNED" }` — hand back to the Maintenance TL (SM never assigns a Technician) |

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

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/reports/positive?from=&to=&rideId=&shiftId=&page=` | SM (own) | — | `{ data:[{ instanceId, template, ride, shift, date, filler, state }], total }` — all-G/compliant, own GZ |
| GET | `/reports/negative?from=&to=&rideId=&page=` | SM (own) | — | `{ data:[{ instanceId, template, ride, shift, date, item, note, photo:{url,thumbUrl}, filler }], total }` — A items + issue photos, own GZ |
| GET | `/reports/overdue?from=&to=&page=` | SM (own) | — | `{ data:[{ instanceId, template, ride, shift, dueAt, filler, escalated:bool }], total }` — own GZ |
| GET | `/reports/:type/export?format=pdf\|xlsx&...filters` | SM (own) | — | `200` file stream (PDF/Excel) — server-rendered, own-GZ scoped |

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

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/notifications` | any | — | `{ items:[{ id, type:"CHECKLIST_SUBMITTED"\|"AWAITING_SM_APPROVAL"\|"ROSTER_GAP"\|"OVERDUE", title, body, at, read }] }` |
| POST | `/me/notifications/:id/read` | any | — | `204` |

---

## Notes
- **SM does not fill or upload photos** — `*-CHK-FILL`, `/uploads/photos` are filler-only (Monitor/SCM/CS/TL). SM's photo access is the **viewer** endpoint above; the resolver denies SM an upload (403).
- **SM is the second approver** — `approve`/`sendback` are called at `level:"SM"` only. The TL-level and OH-level calls are denied to SM by the resolver. Send-back routes to the **original filler**, never the previous approver (`§9 #3`); re-fill restarts the cascade from TL (`§9 #8`).
- **Every read is Game-Zone scoped** server-side (`gameZoneId = me.gameZoneId`); passing another Game Zone's id → 403. SM never sees OH-only data (other branches, cross-GZ users, Game-Zone/ride master writes).
- **All write endpoints emit an `AuditLog` row** (`FOUNDATION §2`). Versioned writes use `If-Match` → 409 on stale.
- **Onboard is Limited:** `POST /users` rejects `roleCode ∈ {OH, SM}` with 422 — SM creates roles ≤ SM only and the Game Zone is server-forced.
- **CL-1 template authoring is own-GZ only:** template `POST/PATCH/duplicate/active`/mapping writes force `gameZoneId = me.gameZoneId`; seeded/global templates (`gameZoneId = null`) are read-only to SM (write → 403). Edits are **versioned** (filled instances keep their version). OH authors across all GZ; TL is team-scoped.
- **CL-2 only outsource reaches SM:** the WorkOrder resolver returns `state = OUTSOURCED` only to the SM; `OPEN/ROUTED/ASSIGNED/IN_PROGRESS/RETURNED/DONE` are denied (403 by id, filtered from lists) — they live in `maintenance-tl-web` / `technician-mobile`. SM `outsource`/`return-to-maintenance` notify the Maintenance TL; SM never assigns a Technician. The parent `ChecklistInstance` is **HELD** until every A-item WO is closed (single-track hold).
