# 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 + team scoping** (TL = own Game Zone + own team), 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 TL's effective access is "own Game Zone + own team; first-level approve; team roster; can fill".
> **NEW vs the anchor:** the anchor (filler) pack consumed **no** approve/sendback endpoints. The TL pack is the **first consumer** of `…/approve` and `…/sendback`. The submit→cascade producer side (the anchor's `…/submit`) lands in the TL's verify queue here.
> **Change List v1.1 additions:** `POST /work-orders` (route A-item → Maintenance, CL-2), `PATCH /roster-entries/:id/reassign` + `GET /me/team/floor` (mid-shift reassign, CL-4), and team **checklist-template CRUD** (Upload Check List, CL-1). **Trainee TL** resolves `allowed:false` on the approve / sendback / route / reassign / template-write grants (CL-3) — same endpoints, resolver-gated off.
> 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 }` (upserts `DeviceToken` for FCM push — SM-notify on TL approve) |

## Bootstrap / identity

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me` | any | — | `{ user, role:{code:"TL",...}, gameZoneScope:"own", gameZone, reportsTo, certifications:[ride], teamCount, nav:[...6 sidebar items...], permissions:[...incl. checklist.approve scope own_team...] }` — **nav + scope + approve grant are server-resolved** |
| GET | `/me/profile` | any | — | `{ fullName, email, phone, role, gameZone, reportsTo, certifiedRides:[...] }` (backs `SH-PROFILE`) |

## Team Leader dashboard (`TL-DASH`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/team/dashboard` | TL (own_team) | — | `{ counts:{ awaitingMyApproval, inProgress, doneToday, overdue, rosterGaps }, verifyQueue:[{ instanceId, template, ride, shift, filler, submittedAt, state }], teamProgress:[{ userId, name, role, assigned, inProgress, submitted, done }] }` |

## My team (`TL-TEAM`, `TL-USER-DETAIL`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/team?role=&search=` | TL (own_team) | — | `{ data:[{ id, fullName, role, certifiedRides:[...], todayShift, progress:{assigned,inProgress,submitted,done}, lastActiveAt }], page, pageSize, total }` — `reportsTo` chain ⊆ me, own GZ |
| GET | `/users/:id` | TL (own_team) | — | `{ user, role, gameZone, reportsTo, certifiedRides, checklistHistory:[{ instanceId, template, ride, state, submittedAt }] }` · 403 if not in my team |

> Read-only. TL has no user create/edit permission — onboarding endpoints (OH/SM packs) reject with 403 for TL via the resolver.

## Team checklist overview (`TL-CHK-OVERVIEW`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/team/checklists?rideId=&shiftId=&period=&status=&tab=active\|overdue&page=` | TL (own_team) | — | `{ data:[{ id, template:{name,frequency}, ride, shift, filler, state, dueAt, cascade:[{level,role,actor,at,state}], statusForViewer }], page, pageSize, total }` — `statusForViewer` from `renderStatusForViewer()` (real sub-state to TL level, Pending above) |

## Verify / approve a submission (`TL-CHK-VERIFY`) — the TL core

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/checklist-instances/:id` | TL (own_team approver) | — | `{ id, template, ride, gameZone, filler, periodStart, periodEnd, dueAt, state, version, sections:[{ title, items:[{ id, text, testMethod, response:{value,note,recordedAt,initials,recordedBy}, aPhoto:{id,url,thumbUrl}|null }] }], completionPhotos:[{id,url,thumbUrl,uploader,takenAt,device}], timeline:[...] }` · 403 if not in TL's team / GZ |
| POST | `/checklist-instances/:id/approve` | TL (`checklist.approve`, own_team) | `If-Match: <version>` | `200 { state:"TL_APPROVED", step:{level:1,action:"APPROVE",decidedAt} }` — writes `ApprovalLog`, sets `ApprovalStep[1].APPROVE`, **notifies SM (Rohit)**; · 409 stale · 403 out-of-scope |
| POST | `/checklist-instances/:id/sendback` | TL (`checklist.approve`, own_team) | `If-Match: <version>` · `{ reason }` (**required**) | `200 { state:"SENT_BACK" }` — writes `ApprovalLog` with reason; returns instance to the **original `fillerId`** (`§9 #3`); **notifies the filler**; · 422 if reason empty · 409 stale |

> **Send-back rule (`§9 #3 / #8`):** a send-back ALWAYS returns to the original filler (not the previous approver). After the filler re-fills + re-submits, the cascade **restarts from TL** — the instance re-appears in this TL's verify queue. The TL never edits the filler's responses/photos; it only approves or sends back.

## Route an A-item to Maintenance (`TL-CHK-VERIFY` → Route to Maintenance) — CL-2

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| POST | `/work-orders` | TL (`workorder.route`, own_team) — **NOT Trainee TL** | `{ instanceId, responseId, priority?:"urgent"\|"normal", note? }` | `201 { id, state:"ROUTED", responseId, assignedTeam:"maintenance" }` — creates **one `WorkOrder` per A-`ChecklistResponse`** (idempotent via `WorkOrder @@unique(responseId)`); transitions the parent instance **`SUBMITTED → HELD`**; **notifies the Maintenance TL** (FCM + in-app); writes `WorkOrderEvent` + `AuditLog` · 403 out-of-scope / Trainee TL · 409 if already routed |

