# Backend API Updates for Mobile

This file is written to by Claude agents working on the backend (`buyerkiosk-web`) to communicate API changes, new endpoints, and important information to the mobile app agent.

## How This Works

1. **Backend Agent** writes updates here when APIs change or new endpoints are added
2. **Mobile Agent** checks this file to stay informed of backend changes
3. Updates should include enough detail for mobile implementation

---

## Recent Updates

<!-- Backend agents: Add new updates at the top using the template below -->

### [2026-09-10] Backstock routing and replenishment lookup fixes

**Type**: `enhancement`
**Affects**: Backstock timeline/templates, create-from-template, replenishment settings

#### Summary
Web branch `fix/pr82-local-regressions` fixes static POST routes being shadowed by the generic event-update route. Locally verified; not a production deployment.

#### Details
- `/api/mobile/backstock/:typeNum/events/timeline`, `/api/mobile/backstock/:typeNum/events/templates`, and `/api/mobile/backstock/:typeNum/events/from-template` now reach their intended handlers instead of returning `Event not found` from the update handler.
- `/api/mobile/backstock/:typeNum/replenishment/settings` resolves the store's current, non-deleted layout via `layoutType = 'current'`, not the nonexistent `isActive` column.
- Request/response contracts, authentication, and store authorization are unchanged. No migration is required.
- Full Web-side note: `../buyerkiosk-web/docs/api/2026-09-10-event-backstock-fixes.md` (path relative to this repository root).

#### Mobile Action Required
- No payload changes required. Recheck these flows against the backend containing the fix before release.

### [2026-08-30] Daily schedule exposes authoritative elapsed shift hours

**Type**: `enhancement`
**Affects**: Team Day/Week/Month schedule duration display

#### Summary
`POST /api/mobile/scheduling/:typeNum/schedule/daily` now includes
`totalHours` on every shift. The backend calculates it from the persisted UTC
start/end instants, so spring-forward and fall-back shifts do not drift when a
client subtracts store-local wall-clock labels.

#### Mobile Action Required
- [x] Team app parses `totalHours` and uses it for Week shift duration chips,
      with wall-clock subtraction only as a compatibility fallback.

### [2026-07-17] Swap position-qualification rule (NOT_QUALIFIED) + filtered targets

**Type**: `enhancement`
**Affects**: Shift swap initiation, swap targets list

#### Summary
`POST /:typeNum/requests/swap` now enforces position qualification in BOTH
directions using the `employeeSchedulePositions` allow-list (same rule as
open-shift claims): the initiator must hold the target shift's position AND
the target coworker must hold the initiator shift's position. Violations
return 400 with code `NOT_QUALIFIED` and a direction-specific message.
`POST /:typeNum/swaps/targets` applies the same filter, so its list never
offers a target the proposal would reject.

#### Mobile Action Required
- [x] Team app: map the swap-initiation codes (`NOT_OWNER`, `OPEN_SHIFT`,
      `UNPUBLISHED`, `SELF_SWAP`, `PAST_SHIFT`, `CONFLICT`) and read the
      `error` envelope field — done 2026-07-17
- [x] Team app: SwapSheet filters rows via `POST /swaps/targets` — done 2026-07-17

### [2026-07-16] Swap flow: Flutter alias routes now live + swap `message` persisted

**Type**: `new-endpoint` + `enhancement`
**Affects**: Shift swap respond/cancel, swap propose note

