# Backend API Updates

## Backend Status Updates

### [2026-08-30] Manager shift update preserves explicit null employeeId

**Type**: `bug-fix`
**Affects**: Scheduling shift edit / convert assigned shift to open shift

#### Summary
`POST /api/mobile/scheduling/:typeNum/manager/shifts/:shiftId/update` now
distinguishes an omitted `employeeId` (no change) from an explicit
`"employeeId": null` (unassign and convert to an open shift). The controller
and service previously used null-dropping `isset` checks, so the Live app's
open-shift toggle could report success while retaining the old assignment.

#### Mobile Action Required
- [x] Live app already sends an explicit null when `clearEmployeeId=true`.

### [2026-07-17] Staff-chat moderation delete + member roleLevel

**Type**: `enhancement`
**Affects**: Chat module — message delete authorization, channel members payload

#### Summary
`DELETE /api/mobile/staff-chat/{typeNum}/messages/{messageId}` now enforces a
role-aware moderation matrix, and the channel members payload exposes each
member's store role so clients can gate moderation UI.

#### Details
Authorization matrix for message delete (was effectively "own OR manager+ deletes
anything"; the mobile client previously only offered own-message delete):
- Anyone: own messages
- Owner (role 1): any message
- Manager (role 2): any message EXCEPT one sent by a **current** store owner
  (sender role resolved from active `userStoreAssignments`; fails closed)
- System messages: never deletable
- Unauthorized attempts return 403 `forbidden`

`GET /channels/{channelId}/members` members entries now include
`roleLevel` (int, 1=Owner 2=Manager 3=Shift Lead 4=Employee, nullable).
Implementation: `StaffChatApiController::deleteMessage()` + `isCurrentStoreOwner()`;
OpenAPI updated in `docs/api/staff-chat-mobile-openapi.yaml`.

#### Mobile Action Required
- [x] Live app: gate Delete action on own || owner || (manager && sender not owner) — done 2026-07-17
- [x] `buyerkiosk_chat` package: parse `roleLevel` into `Member.roleLevel` (+ `isStoreOwner`) — done
- [ ] Team app: optionally adopt moderation delete UI (backend already permits it)

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

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

#### Summary
Schedule mutations are broadcast via Ably on a two-channel layout. The Live app (manager-facing)
should subscribe to BOTH the team channel and the manager-only channel. A new manager-only
unpublish endpoint reverts a published week to draft without sending push/SMS. Every shift
payload now carries `hasUnpublishedChange` for the EDITED badge.

#### 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.
  - Side effects: clears `publishedAt`/`publishedByUserId` on every still-published shift in the
    week, stamps `schedulePublished.unpublishedAt = NOW()`, writes a `schedule_unpublished`
    audit entry. **No push/SMS** — clients learn via the `schedule.unpublished` Ably event.

- `POST /api/mobile/scheduling/:typeNum/chat/users/:userId/channel`
  - `:userId` is `uf_users.id`. Empty body.
  - 200: `{ "channelId": 4231, "isNew": true }`
  - 400 if caller targets themselves, 403 if caller + target share no accessible store,
    404 if user not in `uf_users`.

**Shift payload changes:**

New field `hasUnpublishedChange: boolean` on every shift across 9 endpoints:
schedule/upcoming/today/weekly/shift detail (employee), manager/dashboard, manager/whos-working,
manager/shifts/create, manager/shifts/:id/update. Also present on every `shift` payload inside
Ably `shift.*` events.

Formula: `(publishedAt IS NOT NULL) AND (updated_at > publishedAt)`. Note there is no
`is_published` column — `isPublished` is derived from `publishedAt`. After unpublish the flag
resets to false on all affected shifts (because `publishedAt` is cleared).

Companion timestamps in JSON: `publishedAt`, `updatedAt` (camelCase; DB column is `updated_at`).

**Ably real-time events:**

Channels:
- `kiosk_{typeNum}` — team channel (employees + managers); receives published-state events only.
- `kiosk_{typeNum}_manager` — manager-only; receives ALL mutations including drafts.
  Server-side JWT gating at Ably-token issuance enforces the manager-only subscribe.

Events (5): `shift.created`, `shift.updated`, `shift.deleted`, `schedule.published`,
`schedule.unpublished`.

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

Routing rules:
- `shift.created`, `shift.updated`: manager channel always; team channel iff `shift.isPublished()` at dispatch.
- `shift.deleted`: manager channel always; team channel iff `wasPublished` at delete time.
- `schedule.published`, `schedule.unpublished`: BOTH channels always.

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

#### Mobile Action Required (Live app)
- [ ] Subscribe to BOTH `kiosk_{typeNum}` AND `kiosk_{typeNum}_manager`. Managers will receive
      most events twice — dedup by `eventId` covers it.
- [ ] Implement the unpublish flow in the schedule management screen. Surface
      `shiftsAffected` / `employeesAffected` in a confirmation toast. Map
      `400 NOT_PUBLISHED` to "Week is already in draft."
- [ ] Apply LWW reconciliation for `shift.updated` / `shift.deleted` events: drop the event
      when `event.serverTimestamp <= local.updatedAt`. This matters for the case where the
      user is mid-edit on the same shift another manager just touched — without LWW the
      remote-stale event silently overwrites the in-progress local edit.
- [ ] On `schedule.published` / `schedule.unpublished`, refetch the week from REST.
- [ ] Consume `hasUnpublishedChange` for the EDITED badge on shift tiles — do not recompute.
- [ ] Apply the deep-map conversion (`Map<Object?, Object?>` → `Map<String, dynamic>`) before
      parsing Ably payloads. See the `ably-flutter-map-type-deep-conversion` skill in
      `../buyerkiosk-web`.
- [ ] Tap-to-chat: `POST /api/mobile/scheduling/:typeNum/chat/users/:userId/channel` where
      `:userId` is `uf_users.id`. Reuse the returned `channelId` to open the existing chat
      screen.

#### Feature Flag (server-side)
`ABLY_SCHEDULING_BROADCAST_ENABLED` defaults OFF. Backend will enable it once mobile clients
confirm subscription handling. REST endpoints (including unpublish + the new
`hasUnpublishedChange` field) work regardless of the flag — only the Ably broadcasts are
suppressed while OFF.

---

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

**Type**: `breaking-change`
**Affects**: Clock / Break punch endpoints
**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.

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

#### Details
Affects any endpoint that accepts a `breakType` field for break punches.
The persisted punch row will have `breakType = NULL`; the engine classifies on evaluation.

#### Mobile Action Required
- [x] Stop sending `breakType` for break punches on stores where `breakPolicyEnabled` is `true`.
- [x] Omitting `breakType` is safe on all stores — harmless when flag is off.

---

### [2026-05-07] Backstock Create-Bin Response: Top-level `binId`
**Type**: `enhancement`
**Affects**: Backstock / Bin Create flow

#### Summary
The `POST /api/mobile/backstock/:typeNum/bins/create` response now includes a top-level `binId` field alongside the existing nested `bin` object. This is **additive** — the `bin` object and all its fields are unchanged. The response continues to return HTTP `201 Created` on success.

#### Details

**Before**:
```json
HTTP 201
{
  "success": true,
  "message": "Bin created successfully",
  "bin": {
    "id": 123,
    "uuid": "...",
    "name": "203",
    "mainCategory": ...,
    "location": ...,
    "age": ...,
    "ageDate": "..."
  }
}
```

**After**:
```json
HTTP 201
{
  "success": true,
  "message": "Bin created successfully",
  "binId": 123,
  "bin": { ... unchanged ... }
}
```

#### Why
Flutter's `BinOperationResultModel` already deserializes `binId` from the top level (used to navigate to the new bin's detail screen after create). The previous response only nested the ID at `bin.id`, so post-create navigation silently fell back to `pop()`.

#### Mobile Action Required
- [x] No code changes needed — the existing `BinOperationResultModel.fromJson` already reads `binId` from the top level. Once this backend change ships, post-create navigation to the newly-created bin will start working automatically.