> **Single-track hold (CL-2):** while ≥1 of the instance's `WorkOrder`s is non-terminal the instance stays **`HELD`** — `…/approve` returns **409/422** ("held: actions open"). When the **last** WO closes (`DONE`/`OUTSOURCED`, worked in `maintenance-tl-web` / `technician-mobile`) the engine releases `HELD → TL_APPROVED` and resumes the cascade. The TL only **routes**; assign/done/return/outsource are the Maintenance TL's / Technician's endpoints (their packs). `GET /checklist-instances/:id` returns the instance's `workOrders:[{ id, responseId, state, assignedTo, age }]` so the HeldBanner + per-A WO chips render.

## Fill as TL (`TL-CHK-FILL`) — TL may also fill

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/checklist-instances/:id` | TL (own filler) | — | (same shape as above; `version`/ETag) — when the TL is the assigned filler |
| PATCH | `/checklist-instances/:id/responses` | TL (`checklist.fill`, own) | `If-Match: <version>` · `{ responses:[{ itemId, value:"G"|"A", note? }] }` | `200 { saved, state:"IN_PROGRESS", version }` — **`recordedAt` = server time, `initials` auto-derived from the logged-in user** (`§9 #9`); sets `fillerId`; · 409 stale |
| POST | `/checklist-instances/:id/submit` | TL (`checklist.submit`, own) | `If-Match: <version>` | `200 { state:"SUBMITTED", submittedAt, approvalSteps:[TL,SM,OH] }` · **422** `{ fields:{ completionPhoto?, items:[...] } }` if the photo/answer gate fails. **When the filler is the TL, the next approver is SM** (a TL cannot self-approve) |
| POST | `/uploads/photos` | TL (own filler) | `multipart`: `file`, `instanceId`, `itemId?`, `kind:"COMPLETION"|"NEGATIVE"`, `device?` | `201 { id, url, thumbUrl, kind, itemId? }` — local-disk on VPS |
| DELETE | `/uploads/photos/:id` | TL (own uploader) | — | `204` (remove a staged photo before submit) |

## Team roster (`TL-ROSTER`, `TL-ROSTER-VIEW`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/team/roster?weekOf=YYYY-MM-DD\|date=` | TL (own_team) | — | `{ entries:[{ id, userId, name, role, ride, shift:{name,start,end}, date, isOpener, status, onLeave }], gaps:[{ ride, shift, date }] }` — team only |
| POST | `/roster/entries` | TL (`roster.create`, own_team) | `{ userId, rideId, shiftId, date, isOpener? }` | `201 { id, status:"DRAFT" }` · 403 if userId not in TL's team · 409 duplicate (`@@unique`) |
| PATCH | `/roster/entries/:id` | TL (`roster.edit`, own_team) | `{ rideId?, shiftId?, isOpener? }` | `200 { id }` · conflict warn surfaced client-side |
| DELETE | `/roster/entries/:id` | TL (`roster.edit`, own_team) | — | `204` |
| POST | `/roster/publish` | TL (`roster.create`, own_team) | `{ weekOf\|date, scope:"team" }` | `200 { published, instancesCreated }` — **fires auto-assign for the team's entries** (`FOUNDATION_SPEC §3`); idempotent re-publish |
| GET | `/me/team/floor?date=&shiftId=` | TL (own_team) | — | `{ rows:[{ entryId, userId, name, role, rosteredRideId, currentRideId, onRideSince, certifiedRideIds:[...], counts:{done,inProgress,notStarted,awaitingTL} }] }` — backs `TL-ROSTER-REASSIGN` current-floor board (live current ride) |
| PATCH | `/roster-entries/:id/reassign` | TL (`roster.reassign`, own_team) — **NOT Trainee TL** | `{ newRideId }` | `200 { entryId, oldRideId, newRideId, retargeted:{ toMonitor:[instanceIds], oldRideHolderId, oldRideRetargeted:[instanceIds] } }` — **rotation-aware mid-shift reassign** (`FOUNDATION_SPEC §3a`): re-targets the new ride's **`NOT_STARTED`** instances to this monitor, moves the old ride to its current holder, FCM `ride-changed` to both; **`IN_PROGRESS`/`SUBMITTED` are NOT withdrawn** · **422** if monitor not certified for `newRideId` · 403 out-of-team / Trainee TL |

> TL roster writes are restricted to own-team members + own Game Zone by the resolver. Branch-wide roster + bulk upload live in the SM pack (403 for TL). **Reassign (CL-4)** re-targets only `NOT_STARTED` instances (in-progress safety) and requires `assertCertified(userId, newRideId)`.

## Team checklist assign / override (`TL-CHK-ASSIGN`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/team/assignments?rideId=&shiftId=` | TL (own_team) | — | `{ data:[{ assignmentId, instanceId, template, ride, shift, currentFiller, reason, state }] }` |
| POST | `/checklist-instances/:id/reassign` | TL (`checklist.assign`, own_team) | `{ toUserId }` | `200 { instanceId, filler }` · 403 if toUserId not in team / not certified for the ride |

