# New knowledge — Picker API

## Docker entrypoint and `.env`

Apache/mod_php does not reliably expose dotted environment variables to PHP. The
container entrypoint writes `/var/www/html/.env` from plain compose variables
(`APP_BASEURL`, `DB_HOST`, `JWT_SECRET`, `SMTP_*`, etc.). **Quote values that
contain spaces** (e.g. `email.fromName="Baskit Ops"`) or CI4 DotEnv parsing fails
at boot.

## Dev compose vs prod compose

`docker-compose.yml` must use the same shell-safe variable names as
`docker-compose.prod.yml` and `docker/entrypoint.sh`. After changing the entrypoint,
**rebuild the app image** (`docker compose build app`) — the script is copied at
build time, not bind-mounted.

## JWT alignment

Host `.env` `JWT_SECRET` is passed into the container via compose substitution.
Smoke tests and `php spark picker:issue-tokens` must use the same secret as the
running app. Use the spark command inside the container for refresh-token tests.

## PIN reset mail

PIN reset uses CI4 `Email` with SMTP from env. Dev default targets Mailpit
(`SMTP_HOST=mailpit`, port 1025). Start Mailpit with
`docker compose --profile tools up -d mailpit` (UI on 8025).

## Push notifications

## Canonical shop categories (Ops ↔ app)

- Single normalizer: `App\Libraries\CatalogCategories` — same slugs as the Flutter app and catalogue API (`vas_airtime`, `home_essentials`, `fresh_produce`, `bakery`, `dairy_eggs`, `meat_poultry`, `other`).
- Migration `2026-07-13-140000_SyncCanonicalCategories` upserts canonical rows; legacy taxonomy slugs (`dairy-eggs`, `pantry`, etc.) stay in DB but `active = false`.
- Ingest assigns `products.category_id` from scraped `category_raw` via `resolveCategoryId()`.
- Admin product pickers use `CategoryModel::listActive()` only.


`App\Libraries\PushNotify` is a no-op stub until FCM/device fan-out (BE-14).

## Picker mobile core API (2026-07-13)

Flutter `baskit_picker` contracts against `/api/v1`:

- `POST /auth/login` — phone digits + PIN (`picker_profiles.phone` / `pin_hash`)
- `GET /config` — public reachability probe
- `PATCH /picker/status` — duty on/off (+ optional lat/lng into shifts)
- `GET /picker/jobs`, `GET /picker/jobs/{legId}` — claimable pending legs + active/today
- Mutations: `:accept` (claim-lock `409`), `:decline`, `:pick`, `:substitute`, `:refund`, `:spend`, `:handoff`
- `GET /picker/earnings`, `POST /devices`
- Helpers: `php spark picker:set-pin`, `php spark picker:seed-job`

Pay amounts currently use a flat formula in `PickerJobSerializer` until Ops owns a fee table.

## Dashboard live ops map

- `Admin\Dashboard` builds open-book KPIs from `orders` + on-duty `picker_profiles`
  and a map payload via `App\Libraries\OpsDashboardMap`.
- Stores have no lat/lng columns yet — suburb anchors (Constantia / Tokai /
  Bergvliet / …) place pins; delivery pins use `addresses.lat/lng` when present,
  otherwise demo offsets around Constantia.
- On-duty pickers are placed mid-route between a store and an open order (demo
  path) with dashed polylines for “en route”.
- Front end: Leaflet + CARTO light tiles (`public/assets/js/ops-dashboard-map.js`).
  KPI tiles and legend chips toggle map layers.

## Scraper status lamps (`/ingest/v1/status`)

Console **Scrape runs** shows three lamps (PnP / Checkers / Woolworths):

| Lamp | Meaning | Worker `stage` |
|------|---------|----------------|
| Green | Ready | `idle` (or never posted) |
| Orange | Busy | `scrape`, `push`, `cooldown` |
| Red | Error | `error`, or busy row stale (>30 min) |

Workers (service key) POST:

```http
POST /ingest/v1/status
{ "retailer": "pnp", "stage": "scrape", "message": "aisles 12/40", "host": "pipeline-1" }
```

Batch (up to two concurrent):

```http
POST /ingest/v1/status
{ "scrapers": [
  { "retailer": "pnp", "stage": "scrape", "message": "..." },
  { "retailer": "checkers", "stage": "push" }
] }
```

Rows are keyed `host=scraper:{retailer}` so each retailer keeps its own lamp. GET
returns `{ status, scrapers }` for workers/ops checks. Heartbeats should refresh
every few minutes while busy.