#### Summary
1. **The routes the shipped app already calls now exist** (previously 404):
   - `PUT /api/mobile/scheduling/:typeNum/requests/swap/:id/respond` — body accepts `{"accepted": bool}` (the app's key) AND the canonical `{"accept": bool}`. Same behavior/error codes as the canonical `POST /:typeNum/swaps/:id/respond`.
   - `DELETE /api/mobile/scheduling/:typeNum/requests/swap/:id` — cancels the swap (initiator only). Same behavior as `POST /:typeNum/swaps/:id/cancel`.
   Both return the standard `{success, data: <swap>}` envelope.
2. **The optional swap note is no longer dropped**: `message` in the `POST /:typeNum/requests/swap` (and `/:typeNum/swaps/request`) body is now sanitized (HTML stripped, trimmed, capped at 500 chars), persisted to `scheduleSwapRequests.message` (migration `20260716_001_swap_request_message.json`), and returned as `message` in every swap payload (propose response, GET swap lists for initiator/coworker, and the manager pending-requests list).

#### Mobile Action Required
- [ ] None to ship — existing respond/cancel calls start working once the backend deploys.
- [ ] Optional: surface the swap `message` field on incoming swap request cards/detail views (it is now returned).

### [2026-07-15] Phase 3: WIW gate, geofence UX, clock status accuracy

**Type**: `enhancement` + `breaking-change` (WIW stores)
**Affects**: Clock tab, manager override flows, publish

#### Summary
1. **WhenIWork stores are gated** (`schedulingProvider === 'wiw'`): every mobile clock action (in/out/breaks), the override-request endpoint, and manager manual punches return `403 MOBILE_CLOCK_DISABLED` with a WhenIWork-specific message. `clock/status` reports `canClockIn=false` with the same message.
2. **Geofence accuracy is advisory** (no more hard 403 on accuracy > 100 m): reported accuracy widens the ring up to a 200 m cap. `clock/status` accepts an optional `accuracy` field (send it — status now agrees with what clock-in would decide) and returns `geofenceLowAccuracy`.
3. **`OUTSIDE_GEOFENCE` 403s now include** `overrideRequestAvailable: true` and `overrideRequestEndpoint` — offer the self-service manager-approval request instead of dead-ending.
4. **Publish response**: `notificationsSent` gains `failed` + `failedEmployeeIds`; `employeesNotified` counts only employees actually reached.

#### Mobile Action Required
- [ ] Send `accuracy` with `clock/status` requests.
- [ ] Handle the WIW-store 403 message on clock actions.
- [ ] Surface the override-request offer on OUTSIDE_GEOFENCE.
- [ ] Show failed-notification counts after publish.

### [2026-07-13] getHours: period validation, instant bounds, published-only scheduled hours

**Type**: `enhancement` + `breaking-change` (new 400)
**Affects**: Hours tab / `POST /api/mobile/scheduling/:typeNum/hours`

#### Summary
1. **Period validation (new 400)**: `periodStart`/`periodEnd` must be strict `YYYY-MM-DD` dates, `periodEnd >= periodStart`, span at most 370 days. Violations return `400 INVALID_PERIOD`.
2. **New response fields**: `currentPeriod.periodStartAt` and `currentPeriod.periodEndExclusiveAt` (UTC ISO-8601 instants) describe the exact computed range. For the DEFAULT request these are authoritative — a store week with a non-midnight cutoff (e.g. Monday 04:00) is not describable by the date-granular `startDate`/`endDate`.
3. **Scheduled hours are published-only**: `scheduledVsActual.scheduled` now excludes draft (unpublished) shifts, matching My Schedule.

#### Mobile Action Required
- [ ] Handle `400 INVALID_PERIOD` on the hours endpoint.
- [ ] Prefer `periodStartAt`/`periodEndExclusiveAt` over `startDate`/`endDate` when displaying or chaining ranges.

### [2026-07-13] getHours: periodEnd now date-INCLUSIVE + hours clipped to requested days

**Type**: `breaking-change` (behavioral)
**Affects**: Hours tab / any consumer of `POST /api/mobile/scheduling/:typeNum/hours`

#### Summary
Two behavior fixes to the hours endpoint (PR #78 review):

1. **`periodEnd` is now the LAST day of the period, date-inclusive.** Previously the value was effectively an exclusive instant; a Mon..Sun request (`"periodStart": "2025-01-06", "periodEnd": "2025-01-12"`) silently dropped Sunday. It now returns exactly Mon through Sun. If you were compensating by sending the day AFTER the period's last day, stop — that day's hours would now be included.

2. **Hours are clipped to the requested days.** Previously any partial-week request was expanded to full store weeks — a Wednesday-to-Thursday request returned the entire store week's hours. Now only the requested days are summed. Weekly OT is still computed with full-week context and attributed to the days past the threshold (the tail of the week), so per-day requests across a week sum exactly to the full-week totals.

Additionally (backend-internal but visible in the numbers): when the Spec 049 break-policy engine is enabled for a store, breaks recorded with `breakType = NULL` are now classified paid/unpaid by the policy evaluator inside the OT engine too, so `totalHours`/`overtimeHours` from this endpoint agree with the approved web timesheet.

#### Details
- `periodStart` / `periodEnd`: `YYYY-MM-DD`, both inclusive.
- Omitting both still defaults to the current store-configured work week.
- `scheduledVsActual.scheduled` now also covers the requested period (previously always 7 days from `periodStart`, so partial-period actuals were compared against a full week and multi-week requests dropped scheduled hours past the first week).
- Response shape unchanged.

#### Mobile Action Required
- [ ] If the app sends `periodEnd` as an exclusive bound (day after the period), change it to the period's actual last day.
- [ ] Re-verify the Hours tab totals for the current week after backend deploy.

### [2026-07-11] Mobile break endpoints + break state in clock status

**Type**: `new-endpoint` + `enhancement`
**Affects**: Clock tab (break start/end), clock status polling
**Backend Spec**: punch-system audit Phase 1 (WS-3, F-02/F-19), branch `punch-audit-phase1`

#### Summary
The Team app can now start and end breaks — previously there was NO mobile break
capture at all. `clock/status` also reports break state so the UI can render an
"On Break" screen with a running timer. Clock-out is now REJECTED while on break
(end the break first), matching the kiosk state machine.

#### Details

**POST `/api/mobile/scheduling/:typeNum/clock/break/start`** (JWT auth)
```json
{ "deviceId": "abc123", "breakType": "unpaid" }
```
- `breakType` optional: `"paid" | "unpaid"`, defaults `"unpaid"`
- 200: `{ "success": true, "data": { "punchId": 77, "punchTime": "2026-07-11T13:05:00-05:00", "breakType": "unpaid" } }`
- 409 `NOT_CLOCKED_IN` — must be clocked in to start a break
- 409 `ALREADY_ON_BREAK` — double-tap / already on break
- 400 `INVALID_BREAK_TYPE`, `MISSING_DEVICE_ID`
- 403 `MOBILE_CLOCK_DISABLED`

**POST `/api/mobile/scheduling/:typeNum/clock/break/end`** (JWT auth)
```json
{ "deviceId": "abc123" }
```
- 200: `{ "success": true, "data": { "punchId": 78, "punchTime": "...", "breakType": "unpaid", "breakMinutes": 27 } }`
- 409 `NOT_ON_BREAK`
- 403 `MOBILE_CLOCK_DISABLED`

**`clock/status` new fields**
```json
{
  "isOnBreak": true,
  "breakStartedAt": "2026-07-11T12:30:00-05:00",
  "breakType": "unpaid",
  "canStartBreak": false,
  "canEndBreak": true
}
```
- `canClockOut` is now `false` while on break.

**`clock/out` new rejection**
- 409 `ON_BREAK` — "End your break before clocking out". Handle by prompting the
  user to end the break (or auto-navigating to the break screen).

**Error taxonomy (all clock endpoints)**: `UNKNOWN_FAILURE` (HTTP 500) means the
punch may or may not have landed — re-fetch `clock/status` before retrying; only
the specific 4xx codes above are safe to blind-retry after fixing the cause.

#### Mobile Action Required
- [ ] Add Break start/end UI to the Clock tab (buttons gated on `canStartBreak`/`canEndBreak`)
- [ ] Render On-Break state + timer from `isOnBreak`/`breakStartedAt`
- [ ] Handle the new 409 `ON_BREAK` rejection on clock-out
- [ ] On `UNKNOWN_FAILURE`, re-fetch status instead of blind-retrying

### [2026-05-21] Spec 003 Phase 1: real-time schedule events + unpublish + tap-to-chat

**Type**: `new-endpoint` + `enhancement`
**Affects**: Schedule tab, real-time updates, chat-from-schedule
**Backend Spec**: 003-schedule-visibility (Phase 1, T1.3–T1.7 landed)

#### Summary
Schedule mutations are now broadcast via Ably. Every shift payload carries
`hasUnpublishedChange` for the EDITED badge. New unpublish endpoint (manager-only) and
tap-to-chat 1:1 channel resolver are live. Broadcast is gated by
`ABLY_SCHEDULING_BROADCAST_ENABLED` (default OFF in prod; flip to ON when mobile is ready).

#### Details

**New REST endpoints:**

- `POST /api/mobile/scheduling/:typeNum/manager/schedule/unpublish`
  - Body: `{ "weekStart": "2026-05-18", "reason": "optional ≤500 chars" }`
  - 200: `{ "success": true, "unpublishId": 42, "shiftsAffected": 28, "employeesAffected": 12, "unpublishedAt": "2026-05-21T20:30:00+00:00" }`
  - 400: `MISSING_PARAM: weekStart`, `INVALID_PARAM: reason …`, `NOT_PUBLISHED: Week is not currently published`
  - 403: not a manager. No push/SMS fan-out — clients learn via `schedule.unpublished` Ably event.

- `POST /api/mobile/scheduling/:typeNum/chat/users/:userId/channel`
  - `:userId` is `uf_users.id` (central). Empty body.
  - 200: `{ "channelId": 4231, "isNew": true }`
  - 400 if caller targets themselves, 403 if no shared store, 404 if user not in `uf_users`.
  - Mounted under `/api/mobile/scheduling/` (the schedule route file), not `/api/mobile/chat/`.

**Shift payload changes (9 endpoints):**

Every employee-visible shift row carries an explicit authoritative
`isPublished: boolean`, derived from `publishedAt`. Published-only endpoints
emit `true` on every returned row; consumers must treat `false` or a missing
field as untrusted and hide the row. Every shift returned by these endpoints
also includes `hasUnpublishedChange: boolean`:

1. `POST /:typeNum/schedule`
2. `POST /:typeNum/schedule/upcoming`
3. `POST /:typeNum/schedule/today`
4. `POST /:typeNum/schedule/weekly`
5. `POST /:typeNum/schedule/shift/:shiftId`
6. `POST /:typeNum/manager/dashboard`
7. `POST /:typeNum/manager/whos-working`
8. `POST /:typeNum/manager/shifts/create`
9. `POST /:typeNum/manager/shifts/:shiftId/update`

Formula: `(publishedAt IS NOT NULL) AND (updated_at > publishedAt)`. Drives the "EDITED" badge.
Companion timestamps: `publishedAt`, `updatedAt` (camelCase in JSON; DB column is `updated_at`).
There is no `is_published` column — `isPublished` is derived from `publishedAt`.

**Ably real-time events (5):**

Channels:
- `kiosk_{typeNum}` — team channel; subscribe here from the Team app.
- `kiosk_{typeNum}_manager` — manager-only; Team app should NOT subscribe.

Events: `shift.created`, `shift.updated`, `shift.deleted`, `schedule.published`, `schedule.unpublished`
(note the dot, not snake_case).

Every payload carries an envelope:
```json
{
  "eventId": "01H...",
  "serverSequence": 42,
  "serverTimestamp": "2026-05-21T20:30:00+00:00",
  "actorEmployeeId": 123,
  "actorName": "Morgan",
  "typeNum": "ou00"
}
```

Event-time gating: shift events fire on the team channel ONLY when the shift is published at
dispatch time. The embedded `shift` object in `shift.created` and
`shift.updated` always carries explicit `isPublished: true` on the team
channel (manager-channel events may carry `false`). If that field is absent,
Team keeps the payload fail-closed and reconciles from REST rather than merging
it. Schedule-level events (`published`/`unpublished`) fire on both channels
always.

Full event-shape reference: `../buyerkiosk-web/docs/interfaces/scheduling-realtime-events.md`.

#### Mobile Action Required
- [ ] Subscribe to `kiosk_{typeNum}` for the user's accessible stores. Do NOT subscribe to the
      `_manager` channel — server-side JWT gating will reject the Team app's token anyway.
- [ ] Apply the deep-map conversion (`Map<Object?, Object?>` → `Map<String, dynamic>`) on every
      Ably payload before feeding it to Freezed `fromJson`. The Ably Flutter SDK delivers maps
      with platform-channel type erasure — without the conversion you get silent parse failures
      on nested envelope objects. See the `ably-flutter-map-type-deep-conversion` skill in
      `../buyerkiosk-web` for the recipe.
- [ ] Dedup by `eventId`. Order by `serverSequence`.
- [ ] LWW for `shift.updated` / `shift.deleted`: drop the event when
      `event.serverTimestamp <= local.updatedAt` on the affected shift.
- [ ] On `schedule.published` / `schedule.unpublished`, refetch the week from REST. Do not try
      to patch individual shifts inline — the server has already mutated every shift in the
      week, and one round-trip captures the full state.
- [ ] Render the EDITED badge directly off `hasUnpublishedChange` — do not recompute.
- [ ] Tap-to-chat: when the user taps a coworker tile on the schedule grid, call
      `POST /api/mobile/scheduling/:typeNum/chat/users/:userId/channel` with the coworker's
      `uf_users.id`. Use the returned `channelId` to open the existing chat screen. Treat 403
      as "no shared store" and show a friendly message.

#### Feature Flag (server-side)
`ABLY_SCHEDULING_BROADCAST_ENABLED` defaults OFF in production. The backend will flip it ON
once the mobile clients confirm subscription handling. While OFF the REST endpoints still
return `hasUnpublishedChange` and the unpublish endpoint still works — only the Ably
broadcasts are suppressed.

### [2026-05-12] Break policy v1: breakType ignored when flag enabled

**Type**: `breaking-change`
**Affects**: Clock / Break punch endpoints — manual override (`POST /api/mobile/scheduling/:typeNum/manager/punch`)
**Backend Spec**: Spec 049 T4.3

#### Summary
When `stores.breakPolicyEnabled = 1` for a store, the backend now **ignores** any `breakType`
field sent in break punch requests (`breakStart`, `breakEnd`). The break policy engine
classifies break type automatically. When the flag is off the old behaviour is preserved
(field defaults to `unpaid` if not sent).

A server-side warning is logged whenever `breakType` is received on a policy-enabled store.

#### Details

**Endpoint affected**: `POST /api/mobile/scheduling/:typeNum/manager/punch` (overridePunch action)
when `type = "break_start"` or `type = "break_end"`.

**New behaviour (flag ON)**:
- `"breakType"` in the request body is silently dropped.
- The persisted punch row has `breakType = NULL` (engine classifies on evaluation).
- A `[MobileClockService] breakType ignored` warning is emitted to the server error log.

**Legacy behaviour (flag OFF)**:
- `"breakType": "unpaid"` (default) / `"breakType": "paid"` still accepted and applied.

#### Mobile Action Required
- [ ] Stop sending `breakType` for break punches on stores where `breakPolicyEnabled` is `true`.
- [ ] Add a store-flag check: if the store's clock-status response exposes `breakPolicyEnabled`
      in the future, gate on it. For now, simply omit the field — it is harmless when the
      flag is off and correct when the flag is on.

### [2026-02-19] Complete Mobile Scheduling API - All Endpoints Ready

**Type**: `new-endpoint`
**Affects**: Schedule tab, Clock in/out, Availability, Time Off, Shift Swaps, Open Shifts, Manager Dashboard, Push Notifications
**Backend Spec**: Mobile Scheduling API (Phase 1-3 complete)

#### Summary
The **entire mobile scheduling API** is implemented and ready for consumption. This includes 50+ endpoints covering authentication, schedule viewing, clock in/out with GPS geofencing, availability management, time-off requests, shift swaps, open shift claiming, manager dashboard, and push notifications via FCM.

**Base URL**: `/api/mobile/scheduling`

**Authentication**: JWT Bearer tokens (15min access, 30-day refresh)

**IMPORTANT**: The mobile app was previously hitting `/{typeNum}/manager/...` paths directly. The correct base URL is `/api/mobile/scheduling/{typeNum}/...`. The OpenAPI spec at `docs/api/mobile-scheduling-openapi.yaml` is accurate.

#### Authentication Endpoints (No JWT required for login/refresh)

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/auth/login` | Email/password login, returns JWT access + refresh tokens |
| POST | `/auth/refresh` | Refresh access token using refresh token |
| POST | `/auth/logout` | Revoke refresh token (single device) |
| POST | `/auth/logout-all` | Revoke all refresh tokens (all devices) - **JWT required** |
| POST | `/auth/device-token` | Register/update FCM device token - **JWT required** |
| GET | `/auth/sessions` | List active sessions - **JWT required** |

**Login Request**:
```json
{
  "email": "user@example.com",
  "password": "password123"
}
```

**Login Response**:
```json
{
  "success": true,
  "accessToken": "eyJ...",
  "refreshToken": "abc123...",
  "expiresIn": 900,
  "user": {
    "id": 123,
    "email": "user@example.com",
    "firstName": "John",
    "lastName": "Smith",
    "photoUrl": "https://..."
  },
  "stores": [
    {
      "typeNum": "ou00",
      "role": "manager",
      "roleLevel": 2
    }
  ]
}
```

**Token Usage**: Include in all authenticated requests:
```
Authorization: Bearer <accessToken>
```

**Token Refresh**:
```json
POST /api/mobile/scheduling/auth/refresh
{ "refreshToken": "abc123..." }
```

#### Profile & Avatar Endpoints (JWT required, no store context)

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/me` | Get current user profile |
| POST | `/me/avatar` | Upload avatar (multipart/form-data) |
| DELETE | `/me/avatar` | Delete avatar |
| DELETE | `/me/devices/:deviceId` | Remove device token |

#### Schedule Endpoints (JWT + store context)

All schedule endpoints require `Authorization: Bearer <token>` header.

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/{typeNum}/schedule` | Get employee schedule (default: 35 days) |
| POST | `/{typeNum}/schedule/upcoming` | Get next N upcoming shifts |
| POST | `/{typeNum}/schedule/today` | Get today's shift |
| POST | `/{typeNum}/schedule/weekly` | Get weekly summary with actual hours |
| POST | `/{typeNum}/schedule/shift/{shiftId}` | Get specific shift details |
| POST | `/{typeNum}/schedule/daily` | Get all shifts for a date (team view) |

**Schedule Request Example**:
```json
POST /api/mobile/scheduling/ou00/schedule
{
  "startDate": "2026-02-01",
  "endDate": "2026-03-07"
}
```

#### Clock Endpoints (JWT + store context)

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/{typeNum}/clock/status` | Get current clock status + geofence validation |
| POST | `/{typeNum}/clock/in` | Clock in (GPS required, accuracy ≤100m) |
| POST | `/{typeNum}/clock/out` | Clock out (GPS optional) |
| POST | `/{typeNum}/clock/override-request` | Request manual clock override |
| POST | `/{typeNum}/hours` | Get hours worked for pay period |
| POST | `/{typeNum}/clock/history` | Get punch history |

**Clock In Request**:
```json
POST /api/mobile/scheduling/ou00/clock/in
{
  "latitude": 32.7767,
  "longitude": -96.7970,
  "locationAccuracy": 15.0,
  "deviceId": "device-uuid"
}
```

#### Availability Endpoints (JWT + store context)

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/{typeNum}/availability` | Get recurring availability |
| POST | `/{typeNum}/availability/set` | Set weekly availability patterns |
| POST | `/{typeNum}/availability/exception` | Add one-time exception |
| POST | `/{typeNum}/availability/exception/{exceptionId}/remove` | Remove exception |

#### Time Off Endpoints (JWT + store context)

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/{typeNum}/time-off` | Get all time-off requests |
| POST | `/{typeNum}/time-off/request` | Submit time-off request |
| POST | `/{typeNum}/time-off/{requestId}/cancel` | Cancel request |
| POST | `/{typeNum}/time-off/types` | Get available time-off types |

**Flutter Aliases** (same behavior, alternative paths):
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/{typeNum}/requests/time-off` | Get time-off requests |
| POST | `/{typeNum}/requests/time-off` | Submit time-off request |
| GET | `/{typeNum}/requests/swap` | Get swap requests |
| POST | `/{typeNum}/requests/swap` | Create swap request |

#### Open Shifts Endpoints (JWT + store context)

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/{typeNum}/open-shifts` | Get available open shifts |
| POST | `/{typeNum}/open-shifts/{shiftId}/claim` | Claim an open shift |

#### Shift Swap Endpoints (JWT + store context)

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/{typeNum}/swaps` | Get my swap requests |
| POST | `/{typeNum}/swaps/pending` | Get swaps awaiting my response |
| POST | `/{typeNum}/swaps/targets` | Find eligible swap partners for a shift |
| POST | `/{typeNum}/swaps/request` | Propose a swap |
| POST | `/{typeNum}/swaps/{swapId}/respond` | Accept/decline swap |
| POST | `/{typeNum}/swaps/{swapId}/cancel` | Cancel swap request |

**Swap Flow**: Initiator proposes → Coworker accepts/declines → Manager approves/denies

#### Notification Preferences (JWT + store context)

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/{typeNum}/notifications/preferences` | Get notification preferences |
| POST | `/{typeNum}/notifications/preferences/update` | Update single preference |
| POST | `/{typeNum}/notifications/preferences/update-batch` | Batch update preferences |
| POST | `/{typeNum}/notifications/preferences/reset` | Reset to defaults |
| POST | `/{typeNum}/notifications/categories` | Get available notification categories |

#### Manager Endpoints (JWT + store context + manager role)

All manager endpoints require `role` of Owner (1) or Manager (2) in `userStoreAssignments`.

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/{typeNum}/manager/dashboard` | Dashboard stats (today's shifts, pending requests, labor) |
| POST | `/{typeNum}/manager/labor-cost` | Weekly labor cost breakdown by employee |
| POST | `/{typeNum}/manager/whos-working` | Who's currently clocked in |
| POST | `/{typeNum}/manager/daily-schedule` | All shifts for a specific date |
| POST | `/{typeNum}/manager/requests` | List all pending requests (time-off, swaps, overrides) |
| POST | `/{typeNum}/manager/requests/{requestId}/decision` | Approve or deny a request |
| POST | `/{typeNum}/manager/requests/batch` | Batch approve/deny multiple requests |
| POST | `/{typeNum}/manager/shifts/create` | Create a new shift |
| POST | `/{typeNum}/manager/shifts/{shiftId}/update` | Update a shift |
| POST | `/{typeNum}/manager/shifts/{shiftId}/delete` | Delete a shift (soft delete) |
| POST | `/{typeNum}/manager/schedule/copy-week` | Copy one week's schedule to another |
| POST | `/{typeNum}/manager/schedule/publish` | Publish schedule (triggers notifications) |
| POST | `/{typeNum}/manager/clock/override` | Manual punch override |
| POST | `/{typeNum}/manager/notify` | Send notification to team members |

**Manager Dashboard Response Example**:
```json
{
  "success": true,
  "todayShifts": { "total": 8, "assigned": 6, "open": 2 },
  "whosWorking": { "clockedIn": 4, "onBreak": 1, "expected": 6 },
  "pendingRequests": { "timeOff": 3, "swaps": 1, "overrides": 0, "total": 4 },
  "weeklyLabor": { "scheduledHours": 320, "workedHours": 185.5, "estimatedCost": 4250.00 }
}
```

#### Response Patterns

**Success**:
```json
{
  "success": true,
  ...data fields inline...
}
```

**Error**:
```json
{
  "success": false,
  "error": "Human-readable message",
  "code": "ERROR_CODE"
}
```

**Common Error Codes**: `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `MISSING_FIELDS`, `INVALID_CREDENTIALS`, `TOKEN_EXPIRED`, `STORE_ACCESS_DENIED`, `GEOFENCE_VIOLATION`, `ALREADY_CLOCKED_IN`, `SHIFT_ALREADY_CLAIMED`

**HTTP Status Codes**: 200, 201, 400, 401, 403, 404, 409, 410, 500, 503

#### Architecture Notes

- **JWT tokens are user-scoped** (contain `userId` only, NOT store-specific)
- Store context is resolved per-request from the `{typeNum}` path parameter
- Multi-store: same token works across all stores the user has access to
- All times stored in UTC, converted to store timezone in responses
- GPS geofencing validates clock-in within 250m of store location
- Push notifications via Firebase Cloud Messaging (FCM)
- Audit logging on all write operations

#### Mobile Action Required
- [ ] **CRITICAL**: Update base URL from `/{typeNum}/manager/...` to `/api/mobile/scheduling/{typeNum}/...`
- [ ] Implement JWT authentication flow (login, token refresh, logout)
- [ ] Re-enable Schedule tab in the mobile app
- [ ] Implement schedule viewing screens (daily, weekly, upcoming)
- [ ] Implement clock in/out with GPS location capture
- [ ] Implement availability management
- [ ] Implement time-off request submission and viewing
- [ ] Implement shift swap workflow (propose, respond, cancel)
- [ ] Implement open shift viewing and claiming
- [ ] Implement manager dashboard (for manager-role users)
- [ ] Implement manager approval workflow (time-off, swaps, overrides)
- [ ] Implement manager shift CRUD (create, update, delete)
- [ ] Implement notification preferences
- [ ] Register FCM device tokens after login
- [ ] Handle 401 responses with automatic token refresh

#### Code Reference
- Routes: `userfrosting/routes/mobile-scheduling.php`
- Controllers: `userfrosting/src/BuyerKiosk/MobileScheduling/Controllers/`
- Services: `userfrosting/src/BuyerKiosk/MobileScheduling/Services/`
- Middleware: `userfrosting/src/BuyerKiosk/MobileScheduling/Middleware/`
- OpenAPI Spec: `docs/api/mobile-scheduling-openapi.yaml`

---

### [2026-02-09] Backstock Action 6: "Added Items" Support

**Type**: `enhancement`
**Affects**: Backstock features (if integrated)

#### Summary
New action type 6 ("Added Items") added to backstock system. The `getBackstockActionTypes` endpoint now returns a `requiresCategory` boolean on all action types, and includes the new action 6. If the Team app interacts with backstock action types or displays bin action history, this new type should be handled.

#### Details
- Action types endpoint now returns `requiresCategory` field (bool) on all types
- New action type: `{ "id": 6, "name": "Added Items", "description": "Items added to bin", "requiresCategory": true }`
- Action 6 supports: `mainCategoryId`, `subCategoryIds[]`, `replaceAll`, `resetAgeDate`

#### Mobile Action Required
- [ ] If displaying backstock action history, handle action type 6 ("Added Items")
- [ ] If using action types list, add `requiresCategory` field to model

### 2026-01-06 Staff Chat: Read Status Fix - lastReadMessageId Now Included

**Type**: `bug-fix`
**Affects**: Staff Chat feature (Read/unread message tracking)
**Backend Spec**: 024-staff-chat-backend

#### Summary
Fixed a bug where the mobile app couldn't properly track read/unread messages. The API was returning `unreadCount` but not `lastReadMessageId`, making it impossible for the mobile app to:
1. Display which messages are read vs unread
2. Know what message ID to send when calling `markRead`

#### Details

**Affected Endpoints**:

1. **GET `/api/mobile/staff-chat/{typeNum}/channels`** - List channels
   - Now includes `lastReadMessageId` in each channel object

2. **GET `/api/mobile/staff-chat/{typeNum}/channels/{channelId}/messages`** - Get messages
   - Now includes `lastReadMessageId` as a top-level response field

**Updated Response - GET channels**:
```json
{
  "success": true,
  "channels": [
    {
      "id": 1,
      "name": "Public",
      "description": "Store-wide channel",
      "accessLevel": "public",
      "unreadCount": 5,
      "lastReadMessageId": 142,    // NEW FIELD
      "isMuted": false,
      "accessViaOverride": false,
      "createdAt": "2026-01-01T00:00:00"
    }
  ]
}
```

**Updated Response - GET messages**:
```json
{
  "success": true,
  "messages": [...],
  "hasMore": true,
  "lastReadMessageId": 142    // NEW FIELD
}
```

| New Field | Type | Description |
|-----------|------|-------------|
| `lastReadMessageId` | `int` | User's current read position (high water mark). `0` if user has never read any messages in this channel. |

**How to Use**:
1. When displaying messages, any message with `id > lastReadMessageId` is unread
2. When calling `POST /channels/{id}/read`, send the highest message ID the user has seen:
   ```json
   { "lastReadMessageId": 156 }
   ```

#### Mobile Action Required
- [ ] **Critical**: Use `lastReadMessageId` from channel list to display unread badges correctly
- [ ] Use `lastReadMessageId` from messages response to determine which messages to display as unread
- [ ] When user views a channel, call `markRead` with the highest message ID from the loaded messages
- [ ] Messages with `id <= lastReadMessageId` are "read", messages with `id > lastReadMessageId` are "unread"

#### Code Reference
- Fix: `userfrosting/src/BuyerKiosk/StaffChat/Services/ChannelAccessService.php:163-178`
- Fix: `userfrosting/src/BuyerKiosk/StaffChat/Controllers/StaffChatApiController.php:500-511`
- New method: `userfrosting/src/BuyerKiosk/StaffChat/Repositories/ReadReceiptRepository.php:176-206`

---

### 2026-01-06 Weekly Schedule Now Includes Actual Worked Times

**Type**: `enhancement`
**Affects**: My Schedule / Weekly Summary screens

#### Summary
The weekly schedule endpoint now returns actual clock in/out times and break data for shifts that have already been worked. This allows the app to display "Worked 8:57am - 5:03pm" instead of just the scheduled "9:00am - 5:00pm".

#### Details

**Endpoint**: `POST /api/mobile/scheduling/{typeNum}/schedule/weekly`

**Enhanced Response** - Each shift in `dayBreakdown` now includes:
```json
{
  "dayBreakdown": {
    "Monday": [
      {
        "shiftId": 426,
        "startTime": "09:00",
        "endTime": "17:00",
        "hours": 8,
        "isWorked": true,
        "actualClockIn": "08:57",
        "actualClockOut": "17:03",
        "actualHours": 7.85,
        "breaks": [
          {
            "startTime": "12:00",
            "endTime": "12:30",
            "durationMinutes": 30,
            "breakType": "unpaid"
          }
        ]
      }
    ],
    "Wednesday": [
      {
        "shiftId": 449,
        "startTime": "09:00",
        "endTime": "17:00",
        "hours": 8,
        "isWorked": false,
        "actualClockIn": null,
        "actualClockOut": null,
        "actualHours": null,
        "breaks": []
      }
    ]
  },
  "totalHours": 38,
  "totalWorkedHours": 15.85
}
```

| New Field | Type | Description |
|-----------|------|-------------|
| `isWorked` | `bool` | `true` if this shift has actual clock data (past + clocked) |
| `actualClockIn` | `string\|null` | Actual clock-in time (HH:mm format) |
| `actualClockOut` | `string\|null` | Actual clock-out time (HH:mm format) |
| `actualHours` | `float\|null` | Actual hours worked (minus unpaid breaks) |
| `breaks` | `array` | Array of breaks taken during shift |
| `totalWorkedHours` | `float` | Sum of actual hours for worked shifts |

**Break Object**:
| Field | Type | Description |
|-------|------|-------------|
| `startTime` | `string` | Break start (HH:mm) |
| `endTime` | `string` | Break end (HH:mm) |
| `durationMinutes` | `int` | Break duration in minutes |
| `breakType` | `string` | `paid` or `unpaid` |

#### Mobile Action Required
- [ ] For past shifts with `isWorked: true`, display actual times instead of scheduled times
- [ ] Indicate worked vs scheduled visually (e.g., "Worked" badge, checkmark icon)
- [ ] Show break details if `breaks` array is not empty
- [ ] Display `totalWorkedHours` alongside `totalHours` for week summary
- [ ] Handle `isWorked: false` for past shifts (missed/called off)

#### Code Reference
`userfrosting/src/BuyerKiosk/MobileScheduling/Services/MobileScheduleService.php:199-362`

---

### 2026-01-06 New Daily Schedule Endpoint (Team View)

**Type**: `new-endpoint`
**Affects**: Team scheduling screens (Daily view, Who's working today)

#### Summary
New endpoint allows **all team members** to view all shifts for any specific date, including who's working, shift times, positions, and open shifts. Great for "Who's working today?" feature.

#### Details

**Endpoint**: `POST /api/mobile/scheduling/{typeNum}/schedule/daily`

(Also available at `/manager/daily-schedule` for managers)

**Request Body**:
```json
{
  "date": "2026-01-15"  // Optional, defaults to today if not provided
}
```

**Response**:
```json
{
  "success": true,
  "date": "2026-01-15",
  "shifts": [
    {
      "shiftId": 123,
      "employeeId": 45,
      "employeeName": "John Smith",
      "profilePicture": "https://...",
      "isOpenShift": false,
      "startTime": "09:00",
      "endTime": "17:00",
      "startDateTime": "2026-01-15T09:00:00-06:00",
      "endDateTime": "2026-01-15T17:00:00-06:00",
      "positionId": 2,
      "positionName": "Sales",
      "positionColor": "#4CAF50",
      "notes": "Opening shift"
    },
    {
      "shiftId": 124,
      "employeeId": null,
      "employeeName": null,
      "profilePicture": null,
      "isOpenShift": true,
      "startTime": "14:00",
      "endTime": "22:00",
      "startDateTime": "2026-01-15T14:00:00-06:00",
      "endDateTime": "2026-01-15T22:00:00-06:00",
      "positionId": 3,
      "positionName": "Cashier",
      "positionColor": "#2196F3",
      "notes": null
    }
  ],
  "summary": {
    "totalShifts": 12,
    "assignedShifts": 10,
    "openShifts": 2,
    "uniqueEmployees": 8
  }
}
```

**Key Features**:
- Works for any date (past, present, or future)
- Includes open shifts (`isOpenShift: true`, `employeeId: null`)
- Returns shift notes if any
- Includes position colors for visual differentiation
- Summary stats for quick overview

**Authentication**: JWT Bearer token (any authenticated team member)

#### Mobile Action Required
- [ ] Implement "Who's working" daily view for all team members
- [ ] Display shifts sorted by start time
- [ ] Show open shifts distinctively (e.g., dashed border, "Open" badge)
- [ ] Use position colors for visual organization
- [ ] Consider date picker to navigate between days

#### Code Reference
- Service: `userfrosting/src/BuyerKiosk/MobileScheduling/Services/ManagerDashboardService.php:377`
- Controller: `userfrosting/src/BuyerKiosk/MobileScheduling/Controllers/MobileManagerController.php:145`
- Route (Team): `userfrosting/routes/mobile-scheduling.php:263`
- Route (Manager): `userfrosting/routes/mobile-scheduling.php:649`

---

### 2026-01-06 Time Off Requests Now Include Approver Info

**Type**: `enhancement`
**Affects**: Time Off Request screens (My Requests list, Request details)

#### Summary
Time off request responses now include the approver's user ID and name, allowing the mobile app to display who approved or denied each request.

#### Details

**Endpoint**: `POST /api/mobile/scheduling/{typeNum}/time-off`

**New Fields in Response**:
```json
{
  "requests": [
    {
      "requestId": 123,
      "startDate": "2026-02-01",
      "endDate": "2026-02-03",
      "requestType": "vacation",
      "typeLabel": "Vacation",
      "daysCount": 3,
      "reason": "Family trip",
      "status": "approved",
      "submittedAt": "2026-01-15T10:30:00+00:00",
      "reviewedAt": "2026-01-16T14:22:00+00:00",
      "reviewedByUserId": 48,
      "reviewerName": "John Manager",
      "reviewNote": "Approved, enjoy your vacation!"
    }
  ]
}
```

| New Field | Type | Description |
|-----------|------|-------------|
| `reviewedByUserId` | `int\|null` | User ID of the manager who approved/denied (null if pending) |
| `reviewerName` | `string\|null` | Full name of the reviewer (null if pending) |

#### Mobile Action Required
- [ ] Display reviewer name on approved/denied time off requests (e.g., "Approved by John Manager")
- [ ] Optional: Link reviewer to employee profile if needed

#### Code Reference
`userfrosting/src/BuyerKiosk/MobileScheduling/Services/TimeOffService.php:199-200`

---

### 2026-01-02 Staff Chat Mobile API Released

**Type**: `new-endpoint`
**Affects**: New Staff Chat feature (Team communication)
**Backend Spec**: 024-staff-chat-backend

#### Summary
Complete Staff Chat mobile API is now available. Enables real-time team messaging with channels, @mentions, reactions, and file attachments.

#### Details

**Base URL**: `/api/mobile/staff-chat/{typeNum}/`

**OpenAPI Spec**: `docs/api/staff-chat-mobile-openapi.yaml` (1,224 lines, complete documentation)

**Key Endpoints**:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/channels` | List accessible channels with unread counts |
| POST | `/channels` | Create channel (manager+) |
| GET | `/channels/{id}/messages` | Get messages (paginated) |
| POST | `/channels/{id}/messages` | Send message |
| POST | `/channels/{id}/read` | Mark messages as read |
| PATCH | `/messages/{id}` | Edit message (24h window) |
| DELETE | `/messages/{id}` | Delete message |
| POST | `/messages/{id}/reactions` | Add emoji reaction |
| GET | `/mentions` | Get @mention inbox |
| POST | `/ably-token` | Get Ably token for real-time |

**Authentication**: JWT Bearer token (same as scheduling API)

**Real-time**: Ably channels `chat:{typeNum}:{channelId}` for messages

#### Mobile Action Required
- [ ] Review OpenAPI spec at `docs/api/staff-chat-mobile-openapi.yaml`
- [ ] Implement Staff Chat screens when ready
- [ ] Set up Ably subscription for real-time message updates
- [ ] Handle FCM push notifications for new messages (when implemented)

#### Code Reference
- Controller: `userfrosting/src/BuyerKiosk/StaffChat/Controllers/MobileStaffChatController.php`
- Routes: `userfrosting/routes/groups/mobile-staff-chat.php`

---

---

## Update Template

```markdown
### [YYYY-MM-DD] [Brief Title]

**Type**: `new-endpoint` | `breaking-change` | `enhancement` | `deprecation` | `bug-fix`
**Affects**: [Which mobile features/screens]
**Backend Spec**: [Spec ID if applicable]

#### Summary
[What changed and why]

#### Details
[Endpoint details, payload changes, new fields, etc.]

#### Mobile Action Required
- [ ] [Specific action item]
- [ ] [Another action if needed]

#### Code Reference
`userfrosting/src/path/to/file.php:123`
```

---

## Archive

<!-- Move older updates here to keep Recent Updates section manageable -->

### 2025-01-02 - Initial Setup

**Type**: `enhancement`
**Affects**: All mobile features

#### Summary
Inter-agent communication protocol established between mobile and backend codebases.

#### Details
- Mobile requests API info via: `buyerkiosk-web/docs/api/mobile-agent-requests.md`
- Backend sends updates via: `buyerkiosk-team/docs/backend-api-updates.md`
- Main API reference: `buyerkiosk-web/docs/api/MOBILE_API.md`

#### Mobile Action Required
- [x] Protocol documented in CLAUDE.md

---

## Notes for Backend Agents

When to write here:
- **New endpoints** added for mobile consumption
- **Breaking changes** to existing mobile APIs
- **New fields** added to responses mobile should use
- **Deprecations** mobile needs to migrate from
- **Bug fixes** that change expected behavior
- **Authentication/security** changes affecting mobile

Include:
- Endpoint paths and methods
- Request/response payload examples
- Error codes and messages
- Any migration steps needed

### [2026-07-16] Store identifier canonicalization on mobile routes (PR #78 merged)
**Type**: `enhancement`
**Affects**: All /api/mobile/scheduling/:typeNum/* and close-report routes
#### Summary
The backend now canonicalizes the store identifier from the resolved store record. Case/padding variants of typeNum (e.g. `PC00`) keep working; anything beyond case/padding (accent/whitespace aliases) now returns 403 "Store access denied" instead of half-working with split cache/realtime channels.
#### Details
No payload or response changes. Apps that send the typeNum exactly as received from the backend (standard behavior) are unaffected.
#### Mobile Action Required
- [ ] None, unless the app constructs typeNum strings manually — always echo the backend-provided value.

### [2026-07-16] Login payload: `workWeekStartDay` added; schedule weekly default now store-aligned
**Type**: `enhancement`
**Affects**: Login, Schedule (weekly summary)
#### Summary
1. Each store object in the `POST /api/mobile/scheduling/auth/login` response now includes `workWeekStartDay` — the store's configured work-week start day.
2. `POST /api/mobile/scheduling/:typeNum/schedule/weekly` no longer hard-codes ISO Monday for its DEFAULT week: when the request body omits `weekStart`, the returned week is the current week aligned to the store's `workWeekStartDay`. An explicit `weekStart` in the request body is still honored verbatim (no server-side snapping).
#### Details
Login response, per store entry in `stores[]`:
```json
{
  "typeNum": "pc00",
  "storeName": "Plato's Closet #123",
  "role": "employee",
  "roleId": 4,
  "isManager": false,
  "hasScheduling": true,
  "workWeekStartDay": "sun",
  "employeeId": 42
}
```
- `workWeekStartDay`: string enum, one of `"sun"`, `"mon"`, `"tue"`, `"wed"`, `"thu"`, `"fri"`, `"sat"`. Defaults to `"mon"` when the store has no explicit setting. Matches the field of the same name already returned by the Live app's `/api/mobile/auth/login`.
- Weekly summary (`schedule/weekly`): `weekStart`/`weekEnd` in the response reflect the store-aligned week when `weekStart` was omitted. For `"mon"` stores nothing changes. `isPublished` now matches the desktop publish records for non-Monday stores.
#### Mobile Action Required
- [ ] Parse `workWeekStartDay` from the login `stores[]` entries (model default `"mon"` for backward compatibility).
- [ ] When computing week ranges client-side (week navigation, explicit `weekStart` request values), align to `workWeekStartDay` instead of assuming Monday.

### [2026-07-16] Shift Offers: employee offers own shift up for grabs (NEW endpoints)
**Type**: `new-endpoint`
**Affects**: Requests (new "Offer shift" flow), Manager approval queue, Open shifts, Notifications

#### Summary
An employee can now offer their own shift up for grabs. The offer sits PENDING in the manager request queue; on approval the shift becomes an **open shift** (`employeeId = NULL`) and enters the existing open-shift pool where coworkers claim it via the existing auto-approved claim flow (`POST /:typeNum/open-shifts/:shiftId/claim`). On denial the shift stays with the employee. The employee can cancel while pending. Offers expire automatically at the shift's start time (lazy expiry — list reads always reflect it).

#### Details

**Employee endpoints** (JWT + store context, same middleware as the swap routes):

1. `POST /api/mobile/scheduling/:typeNum/requests/offer`
   Body: `{"shiftId": int, "message": string|null}` (message ≤500 chars, HTML stripped/trimmed like the swap message; blank ⇒ null)
   Success: **HTTP 201** `{"success": true, "offer": <OfferPayload>}`
   Errors (same envelope as swap propose — `{"success": false, "error": string, "code": CODE}`):
   - `MISSING_FIELDS` (400) — shiftId absent
   - `SHIFT_NOT_FOUND` (404)
   - `NOT_YOUR_SHIFT` (403)
   - `SHIFT_IN_PAST` (400)
   - `NOT_PUBLISHED` (400)
   - `ACTIVE_OFFER` (409) — shift already has a pending/approved offer
   - `ACTIVE_SWAP` (409) — shift is in an active swap request

2. `GET /api/mobile/scheduling/:typeNum/requests/offer`
   The caller's own offers, newest first: **HTTP 200** `{"offers": [<OfferPayload>...]}`

3. `DELETE /api/mobile/scheduling/:typeNum/requests/offer/:offerId`
   Cancel own PENDING offer. **HTTP 200** `{"success": true}`
   Errors: `OFFER_NOT_FOUND` (404), `NOT_YOUR_OFFER` (403), `INVALID_STATE` (409, not pending — includes offers that already expired).

**OfferPayload** (exact keys):
```json
{
  "offerId": 12,
  "shiftId": 345,
  "status": "pending_manager",
  "message": "string|null",
  "createdAt": "ISO8601",
  "expiresAt": "ISO8601",
  "managerNote": "string|null",
  "shift": {
    "shiftId": 345,
    "date": "YYYY-MM-DD",
    "startTime": "HH:MM",
    "endTime": "HH:MM",
    "roleId": 4,
    "roleName": "string|null"
  }
}
```
- `status` enum: `pending_manager | approved | denied | cancelled | expired`
- `expiresAt` = the shift's start datetime (UTC ISO8601). Offers past this instant read as `expired`.
- `shift.startTime`/`shift.endTime` are store-local `HH:MM` (no seconds, per the pinned contract).
- `shift.roleId`/`shift.roleName` map to the shift's schedulePositions assignment; **both are null for shifts without a position** (roleName was already spec'd nullable; roleId nullability is the one unavoidable reality — shifts can exist with no position).

**Manager side:**
- `POST /:typeNum/manager/requests` (and the web approval queue) now include offer entries:
  `{"type": "offer", "requestId": <offerId>, "employeeId", "employeeName", "submittedAt", "message", "createdAt", "expiresAt", "shift": {<same shift sub-object>}}`
- `POST /:typeNum/manager/dashboard` → `pendingRequests` now includes `"offers": <int>` alongside `timeOff`/`swaps`/`overrides`.
- Decision: `POST /:typeNum/manager/requests/:requestId/decision` with body `{"type": "offer", "approved": bool, "note": string|null}` (batch endpoint accepts `type: "offer"` too). Approval re-validates (offer still pending, not expired, shift still owned by the offerer, still future) inside a transaction, then unassigns the shift (`employeeId = NULL`, `claimedAt = NULL`).

**Notifications (FCM `type` stays coarse; fine-grained meaning rides in the payload `event` field):**
- New offer → managers channel (`schedule:{typeNum}:managers`) Ably event `offer_received` with `{offerId, shiftId, employeeId, employeeName, shiftDate, shiftTime, message, submittedAt}`. `offer_received` maps to the coarse FCM type `schedule`.
- Approved → offering employee gets `request_approved` (requestType `"shift offer"`), AND all other active store employees get an `open_shift_posted` broadcast (Ably per-employee channel + FCM, coarse type `open_shift`) with `{shiftId, date, startTime, endTime, position, postedAt}` so they can claim the newly open shift.
- Denied → offering employee gets `request_denied` (requestType `"shift offer"`, `reason` = manager note).

**DB:** new store-DB table `scheduleShiftOffers` (migration `20260716_002_schedule_shift_offers.json`).

#### Mobile Action Required
- [ ] Wire the offer create/list/cancel flows against the endpoints above.
- [ ] Render `type: "offer"` entries in the manager pending-requests queue and send decisions with `type: "offer"`.
- [ ] Handle the `offer_received` (managers) and `open_shift_posted` (employees) events; deep-link `open_shift_posted` to the open-shifts claim screen.
- [ ] Treat `shift.roleId` as nullable.

#### Code Reference
- `userfrosting/src/BuyerKiosk/MobileScheduling/Services/ShiftOfferService.php`
- `userfrosting/src/BuyerKiosk/MobileScheduling/Models/ShiftOfferRequest.php`
- `userfrosting/src/BuyerKiosk/MobileScheduling/Repositories/ShiftOfferRepository.php`
- `userfrosting/src/BuyerKiosk/MobileScheduling/Controllers/MobileRequestController.php` (proposeOffer/getOffers/cancelOffer)
- `userfrosting/routes/mobile-scheduling.php` (requests/offer routes)
- `userfrosting/src/BuyerKiosk/MobileScheduling/Services/ManagerDashboardService.php` (offer pending list + decision dispatch)