#### Related Mobile Fix
A companion fix landed on the Flutter side accepting both `200` and `201` status codes from this endpoint (previously only `200` was accepted, causing the spurious "Failed to create bin" dialog on every successful create). See `lib/data/repositories/backstock_repository_impl.dart` `createBin()`.

---

### [2026-04-09] Daily Task Assignment Mobile API (Spec 039, Phase 10)
**Type**: `new-endpoint`
**Affects**: Workbook / Task Management, Daily Assignment View

#### Summary
New mobile endpoints for viewing and managing daily task assignments with position-based assignment support. Managers can view which positions/people are assigned to task groups for any date, and override assignments for today or future dates.

#### Details

**Endpoint 1**: `GET /api/mobile/workbook/:typeNum/daily-assignments/:date`
- **Auth**: HybridAuth (JWT) + StoreAccess + Manager role required (roleLevel 1 or 2)
- **Date format**: `YYYY-MM-DD`
- **Response**:
```json
{
  "success": true,
  "date": "2026-04-10",
  "groups": [
    {
      "groupId": 1,
      "groupName": "Opening Tasks",
      "effectiveAssignment": {
        "type": "position",
        "positionId": 5,
        "positionName": "Cashier",
        "source": "group_default"
      },
      "tasks": [
        {
          "taskId": 10,
          "taskName": "Count drawer",
          "effectiveAssignment": {
            "type": "person",
            "userId": 28,
            "userName": "Ryan",
            "source": "task_day_override"
          },
          "completed": true,
          "completedBy": 28
        }
      ],
      "completionProgress": {
        "total": 5,
        "completed": 3
      }
    }
  ]
}
```

**Endpoint 2**: `PUT /api/mobile/workbook/:typeNum/daily-assignments/:date/override`
- **Auth**: HybridAuth (JWT) + StoreAccess + Manager role required (roleLevel 1 or 2)
- **Request body** (JSON):
```json
{
  "targetType": "task",
  "targetId": 10,
  "assignmentType": "position",
  "positionId": 5
}
```
Or to assign a specific person:
```json
{
  "targetType": "task",
  "targetId": 10,
  "assignmentType": "person",
  "userId": 28
}
```
Or to clear an override:
```json
{
  "targetType": "task",
  "targetId": 10,
  "clear": true
}
```
- **Validation**:
  - `targetType` must be `"task"` or `"group"`
  - `assignmentType` must be `"position"` or `"person"` (required unless `clear: true`)
  - Cannot set overrides for past dates (returns 400)
  - Requires BuyerKiosk native scheduling (returns 400 if store uses WhenIWork etc.)
- **Response**:
```json
{
  "success": true,
  "override": {
    "targetType": "task",
    "targetId": 10,
    "date": "2026-04-10",
    "assignmentType": "position",
    "positionId": 5,
    "userId": null
  }
}
```

**Error responses**:
- `403` - Not a manager
- `400` - Invalid date, past date, missing fields, non-BuyerKiosk scheduling, invalid position/user
- `404` - Store not found
- `500` - Internal server error

#### Mobile Action Required
- [ ] Add daily assignment view screen (manager-only)
- [ ] Implement date navigation (today default, past=read-only, future=editable)
- [ ] Show task groups with assignment badges (position name or person name)
- [ ] Allow tap-to-reassign on each group/task for today and future dates
- [ ] Gate feature behind `hasScheduling: true` and manager role

---

### [2026-02-19] Add `hasScheduling` Flag to Login Response
**Type**: `enhancement`
**Affects**: Scheduling Module (Spec 008), Store selection, Tab visibility

#### Summary
Added `hasScheduling` boolean field to each store object in the login response. This replaces the need to guess scheduling access based on user role.

#### Details
**Endpoint**: `POST /api/mobile/auth/login`

The `hasScheduling` field is `true` when the store uses BuyerKiosk native scheduling, `false` otherwise (including stores using WhenIWork, Homebase, or no scheduling).

```json
{
  "stores": [
    {
      "typeNum": "bk01",
      "storeName": "Store Name",
      "storeType": "pc",
      "hasScheduling": true,
      "storeCity": "Portland",
      "employee": {
        "id": 123,
        "fullName": "John Doe",
        "role": 2
      }
    }
  ]
}
```

Also added to the scheduling-specific auth endpoint: `POST /api/mobile/scheduling/auth/login`

#### Mobile Action Required
- [ ] Parse `hasScheduling` from store object (default `true` for backward compat is safe)
- [ ] Use `hasScheduling` instead of role-based guess to show/hide Schedule tab
- [ ] Remove role-based fallback after backend deploys to production

### [2026-02-09] 🆕 Backstock Action 6: "Added Items" — Add Categories to Bins
**Type**: `new-endpoint`
**Affects**: Backstock Management (BinActionSheet, bin detail, action history)

#### Summary
New action type 6 ("Added Items") allows team members to add items/categories to a bin from the mobile app. Supports main category assignment, subcategory merging, replace-all mode, and age reset control. The `getBackstockActionTypes` endpoint now includes a `requiresCategory` field on all action types.

#### Updated Endpoint: Get Action Types
**POST** `/api/mobile/backstock/:typeNum/action-types`
```json
// Response — NEW: requiresCategory field on all types, new action 6
{
  "success": true,
  "actionTypes": [
    { "id": 0, "name": "Removed Everything", "description": "All items removed from bin", "requiresCategory": false },
    { "id": 1, "name": "Removed Some Items", "description": "Some items removed from bin", "requiresCategory": true },
    { "id": 2, "name": "Restock from Floor", "description": "Items returned from sales floor", "requiresCategory": true },
    { "id": 3, "name": "Moved Bin", "description": "Bin moved to different location", "requiresCategory": false },
    { "id": 4, "name": "Created Bin", "description": "New bin created", "requiresCategory": false },
    { "id": 5, "name": "Pulled for Replenishment", "description": "Bin pulled for floor replenishment", "requiresCategory": false },
    { "id": 6, "name": "Added Items", "description": "Items added to bin", "requiresCategory": true }
  ]
}
```

#### Performing Action 6: "Added Items"
**POST** `/api/mobile/backstock/:typeNum/bins/:binId/action`
```json
// Request
{
  "actionId": 6,
  "mainCategoryId": "7043",          // Optional: main category to assign
  "subCategoryIds": ["101", "102"],   // Optional: subcategories to merge (additive)
  "replaceAll": false,                // Optional: clear all categories first (default false)
  "resetAgeDate": true                // Optional: reset bin age to today (default true)
}

// Success Response
{
  "success": true,
  "message": "Items added to bin successfully",
  "actionIds": [12345],
  "bin": {
    "id": 123,
    "mainCategory": "7043",
    "isEmpty": false
  }
}

// Replace-all + categories: two actions recorded
{
  "success": true,
  "message": "Items added to bin successfully",
  "actionIds": [12344, 12345],   // action 0 (remove) + action 6 (add)
  "bin": {
    "id": 123,
    "mainCategory": "7043",
    "isEmpty": false
  }
}

// Error: Main category conflict on non-empty bin
{
  "success": false,
  "error": "Main category conflict: bin already has a different main category",
  "currentMainCategory": "8012",
  "requestedMainCategory": "7043"
}

// Error: No categories provided without replaceAll
{
  "success": false,
  "error": "Must provide mainCategoryId or subCategoryIds when not replacing all"
}
```

#### Business Logic
| Scenario | Behavior |
|----------|----------|
| Empty bin + mainCategoryId | Sets main category, resets age |
| Empty bin + only subCategoryIds | First sub auto-promoted to main |
| Non-empty bin + conflicting main | Returns 400 with conflict info |
| Non-empty bin + matching main + new subs | Additive merge (no duplicates) |
| replaceAll + categories | Records action 0, clears bin, then adds |
| replaceAll + no categories | Records action 0 only (empties bin) |
| No categories + no replaceAll | Returns 400 validation error |