> Override only — the auto-assign **engine** stays server-side. The TL reassigns an existing instance to another certified, rostered team member.

## Team checklist templates — Upload Check List (`TL-CHK-UPLOAD`) — CL-1

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/team/templates?search=&frequency=&rideId=` | TL (own_team) | — | `{ data:[{ id, name, frequency, ride, fillRole, sectionsCount, itemsCount, gameZone, updatedAt }] }` — team-scoped templates |
| POST | `/checklist-templates` | TL (`template.create`, own_team) — **NOT Trainee TL** | `{ name, frequency:"daily\|weekly\|monthly\|quarterly\|yearly\|opening\|closing", rideId, fillRoleCode, sections:[{ title, items:[{ text, testMethod, inputType:"GA", requiresPhotoOnA:bool }] }] }` | `201 { id }` — `gameZoneId` forced to `me.gameZoneId`; `fillRoleCode` must be within the TL's team roles · 403 if scope exceeds own team/GZ · 422 validation |
| GET | `/checklist-templates/:id` | TL (own_team) | — | `{ id, name, frequency, ride, fillRole, gameZone, sections:[{ title, items:[{ id, text, testMethod, inputType, requiresPhotoOnA }] }], assignments:[{ rideId, shiftId, roleCode }] }` · 403 out-of-scope |
| PATCH | `/checklist-templates/:id` | TL (`template.edit`, own_team) — **NOT Trainee TL** | `{ name?, frequency?, rideId?, fillRoleCode?, sections? }` | `200 { id }` — edit items/mapping; `AuditLog` · 403 / 422 |
| POST | `/checklist-templates/:id/assign` | TL (`template.assign`, own_team) — **NOT Trainee TL** | `{ rideId, shiftId, roleCode }` | `200 { templateId, assignment }` — maps the template to role/ride/shift → **feeds auto-assign** (`FOUNDATION_SPEC §3`) · 403 if outside own team/GZ |

> **Team scope (CL-1):** the TL authors templates whose **fill-role / ride / Game-Zone** stay within their own team + GZ — the resolver rejects GZ-wide / cross-team scope (403). Branch / org-wide template CRUD lives in the SM / OH packs. The auto-assign **engine** remains server-side; templates only feed it. **Trainee TL** has read-only access (no create/edit/assign — CL-3).

## Team negative report (`TL-REP-NEGATIVE`)

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/team/reports/negative?from=&to=&rideId=&shiftId=` | TL (own_team) | — | `{ items:[{ instanceId, itemId, itemText, ride, shift, filler, date, note, photo:{id,url,thumbUrl} }], total }` — A items + issue photos, team scope |
| GET | `/me/team/reports/negative/export?format=pdf\|excel&from=&to=` | TL (own_team) | — | `200` file stream (PDF/Excel) |

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

| Method | Path | Roles | Body | Returns |
|---|---|---|---|---|
| GET | `/me/notifications` | any | — | `{ items:[{ id, type:"CHECKLIST_SUBMITTED"|"SENT_BACK"|"OVERDUE_ESCALATION"|"ROSTER_PUBLISHED", title, body, at, read }] }` — TL receives `CHECKLIST_SUBMITTED` (a filler submitted → verify) |
| POST | `/me/notifications/:id/read` | any | — | `204` |

---

## Notes
- **TL is the first approver:** `POST /checklist-instances/:id/approve` and `…/sendback` resolve for TL (scope `own_team`); they resolve for SM/OH too at their levels (their packs), and 403 for fillers (MON/SCM/CS). One handler, resolver-gated by level.
- **Submit producer side** (the anchor's `…/submit`) is consumed here: on a filler's submit, the cascade engine creates the `ApprovalStep`s and notifies **this TL** (`CHECKLIST_SUBMITTED`) → the instance appears in `/me/team/dashboard.verifyQueue` + `/me/team/checklists?tab=active`.
- **Send-back is sent (not received) by TL** — opposite of the anchor. On TL send-back the engine sets `state=SENT_BACK` and notifies the **original `fillerId`** (`HANDOFFS.md`).
- All write endpoints emit an `AuditLog` row (`FOUNDATION_SPEC §2`).
- Every read is Game-Zone + own-team scoped server-side; passing another team's / branch's id → 403.
- **Route-to-Maintenance is one-way for TL (CL-2):** the TL only `POST /work-orders` (route + hold). Assign/start/done/return/outsource WO endpoints belong to the Maintenance TL + Technician packs; the TL's verify view reads WO status via `GET /checklist-instances/:id.workOrders[]` to render the Held banner and release Approve when the last WO closes.
- **Trainee TL = assist-only (CL-3):** resolved with `allowed:false` on `checklist.approve`, `workorder.route`, `roster.reassign`, and `template.create/edit/assign`. The endpoints are unchanged; the resolver returns 403 and `/me.permissions` drives the disabled buttons. No separate Trainee endpoints.