#### Updated Action Type Reference
| ID | Name | Description | requiresCategory | Notes |
|----|------|-------------|-------------------|-------|
| 0 | Removed Everything | All items removed from bin | false | Clears categories |
| 1 | Removed Some Items | Some items removed | true | Requires categoryId |
| 2 | Restock from Floor | Items returned from sales floor | true | |
| 3 | Moved Bin | Bin moved to different location | false | Requires toLocationId |
| 4 | Created Bin | New bin created | false | Auto-recorded on create |
| 5 | Pulled for Replenishment | Bin pulled for floor replenishment | false | |
| 6 | Added Items | Items added to bin | true | **NEW** — Supports mainCategoryId, subCategoryIds, replaceAll, resetAgeDate |

#### Mobile Action Required
- [ ] Update `BackstockActionType` model to include `requiresCategory` (bool) field
- [ ] Add action type 6 to BinActionSheet UI
- [ ] Implement category picker flow for action 6 (main + subcategories)
- [ ] Handle `replaceAll` toggle in action 6 UI
- [ ] Handle 400 error for main category conflicts (show current vs requested)
- [ ] Handle `actionIds` array in response (may contain 1 or 2 action IDs)
- [ ] Display "Added Items" in action history timeline

#### Files Changed (Backend)
- `userfrosting/src/BuyerKiosk/Backstock/Bin.php` — Added `addCustomTag()`, `mergeCategoryTags()`, `clearAllCategories()`, `isEmpty()`
- `userfrosting/src/BuyerKiosk/Backstock/Action.php` — Case 6 in `getReadableString()`
- `userfrosting/src/BuyerKiosk/Backstock/BackstockFactory.php` — Case 6 in `makeBinReadable()`
- `userfrosting/src/BuyerKiosk/MobileApi/Controllers/MobileApiController.php` — `handleAddItemsAction()`, updated action types
- `userfrosting/routes/groups/backstock.php` — Web route handler for action 6

---

### [2026-02-07] 🆕 Backstock Bin CRUD + Actions API Complete
**Type**: `new-endpoint`
**Affects**: Backstock Management (full bin lifecycle management)

#### Summary
Added comprehensive bin CRUD + Action endpoints for mobile app. You can now create, update, delete, hide, activate bins, and perform all bin actions (remove items, restock, move, etc.) from the mobile app - matching the website's full functionality.

#### New Endpoints

**1. Create Bin**
**POST** `/api/mobile/backstock/:typeNum/bins/create`
```json
// Request
{
  "name": "1500",
  "mainCategory": "2063",       // Category ID from kiosk_sales.subcategories
  "location": 3,                 // Location ID
  "dateCreated": "2026-02-07",   // Optional, defaults to today
  "subCategories": ["107", "108"] // Optional sub-category IDs
}

// Response
{
  "success": true,
  "message": "Bin created successfully",
  "bin": {
    "id": 18200,
    "uuid": "ABC12345",
    "name": "1500",
    "mainCategory": "2063",
    "location": 3,
    "age": 0,
    "ageDate": "2026-02-07 12:00:00"
  }
}
```

**2. Update Bin (Comprehensive Save)**
**POST** `/api/mobile/backstock/:typeNum/bins/:binId/update`
```json
// Request - all fields optional
{
  "name": "1500-Updated",
  "mainCategory": "2064",
  "location": 5,
  "dateCreated": "2026-02-01",
  "subCategories": ["107"],
  "notes": "Seasonal items",
  "itemCount": 50,
  "estimatedValue": 250.00,
  "resetAgeDate": true,
  "actions": [                    // Optional array of actions to perform
    {
      "actionId": 1,              // 1 = Removed Some Items
      "categoryId": "2063"        // Which category items were removed from
    }
  ]
}

// Response
{
  "success": true,
  "message": "Bin updated successfully",
  "bin": { /* Full bin object */ }
}
```

**3. Perform Action on Bin**
**POST** `/api/mobile/backstock/:typeNum/bins/:binId/action`
```json
// Request
{
  "actionId": 3,           // Required: 0-5 (see action types below)
  "categoryId": "2063",    // Optional: category affected by action
  "toLocationId": 5,       // Required for actionId=3 (Move)
  "resetAgeDate": true     // Optional: reset bin age to today
}

// Response
{
  "success": true,
  "message": "Action recorded successfully",
  "action": {
    "id": 12345,
    "type": 3,
    "typeName": "Moved Bin",
    "binId": 18181,
    "categoryId": "2063",
    "performedBy": 500
  }
}
```

**4. Delete Bin (Soft Delete)**
**POST** `/api/mobile/backstock/:typeNum/bins/:binId/delete`
```json
// Response
{
  "success": true,
  "message": "Bin deleted successfully",
  "binId": 18181
}
```

**5. Hide Bin (Make Inactive)**
**POST** `/api/mobile/backstock/:typeNum/bins/:binId/hide`
```json
// Response
{
  "success": true,
  "message": "Bin hidden successfully",
  "binId": 18181
}
```

**6. Activate Bin (Unhide)**
**POST** `/api/mobile/backstock/:typeNum/bins/:binId/activate`
```json
// Response
{
  "success": true,
  "message": "Bin activated successfully",
  "bin": { /* Full bin object */ }
}
```

**7. Generate Descriptive Name**
**POST** `/api/mobile/backstock/:typeNum/bins/:binId/generate-name`
```json
// Response
{
  "success": true,
  "generatedName": "Summer Boots #1500",
  "binId": 18181
}
```

**8. Lookup Bin by Name (Barcode Scanning)**
**POST** `/api/mobile/backstock/:typeNum/bins/lookup`
```json
// Request
{ "name": "1500" }

// Response
{
  "success": true,
  "found": true,
  "bin": {
    "id": 18181,
    "uuid": "ABC12345",
    "name": "1500",
    "mainCategory": "2063",
    "location": 3,
    "age": 30,
    "ageDate": "2026-01-07",
    "isHidden": false,
    "hasBeenUsed": true
  }
}
```

**9. Get Hidden Bins**
**POST** `/api/mobile/backstock/:typeNum/bins/hidden`
```json
// Response
{
  "success": true,
  "bins": [ /* Array of hidden bin objects */ ],
  "count": 5
}
```

**10. Get Action Types**
**POST** `/api/mobile/backstock/:typeNum/action-types`
```json
// Response
{
  "success": true,
  "actionTypes": [
    { "id": 0, "name": "Removed Everything", "description": "All items removed from bin" },
    { "id": 1, "name": "Removed Some Items", "description": "Some items removed from bin" },
    { "id": 2, "name": "Restock from Floor", "description": "Items returned from sales floor" },
    { "id": 3, "name": "Moved Bin", "description": "Bin moved to different location" },
    { "id": 4, "name": "Created Bin", "description": "New bin created" },
    { "id": 5, "name": "Pulled for Replenishment", "description": "Bin pulled for floor replenishment" }
  ]
}
```

#### Action Type Reference
| ID | Name | Description | Notes |
|----|------|-------------|-------|
| 0 | Removed Everything | All items removed from bin | Clears categories |
| 1 | Removed Some Items | Some items removed | Requires categoryId |
| 2 | Restock from Floor | Items returned from sales floor | |
| 3 | Moved Bin | Bin moved to different location | Requires toLocationId |
| 4 | Created Bin | New bin created | Auto-recorded on create |
| 5 | Pulled for Replenishment | Bin pulled for floor replenishment | |

#### Authentication
All endpoints require: Hybrid auth (JWT or API key) + store access

#### Mobile Action Required
- [ ] Create `BinCreateRequest` and `BinUpdateRequest` models
- [ ] Create `BinActionRequest` model with actionId, categoryId, toLocationId
- [ ] Add bin CRUD service methods
- [ ] Implement "Create Bin" flow (name entry, category selection, location)
- [ ] Implement "Edit Bin" modal with action selection
- [ ] Implement quick-action buttons (Hide, Activate, Delete)
- [ ] Implement barcode scanning with lookup endpoint
- [ ] Add "Hidden Bins" view with reactivation option
- [ ] Show action types in picker when performing actions

#### Files Changed (Backend)
- `userfrosting/src/BuyerKiosk/MobileApi/Controllers/MobileApiController.php` - Added 10 new methods
- `userfrosting/routes/groups/mobile.php` - Added 10 new routes

---

### [2026-02-07] 🔧 Bin Details Endpoint - Enhanced API Format (LIVE)
**Type**: `enhancement`
**Affects**: Backstock Management (bin detail view)

#### Summary
Bin details API now returns enhanced format with nested objects for location, category, stats, and action history. This replaces the flat field structure.

#### Endpoint
**POST** `/api/mobile/backstock/:typeNum/bins/:binId/details`

**Authentication**: Hybrid auth (JWT or API key) + store access

#### Response (ENHANCED FORMAT)
```json
{
  "success": true,
  "bin": {
    "id": 18181,
    "uuid": "0C1C66E7",
    "name": "Bin #18181",
    "generatedName": null,
    "notes": null,
    "itemCount": 39,
    "estimatedValue": 339.81,
    "age": 0,
    "ageDate": "2026-02-07 17:11:16",
    "active": true,
    "location": {
      "id": 3,
      "name": "Storage Unit B",
      "onsite": false
    },
    "mainCategory": {
      "id": "2063",
      "name": "Toddler / Outerwear - Fall/Winter",
      "color": "6366f1",
      "isPOS": true
    },
    "subCategories": [],
    "categoryStats": {
      "otherBinsInCategory": 11,
      "categoryAvgAge": 38.5
    },
    "actionHistory": [
      {
        "id": 702,
        "action": "Removed Some Items",
        "actionId": 1,
        "performedAt": "2026-02-06 20:27:00",
        "performedBy": "Pearl Harbor"
      }
    ]
  }
}
```

#### Nested Objects
| Object | Fields | Description |
|--------|--------|-------------|
| `location` | `id`, `name`, `onsite` | Full location details |
| `mainCategory` | `id`, `name`, `color`, `isPOS` | Resolved category with color |
| `subCategories` | Array of category objects | Sub-categories assigned |
| `categoryStats` | `otherBinsInCategory`, `categoryAvgAge` | Category statistics |
| `actionHistory` | Array of action objects | Recent actions on this bin |

#### Action History Object
| Field | Type | Description |
|-------|------|-------------|
| `id` | int | Action record ID |
| `action` | string | Human-readable action name |
| `actionId` | int | Action type ID (see action types) |
| `performedAt` | string | Timestamp of action |
| `performedBy` | string | Employee name who performed |
| `fromLocation` | string? | For move actions - source location |
| `toLocation` | string? | For move actions - destination location |

#### Mobile Models Created
```dart
// Nested location object
@freezed class BinLocationModel { id, name, onsite }

// Nested category object
@freezed class BinCategoryModel { id, name, color, isPOS }

// Category statistics
@freezed class BinCategoryStatsModel { otherBinsInCategory, categoryAvgAge }

// Action history entry
@freezed class BinActionModel { id, action, actionId, performedAt, performedBy, fromLocation?, toLocation? }

// Main bin detail model
@freezed class BinDetailModel {
  // ... basic fields ...
  BinLocationModel? location,
  BinCategoryModel? mainCategory,
  List<BinCategoryModel> subCategories,
  BinCategoryStatsModel? categoryStats,
  List<BinActionModel> actionHistory,
}
```

#### Mobile Action Required
- [x] **COMPLETE** - `BinDetailModel` updated with nested object models
- [x] **COMPLETE** - `BackstockMapper.binDetailToEntity()` extracts from nested objects
- [x] **COMPLETE** - `BinDetailScreen` displays all available data
- [x] **COMPLETE** - Safe color parsing for category color
- [ ] TODO: Display action history timeline on bin detail screen
- [ ] TODO: Show "11 other bins in this category" badge using categoryStats

---

### [2026-02-07] 🆕 Backstock Bin Search Endpoint Added
**Type**: `new-endpoint`
**Affects**: Backstock Management (bin browsing, search, filtering)

#### Summary
Added a comprehensive bin search endpoint that searches ALL bins (not just stale ones) with full text search and filter capabilities.

#### Endpoint
**POST** `/api/mobile/backstock/:typeNum/bins/search`

**Authentication**: Hybrid auth (JWT or API key) + store access

#### Request Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | No | Text search (searches name, UUID, category) |
| `categoryId` | string | No | Filter by category ID |
| `locationId` | int | No | Filter by location ID |
| `onsite` | bool | No | Filter by onsite/offsite status |
| `minAge` | int | No | Minimum age in days |
| `maxAge` | int | No | Maximum age in days |
| `limit` | int | No | Max results (default 50, max 200) |
| `offset` | int | No | Pagination offset (default 0) |
| `sortBy` | string | No | Sort field: `name`, `age`, `category`, `location` (default: `age`) |
| `sortDir` | string | No | Sort direction: `asc`, `desc` (default: `desc`) |

#### Response
```json
{
  "success": true,
  "bins": [
    {
      "id": 15028,
      "uuid": "Osf8LCQ0",
      "name": "1439",
      "mainCategory": {
        "id": "107",
        "name": "Boots",
        "color": "1dba6e"
      },
      "location": {
        "id": 20,
        "name": "Back Room",
        "onsite": true
      },
      "age": 360,
      "ageDate": "2025-02-12",
      "active": true,
      "notes": null
    }
  ],
  "pagination": {
    "total": 245,
    "limit": 50,
    "offset": 0,
    "hasMore": true
  },
  "filters": {
    "query": "boots",
    "categoryId": null,
    "locationId": null,
    "onsite": null,
    "minAge": null,
    "maxAge": null
  }
}
```

#### Use Cases
- **Browse all bins**: Call with no filters to list all bins
- **Search by name**: `{ "q": "1439" }` - finds bins by name/UUID
- **Find stale bins**: `{ "minAge": 90 }` - bins older than 90 days
- **Onsite only**: `{ "onsite": true }` - bins in back room
- **Category filter**: `{ "categoryId": "107" }` - all Boots bins
- **Combined**: `{ "q": "summer", "onsite": false, "maxAge": 180 }` - offsite summer bins under 180 days old

#### Mobile Action Required
- [ ] Create `BinSearchParams` model for request parameters
- [ ] Update `Bin` model to include `location` object (id, name, onsite)
- [ ] Implement bin search/browse screen
- [ ] Add filter UI for category, location, age range
- [ ] Implement pagination with infinite scroll or "Load More"

#### Files Changed (Backend)
- `userfrosting/src/BuyerKiosk/MobileApi/Controllers/MobileApiController.php` - Added `searchBackstockBins()` method
- `userfrosting/routes/groups/mobile.php` - Added route

---

### [2026-02-07] 🔧 Backstock Categories API Enhanced
**Type**: `enhancement` + `bugfix`
**Affects**: Backstock Management (event form, bin categorization, reports)

#### Summary
**FIXED** categories returning wrong data (Plato's Closet categories instead of Once Upon a Child). Now fetches from global `kiosk_sales.subcategories` table filtered by store concept. Also added `binCount` field to each category.

#### Root Cause
The local `drsSubCategories` table had incorrect franchise data. Switched to global `kiosk_sales.subcategories` table which has correct category data per concept (franchise type).

#### Endpoint
**POST** `/api/mobile/backstock/:typeNum/categories`

**Authentication**: Hybrid auth (JWT or API key) + store access

#### Response (Updated)
```json
{
  "success": true,
  "categories": [
    {
      "id": "3530",
      "name": "Toddler / Swimwear",
      "color": "6366f1",
      "isPOS": true,
      "binCount": 5
    },
    {
      "id": "1013",
      "name": "Newborn / Tops - Fall/Winter",
      "color": "6366f1",
      "isPOS": true,
      "binCount": 0
    },
    {
      "id": "107",
      "name": "Boots",
      "color": "1dba6e",
      "isPOS": false,
      "binCount": 19
    }
  ]
}
```

#### New/Changed Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Subcategory code (POS) or custom category ID |
| `name` | string | Category name (now shows correct franchise-specific names!) |
| `color` | string | Hex color code (no # prefix) |
| `isPOS` | bool | **NEW** - `true` for POS subcategories, `false` for custom categories |
| `binCount` | int | **NEW** - Number of bins assigned to this category |

#### Breaking Changes
- **NONE** - Existing fields preserved, new fields added

#### Mobile Action Required
- [ ] Update `BackstockCategory` model to include `isPOS` (bool) and `binCount` (int) fields
- [ ] Categories now show correct names for the store's franchise type
- [ ] Consider showing bin count badges on category selection UI
- [ ] Test with both OU (Once Upon a Child) and PC (Plato's Closet) stores

#### Files Changed (Backend)
- `userfrosting/src/BuyerKiosk/Backstock/BackstockFactory.php:59-180` - Now queries `kiosk_sales.subcategories` with bin count joins
- `userfrosting/src/BuyerKiosk/MobileApi/Controllers/MobileApiController.php:3595-3620` - Uses new `getCategoriesWithBinCounts()` method

---

### [2026-02-06] Backstock Locations Endpoint Added
**Type**: `new-endpoint`
**Affects**: Backstock Management (event form, bin search)

#### Summary
Added the missing `/api/mobile/backstock/:typeNum/locations` endpoint that was returning 404. This endpoint returns all backstock locations for filtering bins and creating events.

#### Endpoint
**POST** `/api/mobile/backstock/:typeNum/locations`

**Authentication**: Hybrid auth (JWT or API key) + store access

#### Response
```json
{
  "success": true,
  "locations": [
    {
      "id": 1,
      "name": "Back Room",
      "onSite": true
    },
    {
      "id": 2,
      "name": "Storage",
      "onSite": false
    }
  ]
}
```

#### Mobile Action Required
- [x] **NO ACTION NEEDED** - Your existing code should now work
- The model matches exactly: `id` (int), `name` (String), `onSite` (bool)
- You can remove any 404 fallback workarounds if desired

#### Files Changed (Backend)
- `userfrosting/src/BuyerKiosk/MobileApi/Controllers/MobileApiController.php:3621-3662` - Added `getBackstockLocations()` method
- `userfrosting/routes/groups/mobile.php:1663-1681` - Added route

---

### [2026-01-26] 🐛 CRITICAL BUG FIX: Close Reports Monetary Values
**Type**: `bugfix`
**Affects**: Close Reports API - ALL monetary values were 100x too large

#### Summary
**FIXED** a double-conversion bug causing all monetary values (sales, buys, cash, etc.) to be returned as 100x their correct value. Values were being converted from dollars to cents twice (once on write, once on read).

#### Root Cause
The Value Objects (SalesSummaryVO, BuysDataVO, etc.) only had `fromArray()` which converts dollars→cents (×100). When reading from the database (where values are already in cents), the same method was incorrectly applied, resulting in cents→cents×100.

#### The Fix
Added `fromStoredData()` methods to all Value Objects that accept already-converted cent values without multiplying. Updated `CloseReport::fromDatabaseRow()` to use `fromStoredData()` instead of `fromArray()`.

#### Before (Bug)
```dart
// API returned: netSalesRetail = 25206100
// Mobile calculated: $252,061.00 ❌ (100x too large)
final sales = response['netSalesRetail'] / 100;  // $252,061.00 wrong!
```

#### After (Fixed)
```dart
// API now returns: netSalesRetail = 252061
// Mobile calculates: $2,520.61 ✓ correct!
final sales = response['netSalesRetail'] / 100;  // $2,520.61 correct!
```

#### Mobile Action Required
- [x] **NO ACTION NEEDED** - Backend fix only
- The `/100` conversion logic in your code is correct
- Refresh API data to get corrected values

#### Files Changed (Backend)
- `src/BuyerKiosk/CloseReport/ValueObjects/SalesSummaryVO.php` - Added `fromStoredData()`
- `src/BuyerKiosk/CloseReport/ValueObjects/BuysDataVO.php` - Added `fromStoredData()`
- `src/BuyerKiosk/CloseReport/ValueObjects/ReturnsDataVO.php` - Added `fromStoredData()`
- `src/BuyerKiosk/CloseReport/ValueObjects/CashDataVO.php` - Added `fromStoredData()`
- `src/BuyerKiosk/CloseReport/ValueObjects/LaborDataVO.php` - Added `fromStoredData()`
- `src/BuyerKiosk/CloseReport/Models/CloseReport.php` - Use `fromStoredData()` in `fromDatabaseRow()`

---

### [2026-01-26] Close Reports - FULL Email Parity Now Available
**Type**: `enhancement`
**Affects**: Close Reports API - All detail endpoints

#### Summary
Close reports now return **COMPLETE DRS payload data** with full 1:1 email parity! Added 5 new sections to the API response.

#### New Sections Added

**1. `tenders`** - Payment Method Breakdown
```json
"tenders": {
  "tenders": {
    "cash": { "count": 31, "expected": 130764, "actual": 130764, "variance": 0 },
    "visa": { "count": 31, "expected": 91214, "actual": 91214, "variance": 0 },
    "mastercard": { "count": 18, "expected": 62390, "actual": 62390, "variance": 0 },
    "amex": { "count": 2, "expected": 1475, "actual": 1475, "variance": 0 },
    "discover": { "count": 4, "expected": 10337, "actual": 10337, "variance": 0 },
    "giftcard": { "count": 1, "expected": 1585, "actual": 1585, "variance": 0 },
    "storeCredit": { "count": 4, "expected": 12473, "actual": 12473, "variance": 0 }
  },
  "totalExpected": 310238,
  "totalActual": 310238,
  "totalVariance": 0,
  "hasVariance": false
}
```

**2. `cashDrawer`** - Cash Drawer Details
```json
"cashDrawer": {
  "openingCash": 140000,
  "cashTendered": 30985,
  "checksIssued": 6929,
  "storeCreditsIssued": 19470,
  "giftCardsIssued": 0,
  "cashPaidIn": 0,
  "cashPaidOut": 0,
  "freightCollected": 0,
  "cashOverShort": 0,
  "nextDayOpen": 140000,
  "checkDeposit": 0,
  "newARCharges": 0,
  "depositsReceived": 0,
  "depositsUsed": 0
}
```

**3. `buyPayments`** - How Buys Were Paid
```json
"buyPayments": {
  "buyPaidCash": 40221,
  "buyPaidCheck": 59539,
  "buyPaidStoreCredit": 4722,
  "buyPaidOffsite": 0,
  "buyPaidTradeIns": 32867,
  "buyPaidTotal": 137349,
  "tradeCount": 7,
  "tradeAmount": 32867,
  "storeCreditsOutstandingCount": 602,
  "storeCreditsOutstandingAmount": 1162906
}
```

**4. `newUsedBreakdown`** - New vs Used Sales
```json
"newUsedBreakdown": {
  "newSales": 2597,
  "newReturns": 0,
  "newNetSales": 2597,
  "newCost": 1080,
  "usedSales": 219742,
  "usedReturns": -22100,
  "usedNetSales": 197642,
  "usedCost": 66836,
  "newWTD": 2597,
  "usedWTD": 197642,
  "newMTD": 43963,
  "usedMTD": 3694063,
  "newYTD": 617671,
  "usedYTD": 50065777,
  "continuingFee": 10012,
  "continuingFeePercentage": 5.0,
  "netSalesContinuingForFee": 200239,
  "taxCollected": 16688,
  "usedSalesPercentage": 98.7
}
```

**5. `yearOverYear`** - Last Year vs Current Year Comparisons
```json
"yearOverYear": {
  "lastYear": {
    "day": { "grossSalesCost": 92905, "grossSalesRetail": 257688, "netSalesCost": 89720, "netSalesRetail": 252738 },
    "wtd": { "grossSalesCost": 92905, "grossSalesRetail": 257688, "netSalesCost": 89720, "netSalesRetail": 252738 },
    "mtd": { "grossSalesCost": 1455132, "grossSalesRetail": 4083040, "netSalesCost": 1440440, "netSalesRetail": 4043942 }
  },
  "currentYear": {
    "wtd": { "grossSalesCost": 77356, "grossSalesRetail": 222339, "netSalesCost": 67916, "netSalesRetail": 200239 },
    "mtd": { "grossSalesCost": 1359782, "grossSalesRetail": 3806929, "netSalesCost": 1332751, "netSalesRetail": 3738026 }
  },
  "wtdChangePercent": -20.8,
  "mtdChangePercent": -7.6
}
```

#### Mobile Action Required
- [ ] Add `TendersData` model with nested tender entries
- [ ] Add `CashDrawerData` model
- [ ] Add `BuyPaymentsData` model
- [ ] Add `NewUsedBreakdownData` model with WTD/MTD/YTD fields
- [ ] Add `YearOverYearData` model with nested lastYear/currentYear
- [ ] Display tender breakdown (Visa/MC/Cash/etc.) on report detail
- [ ] Display new vs used sales percentages
- [ ] Display YoY comparison metrics with change indicators
- [ ] All new sections are OPTIONAL - check for null before accessing

---

### [2026-01-26] Close Reports - Full Payload Migration (Original)

#### New Fields Available in Report Detail

The `/detail` and `/latest` endpoints now return these additional sections:

```json
{
  "success": true,
  "report": {
    "id": 243,
    "metadata": {
      "reportDate": "2026-01-07",
      "postedAt": "2026-01-26T23:39:48+00:00",
      "storeNumber": "",
      "storeName": "",
      "typeNum": "ou00"
    },
    "salesSummary": {
      "grossSalesRetail": 25326100,
      "grossSalesCost": 8781100,
      "grossSalesGM": 653000,
      "netSalesRetail": 25206100,
      "netSalesCost": 8745100,
      "netSalesGM": 653000,
      "salesCount": 69,
      "averageRetail": 365300,
      "salesGoal": 0,
      "salesVsGoalPercent": 0
    },
    "buys": {
      "buysCost": 13090800,
      "buysRetail": 39140000,
      "buysGM": 666000,
      "buysCount": 31,
      "buysGoal": 0,
      "buysOutstanding": 0,
      "buysVsGoalPercent": 0
    },
    "returns": {
      "returnsCost": -36000,
      "returnsRetail": -120000,
      "returnsNumber": 1,
      "returnsGM": 700000
    },
    "goals": {
      "salesGoal": 0,
      "buysGoal": 0,
      "salesVsGoalPercent": 0,
      "buysVsGoalPercent": 0
    },
    "cash": {
      "cashPaidIn": 0,
      "cashPaidOut": 0,
      "cashVariance": -140000,
      "safeBalance": 0,
      "alerts": [],
      "hasDiscrepancy": true
    },
    "checklists": {
      "openingCheckListSubmitter": "",
      "openingCheckListComments": "",
      "closingCheckListSubmitter": "",
      "closingCheckListComments": "",
      "varianceExplanation": "$15.00 given to student Alyssa from register 1",
      "dayCloseComments": "",
      "dayCloseSubmitter": "A Weaver",
      "tasks": [],
      "hasOpeningChecklist": false,
      "hasClosingChecklist": false,
      "completedTaskCount": 0,
      "totalTaskCount": 0
    },
    "dailyResults": {
      "openingCheckListSubmitter": "",
      "closingCheckListSubmitter": "",
      "varianceExplanation": "$15.00 given to student Alyssa from register 1",
      "dayCloseComments": "",
      "dayCloseSubmitter": "A Weaver"
    },
    "labor": null,
    "comparisons": null,
    "isLegacyMigrated": true
  }
}
```

#### Key Data Notes
- **All monetary values in CENTS** (divide by 100 for display)
- **Optional sections** (`labor`, `cash`, `checklists`, `dailyResults`, `comparisons`) only included when data exists
- **`isLegacyMigrated: true`** indicates historical data (not submitted today)
- **GM fields** are in basis points (653000 = 65.3%)

#### Mobile Action Required
- [ ] Update `CloseReport` model to include new optional fields
- [ ] Display `checklists.varianceExplanation` for cash variance context
- [ ] Display `checklists.dayCloseSubmitter` for who closed the store
- [ ] Handle null optional sections gracefully
- [ ] Test with ou00 and pc00 stores (have full data)

---

### [2026-01-26] Store Stats Endpoint Added
**Type**: `new-endpoint`
**Affects**: Dashboard / Home Screen

#### Summary
New endpoint for comprehensive store statistics (dashboard data).

#### Endpoint
**POST** `/api/mobile/storeStats/:typeNum`

#### Response
```json
{
  "success": true,
  "financials": {
    "netSalesRetail": 25206100,
    "salesGoal": 30000000,
    "salesVsGoalPercent": 84.02,
    "buysCount": 31,
    "buysCost": 13090800,
    "buysGoal": 15000000
  },
  "todayQueue": {
    "buysCompleted": 31,
    "containersProcessed": 156,
    "newCustomers": 8,
    "avgWaitTime": 12.5
  },
  "vsAverage": {
    "buys": 5.2,
    "containers": -2.1,
    "newCustomers": 15.0
  },
  "weekOverWeek": {
    "salesChange": 8.5,
    "buysChange": 3.2
  },
  "labor": {
    "enabled": true,
    "clockedInCount": 4,
    "totalHours": 28.5,
    "totalWages": 42750,
    "laborPercent": "16.5%",
    "salesPerLaborHour": 885.12,
    "employees": [...]
  }
}
```

#### Mobile Action Required
- [ ] Implement dashboard home screen using this endpoint
- [ ] Handle `labor.enabled: false` for stores without WhenIWork

---

### [2026-01-03] Staff Chat API Breaking Change - Field Renames
**Type**: `breaking-change`
**Affects**: Staff Chat feature (Spec 024) - All endpoints

#### Summary
Staff Chat API response payloads have changed field names as part of migration from store-level `employees` table to global `kiosk_users.users` table. All `employeeId` fields are now `userId`.

#### Field Name Changes

| Endpoint/Model | Old Field | New Field |
|----------------|-----------|-----------|
| **Message** | `senderEmployeeId` | `senderUserId` |
| **Reaction** | `employeeId` | `userId` |
| **Channel** | `createdByEmployeeId` | `createdByUserId` |
| **ChannelMember** | `employeeId` | `userId` |
| **Mention** | `mentionedEmployeeId` | `mentionedUserId` |
| **ReadReceipt** | `employeeId` | `userId` |

#### Affected Endpoints
All Staff Chat endpoints return these models:
- `GET /api/mobile/staff-chat/:typeNum/channels` - Channel list
- `GET /api/mobile/staff-chat/:typeNum/channels/:id` - Channel detail
- `GET /api/mobile/staff-chat/:typeNum/channels/:id/messages` - Message list
- `POST /api/mobile/staff-chat/:typeNum/channels/:id/messages` - Send message
- `GET /api/mobile/staff-chat/:typeNum/channels/:id/members` - Member list
- All reaction endpoints
- All read receipt endpoints

#### Example Response Changes

**Before (Message):**
```json
{
  "id": 1,
  "channelId": 1,
  "senderEmployeeId": 500,
  "senderType": "employee",
  "content": "Hello team!",
  "reactions": [
    { "id": 1, "employeeId": 501, "emoji": "👍" }
  ]
}
```

**After (Message):**
```json
{
  "id": 1,
  "channelId": 1,
  "senderUserId": 500,
  "senderType": "employee",
  "content": "Hello team!",
  "reactions": [
    { "id": 1, "userId": 501, "emoji": "👍" }
  ]
}
```

#### Mobile Action Required
- [ ] Update `Message` model: rename `senderEmployeeId` → `senderUserId`
- [ ] Update `Reaction` model: rename `employeeId` → `userId`
- [ ] Update `Channel` model: rename `createdByEmployeeId` → `createdByUserId`
- [ ] Update `ChannelMember` model: rename `employeeId` → `userId`
- [ ] Update `Mention` model: rename `mentionedEmployeeId` → `mentionedUserId`
- [ ] Update `ReadReceipt` model: rename `employeeId` → `userId`
- [ ] Update any Dart code that references these fields
- [ ] Test all Staff Chat functionality after update

#### Why This Change?
This aligns Staff Chat with the global user system (`kiosk_users.users`) rather than store-level employees. The `userId` now references the central users table, enabling cross-store user identity.

#### Rollout
This change will be deployed with the next backend release. Coordinate mobile app update accordingly.

---

### [2026-01-03] Close Reports API Implementation Complete
**Type**: `new-endpoint`
**Affects**: Close Reports feature (PRD 026)

#### Summary
All Close Reports API endpoints are now live and ready for integration. Implementation passed Codex review and Phase 11 validation.

#### Mobile Action Required
- [ ] Implement close reports list view
- [ ] Implement close report detail view
- [ ] Implement calendar widget with report indicators
- [ ] Implement comparison view
- [ ] Handle push notification deep links: `buyerkiosklive://close-report/{typeNum}/{date}`
- [ ] Add user preferences UI for notification settings

---

**Version**: 1.1.0
**Base URL**: `https://api.buyerkiosk.com/api/mobile/close-reports`
**Authentication**: JWT Bearer Token (via `Authorization: Bearer <token>`)
**Date**: 2026-01-03

## Overview

REST API for mobile app access to daily close reports. All endpoints require:
1. Valid JWT token from `/api/mobile/scheduling/auth/login`
2. User must have store access via `userStoreAssignments`

All monetary values are in **cents** (integers) to avoid floating-point issues.

---

## Endpoints

### 1. List Reports

**POST** `/:typeNum/list`

List recent close reports with pagination.

#### Request Body
```json
{
  "limit": 30,    // Optional, max 100, default 30
  "offset": 0     // Optional, default 0
}
```

#### Response (200 OK)
```json
{
  "success": true,
  "reports": [
    {
      "id": 1,
      "reportDate": "2026-01-02",
      "postedAt": "2026-01-02T23:30:00+00:00",
      "netSalesRetail": 1234567,  // $12,345.67 in cents
      "salesVsGoalPercent": 105.5,
      "buysCount": 42,
      "buysTotal": 567890,  // $5,678.90 in cents
      "hasDiscrepancy": false,
      "laborPercent": 18.5
    }
  ],
  "pagination": {
    "total": 365,
    "limit": 30,
    "offset": 0,
    "hasMore": true
  }
}
```

---

### 2. Get Report Detail

**POST** `/:typeNum/detail`

Get full report for a specific date.

#### Request Body
```json
{
  "date": "2026-01-02"  // Required, YYYY-MM-DD format
}
```

#### Response (200 OK)
```json
{
  "success": true,
  "report": {
    "metadata": {
      "id": 1,
      "reportDate": "2026-01-02",
      "postedAt": "2026-01-02T23:30:00+00:00",
      "typeNum": "ou00"
    },
    "salesSummary": {
      "grossSalesRetail": 150000,
      "grossSalesCost": 75000,
      "grossSalesGM": 75000,
      "netSalesRetail": 140000,
      "netSalesCost": 70000,
      "netSalesGM": 70000,
      "salesCount": 25,
      "averageRetail": 5600,
      "salesGoal": 150000,
      "salesVsGoalPercent": 93.33
    },
    "buys": {
      "buysCost": 50000,
      "buysRetail": 100000,
      "buysGM": 50000,
      "buysCount": 42,
      "buysGoal": 60000,
      "buysOutstanding": 3,
      "buysVsGoalPercent": 83.33
    },
    "returns": {
      "returnsCost": 3456,
      "returnsRetail": 6912,
      "returnsNumber": 2,
      "returnsGM": 3456
    },
    "labor": {
      "laborPercentage": 18.5,
      "laborHours": 24.5,
      "laborDollars": 24500
    },
    "rawPayload": { /* Original DRS payload for debugging */ }
  }
}
```

#### Error Responses
- **400 Bad Request**: Invalid date format
  ```json
  { "success": false, "error": { "code": "INVALID_DATE", "message": "Date must be in YYYY-MM-DD format", "status": 400 } }
  ```
- **404 Not Found**: No report for date
  ```json
  { "success": false, "error": { "code": "NOT_FOUND", "message": "No close report found for date: 2026-01-02", "status": 404 } }
  ```

---

### 3. Get Latest Report

**POST** `/:typeNum/latest`

Convenience endpoint to get the most recent close report.

#### Request Body
None (empty object `{}` is fine)

#### Response
Same as Get Report Detail endpoint.

---

### 4. Calendar

**POST** `/:typeNum/calendar`

Get dates with available reports (for calendar widgets).

#### Request Body
```json
{
  "year": 2026,    // Required
  "month": 1       // Optional (1-12), omit for full year
}
```

#### Response (200 OK)
```json
{
  "success": true,
  "dates": [
    "2026-01-01",
    "2026-01-02",
    "2026-01-03"
  ],
  "year": 2026,
  "month": 1
}
```

#### Error Responses
- **400 Bad Request**: Missing year or invalid month
  ```json
  { "success": false, "error": { "code": "MISSING_YEAR", "message": "Year parameter is required", "status": 400 } }
  ```

---

### 5. Compare Reports

**POST** `/:typeNum/compare`

Compare two reports with delta calculations and variance highlights.

#### Request Body
```json
{
  "primaryDate": "2026-01-02",     // Required, newer date
  "comparisonDate": "2026-01-01"   // Required, older date
}
```

#### Response (200 OK)
```json
{
  "success": true,
  "primary": {
    "id": 2,
    "reportDate": "2026-01-02",
    "postedAt": "2026-01-02T23:30:00+00:00",
    "netSalesRetail": 1000000,
    "salesVsGoalPercent": 105.5,
    "buysCount": 50,
    "buysTotal": 500000,
    "hasDiscrepancy": false,
    "laborPercent": 18.5
  },
  "comparison": {
    "id": 1,
    "reportDate": "2026-01-01",
    "postedAt": "2026-01-01T23:15:00+00:00",
    "netSalesRetail": 900000,
    "salesVsGoalPercent": 95.0,
    "buysCount": 45,
    "buysTotal": 450000,
    "hasDiscrepancy": true,
    "laborPercent": 20.0
  },
  "deltas": {
    "netSalesRetail": 100000,         // +$1,000 in cents
    "netSalesRetailPercent": 11.11,   // +11.11%
    "buysCount": 5,                   // +5 buys
    "buysCountPercent": 11.11,
    "buysTotal": 50000,
    "buysTotalPercent": 11.11,
    "salesVsGoalPercent": 10.5,       // 105.5 - 95.0
    "laborPercent": -1.5              // 18.5 - 20.0
  },
  "highlights": [
    {
      "field": "netSalesRetail",
      "label": "Net Sales",
      "direction": "up",
      "percentChange": 11.1,
      "significance": "warning"   // "warning" (>10%) or "critical" (>25%)
    }
  ]
}
```

#### Notes
- If only primary report exists, `comparison` and `deltas` will be `null`
- `highlights` contains only significant variances (>10% change)
- `significance`: `"warning"` for >10%, `"critical"` for >25% variance

---

## Error Responses (All Endpoints)

### 401 Unauthorized
```json
{
  "error": "unauthorized",
  "message": "Missing or invalid Authorization header"
}
```

### 403 Forbidden
```json
{
  "success": false,
  "error": "Store access denied"
}
```

### 503 Service Unavailable
```json
{
  "success": false,
  "error": "Mobile close reports API is not configured"
}
```

---

## Usage Examples (Dart/Flutter)

### List Reports
```dart
final response = await http.post(
  Uri.parse('https://api.buyerkiosk.com/api/mobile/close-reports/ou00/list'),
  headers: {
    'Authorization': 'Bearer $accessToken',
    'Content-Type': 'application/json',
  },
  body: jsonEncode({'limit': 30, 'offset': 0}),
);
```

### Get Report Detail
```dart
final response = await http.post(
  Uri.parse('https://api.buyerkiosk.com/api/mobile/close-reports/ou00/detail'),
  headers: {
    'Authorization': 'Bearer $accessToken',
    'Content-Type': 'application/json',
  },
  body: jsonEncode({'date': '2026-01-02'}),
);
```

### Calendar
```dart
final response = await http.post(
  Uri.parse('https://api.buyerkiosk.com/api/mobile/close-reports/ou00/calendar'),
  headers: {
    'Authorization': 'Bearer $accessToken',
    'Content-Type': 'application/json',
  },
  body: jsonEncode({'year': 2026, 'month': 1}),
);
```

### Compare Reports
```dart
final response = await http.post(
  Uri.parse('https://api.buyerkiosk.com/api/mobile/close-reports/ou00/compare'),
  headers: {
    'Authorization': 'Bearer $accessToken',
    'Content-Type': 'application/json',
  },
  body: jsonEncode({
    'primaryDate': '2026-01-02',
    'comparisonDate': '2026-01-01',
  }),
);
```

---

## Data Contracts

### CloseReportSummaryDTO
| Field | Type | Description |
|-------|------|-------------|
| `id` | int | Database record ID |
| `reportDate` | string | Date in YYYY-MM-DD format |
| `postedAt` | string | ISO 8601 timestamp |
| `netSalesRetail` | int | Net sales in cents |
| `salesVsGoalPercent` | float | Percentage of goal |
| `buysCount` | int | Number of buys |
| `buysTotal` | int | Total buys cost in cents |
| `hasDiscrepancy` | bool | Cash discrepancy flag |
| `laborPercent` | float | Labor as % of sales |

### Highlight Object
| Field | Type | Description |
|-------|------|-------------|
| `field` | string | Field name (e.g., "netSalesRetail") |
| `label` | string | Display label (e.g., "Net Sales") |
| `direction` | string | "up" or "down" |
| `percentChange` | float | Percent change value |
| `significance` | string | "warning" (>10%) or "critical" (>25%) |

---

## Backend Implementation

- **Controller**: `userfrosting/src/BuyerKiosk/CloseReport/Controllers/CloseReportMobileController.php`
- **Routes**: `userfrosting/routes/api/mobile/close-reports.php`
- **Repository**: `userfrosting/src/BuyerKiosk/CloseReport/Repositories/CloseReportRepository.php`
- **Tests**: `tests/Unit/CloseReport/CloseReportMobileControllerTest.php`

---

*Generated: 2026-01-03 | Spec: 026-daily-close-report-modernization Phase 6*

---

### [2026-02-19] Private Channels — User-Level Access Control for Staff Chat
**Type**: `enhancement`
**Affects**: Staff Chat (channel creation, member management, channel listing)

#### Summary
Staff Chat now supports a `private` access level for channels. Unlike `public`/`manager`/`owner` channels where access is role-based, private channels are controlled by an explicit member list. Only users explicitly added as members can see or interact with private channels. This enables DMs, small group chats, and project-specific channels.

#### Key Behavior Changes

| Behavior | Before | After |
|----------|--------|-------|
| `accessLevel` values | `public`, `manager`, `owner` | `public`, `manager`, `owner`, **`private`** |
| Channel visibility | Role-based only | Role-based + explicit member list for private |
| `GET /channels` | Returns role-accessible channels | Also returns private channels where user is a member |
| Channel management | Any manager+ can manage | Private: only creator or manager+member can manage |

#### Updated Endpoint: Create Channel
**POST** `/api/mobile/staff-chat/:typeNum/channels`

New optional field `memberUserIds` — required when `accessLevel` is `private`.

```json
// Request — Private channel with explicit members
{
  "name": "Project Alpha",
  "description": "Discussion for Project Alpha team",
  "accessLevel": "private",
  "memberUserIds": [28, 151, 153, 155]
}

// Success Response — includes members array for private channels
{
  "success": true,
  "channel": {
    "id": 5,
    "typeNum": "ou00",
    "name": "Project Alpha",
    "description": "Discussion for Project Alpha team",
    "accessLevel": "private",
    "isDefault": false,
    "retentionDays": null,
    "createdByUserId": 28,
    "createdAt": "2026-02-19 10:00:00"
  },
  "members": [28, 151, 153, 155]
}

// Error — private channel without members
// HTTP 400
{
  "error": "Private channels require at least one member in memberUserIds"
}
```

**Notes**:
- The creator is automatically included in the member list (no need to add yourself)
- `memberUserIds` is ignored for non-private access levels
- Non-private channels work exactly as before (no breaking change)

#### New Endpoint: Bulk Sync Members
**PUT** `/api/mobile/staff-chat/:typeNum/channels/:channelId/members`

Replace the entire member list for a channel. Adds missing users, removes unlisted users.

```json
// Request
{
  "memberUserIds": [28, 151, 153]
}

// Success Response
{
  "success": true,
  "message": "Members synced successfully",
  "members": [
    { "userId": 28, "firstName": "Ryan", "lastName": "V", "membershipType": "manual" },
    { "userId": 151, "firstName": "Kay", "lastName": "S", "membershipType": "manual" },
    { "userId": 153, "firstName": "Hellen", "lastName": "M", "membershipType": "manual" }
  ]
}

// Error — no permission
// HTTP 403
{
  "error": "You do not have permission to manage members for this channel"
}
```

**Permission rules**:
- Private channel creator can always sync members (regardless of role)
- Manager+ who is an explicit member can sync members
- For non-private channels: standard manager+ permission required

#### Enhanced Endpoint: Get Members
**GET** `/api/mobile/staff-chat/:typeNum/channels/:channelId/members`

Response now includes user display names (firstName, lastName) for building user pickers.

```json
// Response
{
  "success": true,
  "members": [
    { "userId": 28, "firstName": "Ryan", "lastName": "V", "membershipType": "manual" },
    { "userId": 151, "firstName": "Kay", "lastName": "S", "membershipType": "manual" },
    { "userId": 153, "firstName": "Hellen", "lastName": "M", "membershipType": "manual" }
  ]
}
```

#### Channel Object — New Fields

The `accessLevel` field in channel objects now includes `"private"` as a valid value:

| Field | Type | Values |
|-------|------|--------|
| `accessLevel` | string | `"public"`, `"manager"`, `"owner"`, **`"private"`** |

#### Access Control Logic

| Access Level | Who can see | Who can manage |
|-------------|-------------|----------------|
| `public` | All employees (roles 1-4) | Manager+ (roles 1-2) |
| `manager` | Manager+ (roles 1-2) | Manager+ (roles 1-2) |
| `owner` | Owner only (role 1) | Owner only (role 1) |
| **`private`** | **Explicit members only** | **Creator, or manager+ who is a member** |

#### Mobile Action Required
- [ ] Add `"private"` as a valid `accessLevel` option in channel creation UI
- [ ] Add user picker for selecting `memberUserIds` when creating private channels
- [ ] Handle `members` array in create channel response for private channels
- [ ] Add member management UI (sync members via PUT endpoint)
- [ ] Display private channels with appropriate icon/indicator in channel list
- [ ] Hide channel management options for non-creator non-manager members of private channels

#### Backend Implementation
- **Migration**: `userfrosting/migrations/input/20260219_039_001_staff_chat_add_private_access_level.json`
- **Model**: `userfrosting/src/BuyerKiosk/StaffChat/Models/Channel.php`
- **Service**: `userfrosting/src/BuyerKiosk/StaffChat/Services/ChannelAccessService.php`
- **Repository**: `userfrosting/src/BuyerKiosk/StaffChat/Repositories/ChannelMemberRepository.php`
- **Controller**: `userfrosting/src/BuyerKiosk/StaffChat/Controllers/StaffChatApiController.php`
- **Mobile Controller**: `userfrosting/src/BuyerKiosk/StaffChat/Controllers/MobileStaffChatController.php`
- **Routes**: `userfrosting/routes/groups/mobile-staff-chat.php`

---

*Generated: 2026-02-19 | Feature: Private Channels (Spec 039)*

### [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.
