# Baskit Ops — Build Log

Progress log for the module build-out. Each module records **Started** and
**Finished** with what was built and how it was tested (positive + negative
security). Newest entries at the bottom of each module block.

## Status overview

| # | Module | Depends on | Status |
|---|--------|-----------|--------|
| 0 | Foundation (auth, RBAC, users, audit, settings, brand) | — | ✅ Done |
| 1 | Retailers & Stores | Foundation | ✅ Done |
| 2 | Products / Catalog (PIM) | Retailers | ✅ Done |
| 3 | Scrape Runs / Ingestion | Products | ✅ Done |
| 4 | Customers & beta approval | Foundation | ✅ Done |
| 5 | Pickers & prepaid cards | Foundation, Stores | ✅ Done |
| 6 | Orders & completion | Customers, Products, Stores, Pickers | ✅ Done |
| 7 | Payments & reconciliation | Orders, Pickers, Customers | ✅ Done |
| 8 | Media & Ads | Products | ✅ Done |
| 9 | Support & issues | Orders, Pickers, Runs | ✅ Done |
| 10 | Reports | All | ✅ Done |

Legend: ✅ done · ⏳ in progress · ⬜ not started

Testing convention per module — **positive**: an authorised role can list/
create/edit and changes persist & audit-log; **negative security**: a role
without the permission is 403'd (GET + POST), unauthenticated is redirected,
CSRF-less POST is rejected, invalid input is rejected, and IDOR/ownership is
enforced where relevant.

---

## Module 0 — Foundation
**Finished.** Dockerised CI4+Postgres, Shield auth, RBAC (groups + per-user
override matrix, deny-wins), audit logging, settings, users & access module,
brand CI + two-plane login + favicon. Verified end-to-end.

---

## Module 1 — Retailers & Stores  (SET-05)
**Started 2026-07-10.** Reference data every other module leans on: the four
participating retailers (PnP, Checkers Sixty60, Woolworths, Spar) and their
stores (name, suburb, hours, active). Gated by `settings.view` / `settings.manage`.
Plan: `retailers` + `stores` tables (migration), models, `Admin\Retailers`
controller (list + CRUD + active toggles), views, seeder for the 4 retailers +
Constantia-area stores, audit logging on writes. Linked from the Settings page.

**Finished 2026-07-10.** Built `retailers` + `stores` migration, `RetailerModel`
/`StoreModel`, `Admin\Retailers` (list, create/update/toggle retailer; create/
update/toggle store), views (`admin/retailers/index`), routes under `/admin/
retailers` + `/admin/stores`, `RetailerSeeder` (4 retailers + Constantia stores),
Settings-page link. Audit logging on every write.
Testing — 13 checks, all pass. **Positive:** owner views list, creates retailer,
adds store (persist + audit). **Negative security:** ops & support roles (no
`settings.view`) → 403 on GET and POST; unauthenticated → 302 to login;
CSRF-less POST → 403; invalid slug rejected (not persisted); blocked write
attempts confirmed absent from DB. Visual QA passed (on-brand, inline edit).
Note: a `Food Lovers` retailer + `PnP Newlands` store remain as test/demo data.

---

## Module 2 — Products / Catalog (PIM)  (PRO)
**Started 2026-07-10.** The catalog the apps read and the target of ingestion.
Plan: tables `categories`, `tags`, `products`, `product_tag`, `retailer_prices`
(current per-retailer price + availability), `substitution_groups` +
`substitution_members`, `retailer_skus` (cross-retailer equivalence + the
unmatched queue). Models for each. `Admin\Products` (list with search/category/
coverage/best-price/status; detail with per-retailer prices, tags, substitution
group, equivalence; create/edit/archive/restore; set image; per-retailer price
& availability edit). `Admin\Catalog` (categories & tags managers).
`Admin\Equivalence` (unmatched queue, confirm/reject match). Gated by
`products.view`/`products.manage`/`products.taxonomy`/`products.equivalence`.
Seeder with the PRD sample products + prices + an unmatched SKU. Audit on writes.
(Price-history over time lands with Module 3 ingestion.)

**Finished 2026-07-10.** Built the `CreateCatalog` migration (8 tables:
categories, tags, products, product_tag, retailer_prices, substitution_groups,
substitution_members, retailer_skus). Models: Product (with best-price/coverage
list query), Category, Tag, RetailerPrice, SubstitutionGroup/Member, RetailerSku.
Controllers: `Admin\Products` (list w/ search+category+status filters, detail,
create/edit, archive/restore, per-retailer price upsert, tag toggle, substitution
assign/clear), `Admin\Catalog` (categories + tags managers), `Admin\Equivalence`
(unmatched queue, confirm/reject match). Views for all. Routes gated per
capability (view/manage/taxonomy/equivalence). `CatalogSeeder` with PRD sample
products + per-retailer prices + one unmatched SKU. Audit on every write.
Testing — 17 checks, all pass. **Positive:** list w/ coverage+best price, detail,
ops creates product, per-retailer price upsert, add category+tag, confirm
equivalence match (all persist + audit). **Negative security:** support &
finance (no `products.*`) → 403 on GET+POST; CSRF-less POST → 403; product
without required `size` rejected (not persisted); blocked writes confirmed absent.
Visual QA passed. Note: price history over time is deferred to Module 3 (ingestion
writes observations); detail currently shows the latest observation per retailer.

---

## Module 3 — Scrape Runs / Ingestion  (RUN + ingestion API)
**Started 2026-07-10.** Two halves. (a) The scraper-only **ingestion API**
(`/ingest/v1/*`, service-key header + optional IP allowlist, CSRF-exempt): open
run → products:batch / prices:batch / availability:batch → close run, plus list
runs, rejected records, and unmatched products. Writes retailer_skus, products,
retailer_prices, and a new `price_observations` history table; maintains per-run
accounting that reconciles fetched = created + updated + unchanged + skipped +
rejected, with a reason per rejected/skipped record. (b) Console **RUN screens**
(`Admin\Runs`): runs list, run detail (reconciliation), rejected/skipped/applied
drills, abort in-flight run, schedule view — gated by `runs.view`/`runs.manage`.
Plan: `CreateIngestion` migration (ingest_runs, ingest_records, price_observations),
models, `ServiceKeyFilter`, `Ingest\Ingestion` API controller, `Admin\Runs`
console controller + views, routes. Security tests will cover valid vs
missing/bad service key and the console permission gating.

**Finished 2026-07-10.** Built `CreateIngestion` migration (ingest_runs,
ingest_records, price_observations). Models: IngestRun (with `tally()` atomic
counter increments), IngestRecord, PriceObservation. `ServiceKeyFilter` (alias
`servicekey`) — `X-Service-Key` compared with `hash_equals`, optional
`INGEST_ALLOWED_IPS` allowlist, fails-shut when no key configured. Ingestion API
`Ingest\Ingestion` (openRun, products/prices/availability batches, closeRun with
reconciliation, listRuns, rejected, unmatched) at `/ingest/v1/*` (colon paths per
PRD: `products:batch`, `runs/{id}:close`). Console `Admin\Runs` (list, detail w/
reconciliation KPIs, applied/skipped/rejected drills, abort, schedule) + views.
Routes: ingest group `servicekey`-gated & CSRF-exempt; console gated
`runs.view`/`runs.manage`. Env: `INGEST_SERVICE_KEY` (.env + compose).
Testing — 25 checks, all pass. **Positive:** full pipeline open→products→prices→
availability→close; run reconciles (6 = 0+2+1+0+3); price observation recorded;
rejected/unmatched read-back; console list/detail/drills/schedule; abort works.
**Negative security:** ingestion 401 on missing AND wrong service key; console
`runs` 403 for support (no `runs.*`); abort 403 for support and 403 without CSRF.
Found & fixed a real bug (undefined `in_stock` key → 500 on price rows lacking
it) and a harness bug (POST helper defaulted to GET). Visual QA matches PRD M9.

---

## Module 4 — Customers & beta approval  (CUS)
**Started 2026-07-10.** Customer accounts are Shield users in the `customer`
group, extended with domain tables. Plan: `CreateCustomers` migration
(customer_profiles w/ beta_status, addresses w/ 7km radius check, wallets,
wallet_transactions ledger, devices). Models for each. `Admin\Customers`:
CUS-01 list (status, beta filter, last active), CUS-02 beta application queue,
CUS-03 approve/decline, CUS-04 detail (profile, addresses+radius, devices),
CUS-05 orders tab (placeholder until Orders module), CUS-06 wallet tab + ledger,
CUS-07 wallet adjustment (credit/debit w/ reason, atomic balance update),
CUS-08 suspend/reactivate (Shield ban). Gated `customers.view`/`.manage`/
`.approve`/`.wallet`. Seeder: sample customers + profiles + wallets + addresses +
one pending beta application. Audit on all writes. Tests positive + negative
security (per-capability gating, CSRF, wallet integrity, self/validation).

**Finished 2026-07-10.** Built `CreateCustomers` migration (customer_profiles,
addresses, wallets, wallet_transactions, devices). Models for each + `Wallet`
library (atomic balance change + ledger entry in a DB transaction; refuses
negative balances). `Admin\Customers`: list (beta filter, joins profile), beta
queue, approve/decline, detail (profile, addresses w/ Haversine 7km radius flag,
devices, wallet summary), wallet tab + ledger, wallet adjustment, add address,
suspend/reactivate (Shield ban). Views for all. Routes gated
`customers.view`/`.approve`/`.wallet`/`.manage`. `CustomerSeeder` (5 customers,
profiles, wallets, addresses incl. one outside radius, 2 pending beta apps).
Audit on all writes.
Testing — 25 checks, all pass. **Positive:** list/queue/detail/wallet; approve
beta; wallet credit (ledger balance_after correct); suspend+reactivate; add
address w/ radius computed (Inside). **Negative security / integrity:** debit
beyond balance refused (balance unchanged); zero amount refused; approve→403 for
support, wallet→403 for support, suspend→403 for finance, add-address→403 for
support; picker-manager (no `customers.view`)→403; CSRF-less→403; anon→302.
Found & fixed a real bug: beta queue selected `users.email` but Shield stores
email in `auth_identities` (joined `ai.secret` instead). Visual QA passed.

## Module 5 — Pickers & prepaid cards  (PIK)
**Started 2026-07-10.** Pickers are Shield users in the `picker` group, extended
with domain tables. Plan: `CreatePickers` migration (picker_profiles w/ approval
+ duty + vehicle + rating, picker_store_coverage pivot, picker_shifts,
picker_earnings, prepaid_cards, card_transactions). `Card` library (atomic
load/spend). `Admin\Pickers`: PIK-01 list (duty, lifetime picks, rating, week
earnings), PIK-02 applications, PIK-03 approve, PIK-04 detail + tabs, PIK-05 jobs
(placeholder until Orders), PIK-07 shifts, PIK-08 earnings, PIK-09 payout
statement, PIK-10 prepaid card + ledger, PIK-11 assign/replace card + load funds,
PIK-12 edit (contact/coverage/vehicle), PIK-13 suspend. Duty toggle for ops.
Gated `pickers.view`/`.approve`/`.manage`/`.card`. Seeder: sample pickers +
profiles + a card. Audit on writes. Tests positive + negative (card integrity,
per-capability gating, CSRF).

**Finished 2026-07-10.** Built `CreatePickers` migration (picker_profiles,
picker_store_coverage, picker_shifts, picker_earnings, prepaid_cards,
card_transactions). Models for each + `Card` library (atomic load/spend, refuses
negative). `Admin\Pickers`: list (duty/picks/on-time/rating/week earnings),
applications, approve/decline, detail (KPIs + identity + coverage + shifts +
earnings + jobs placeholder), edit (contact/vehicle/coverage pivot), duty toggle,
suspend/reactivate, prepaid card tab (assign/replace + load funds + ledger),
payout statement (weekly rollup). Views for all. Routes gated
`pickers.view`/`.approve`/`.manage`/`.card`. `PickerSeeder` (3 approved + 1
pending, matches PRD, with a loaded card). Audit on all writes. Added an Account
status badge to the picker detail during QA.
Testing — 25 checks, all pass. **Positive:** list/applications/detail/card/payout;
approve; assign card + load (ledger balance correct); edit + coverage; duty toggle;
suspend+reactivate (Suspended badge). **Negative security / integrity:** zero-load
refused (balance unchanged); approve→403 support, assign-card→403 support,
edit→403 support, suspend→403 support; finance (no `pickers.*`)→403; CSRF-less
load→403; anon→302. Visual QA matches PRD M3. Jobs/shifts/earnings tables exist
and render empty until the Orders module populates them.

## Module 6 — Orders & completion  (ORD)
**Started 2026-07-10.** The operating spine. Plan: `CreateOrders` migration
(orders, order_legs, order_items, order_events, refunds). Models for each.
`Admin\Orders`: ORD-01 list (filters status/store/date/customer), ORD-02 detail
(header, totals, Smart Split saving, per-store legs w/ item states — the "did the
picker finish" view), ORD-03 leg detail (picker, pack-by, item states, spend vs
target, receipt, handoff), ORD-04 item drill (event history), ORD-06 timeline,
ORD-07 assign/reassign picker, ORD-08 cancel order/leg, ORD-09/10 refund + confirm
(credits customer wallet on confirm), ORD-11 internal note, ORD-12 handoff record;
ORD-05 live tracking placeholder. Every action writes an order_event + audit.
Gated `orders.view`/`.assign`/`.cancel`/`.refund`/`.note`. Seeder: PRD sample
orders (#BK-2841 etc.) with legs/items/events, back-filling picker earnings and
customer order history. Tests positive + negative (gating, CSRF, refund wallet
integrity, cross-refs).

**Finished 2026-07-10.** Built `CreateOrders` migration (orders, order_legs,
order_items, order_events, refunds). Models for each (Order w/ filtered list +
store-mix aggregate, OrderLeg w/ forOrder/forPicker, OrderItem, OrderEvent w/
record(), Refund). `Admin\Orders`: list (status/store/date/customer filters),
detail (KPIs + per-leg items w/ states + spend-vs-target + handoff — the "did the
picker finish" view), leg detail (+ handoff record), item drill (event history),
timeline, tracking (placeholder), assign/reassign picker, cancel, refund raise +
confirm (credits customer wallet via `Wallet`), internal note. Views for all.
Routes gated `orders.view`/`.assign`/`.cancel`/`.refund`/`.note`. `OrderSeeder`
(PRD orders BK-2841/2840/2839 w/ legs/items/events) + `ResetOrdersSeeder` (dev).
Back-filled the customer-detail Orders list and picker-detail Jobs list (were
placeholders) with real data. Every action writes an order_event + audit.
Testing — 29 checks, all pass. **Positive:** list/detail/leg/item/timeline/
tracking; status filter; assign picker (timeline updates); note; refund raise →
confirm credits wallet (R120→R145); cancel; customer/picker cross-refs populated.
**Negative security / integrity:** per-action gating (assign→403 support,
refund→403 ops, cancel→403 support); picker-mgr (no `orders.*`)→403; CSRF-less→
403; anon→302; **IDOR** confirm-refund via wrong order id → no effect. Visual QA
matches PRD M6. Made the order test deterministic via reset+reseed.

## Module 7 — Payments & reconciliation  (PAY)
**Started 2026-07-10.** Ties order legs (spend vs charge), prepaid cards
(Module 5), customer wallets (Module 4), and the Peach webhook log. Plan:
`CreatePayments` migration (add recon_status/recon_note/recon_by/recon_at to
order_legs; new payment_webhooks table). `PaymentWebhookModel`. `Admin\Payments`:
PAY-01 reconciliation queue (handed-off legs pending recon), PAY-02 recon detail
(items + card txn + customer charge), PAY-03 mark reconciled, PAY-04 flag
discrepancy, PAY-05 prepaid cards list, PAY-06 card detail & ledger, PAY-07 load
funds, PAY-08 customer wallet ledger (all customers), PAY-09 gateway webhook log +
payload drill. Gated `payments.view`/`.reconcile`/`.cards`. Seeder: sample Peach
webhooks. Audit on writes. Tests positive + negative (recon gating, card load
integrity, CSRF).

**Finished 2026-07-10.** Built `CreatePayments` migration (recon_status/note/by/at
columns on order_legs; payment_webhooks table). `PaymentWebhookModel`.
`Admin\Payments`: reconciliation queue (handed-off legs pending, with spend/
variance/target/charge), recon detail (items + picker card txns + variance KPIs),
mark reconciled, flag discrepancy, prepaid cards list, card detail + ledger, load
funds, customer wallet ledger (all customers), Peach webhook log + payload drill.
Views for all. Routes gated `payments.view`/`.reconcile`/`.cards`. `PaymentSeeder`
(5 Peach webhooks incl. one bad-signature). Audit on writes.
Testing — 24 checks, all pass. **Positive:** recon queue/detail; mark reconciled
(leaves queue); flag discrepancy; cards list/detail; load funds (ledger updates);
wallet ledger; webhook log + payload drill; bad-signature webhook shown invalid.
**Negative security / integrity:** zero card-load refused; reconcile/flag→403 for
picker-manager (no `payments.reconcile`); support (no `payments.*`)→403 across
queue/cards/webhooks; CSRF-less reconcile→403; anon→302.
**Cross-cutting bug fixed:** Postgres returns booleans as `'t'`/`'f'` strings
(both truthy in PHP) — every boolean display and the retailer active-toggle were
subtly wrong. Added a `truthy()` helper and applied it to webhook signature,
handoff_verified, within_radius, retailer/store active (view + toggle logic).
Regression-checked the retailer toggle (flips + restores). Visual QA passed.

## Module 8 — Media & Ads  (MED)
**Started 2026-07-10.** Console side of the Tier-1 media framework. Plan:
`CreatePlacements` migration (placements: name, slot [hero|deals|search|
last_minute], sponsor, label, linked product, search_term, creative, schedule,
status, booked/served impressions + clicks). `PlacementModel`. `Admin\Ads`:
MED-01 list by slot/status, MED-02 editor (slot/creative/product/schedule/sponsor
label), MED-03 creative preview (in-app slot frame), MED-04 schedule view, MED-05
performance (impressions vs booked, CTR), MED-06 pause/resume + end. Gated
`ads.view`/`ads.manage`. Seeder: PRD sample placements (Krönung hero, Sasko
deals, Clover search, Nulaid last-minute). Audit on writes. Tests positive +
negative (gating, CSRF, validation).

**Finished 2026-07-10.** Built `CreatePlacements` migration (placements table).
`PlacementModel` (SLOTS constant, filtered list). `Admin\Ads`: list (slot/status
filters), create/edit editor (slot/creative/product/schedule/sponsor label),
detail with in-frame creative preview, schedule view, performance (impressions
vs booked fill %, CTR), pause/resume, end. Views for all. Routes gated
`ads.view`/`ads.manage`. `PlacementSeeder` (PRD placements: Krönung hero, Sasko
deals, Clover search, Nulaid last-minute, with delivery numbers). Audit on writes.
Testing — 26 checks, all pass. **Positive:** list/schedule/performance (CTR);
create/edit; pause→resume→end lifecycle; detail preview. **Negative security /
validation:** ended placement cannot resume; placement without slot rejected;
create/edit/end→403 for ops (no `ads.manage`); support (no `ads.*`)→403;
CSRF-less→403; anon→302; blocked writes absent. Visual QA matches PRD M13
(in-frame sponsored preview + delivery KPIs).

## Module 9 — Support & issues  (SUP)
**Started 2026-07-10.** Plan: `CreateSupport` migration (issues, issue_events,
feedback). Models for each. `Admin\Support`: SUP-01 issue log (filter status/
category, link to order/picker/run), SUP-02 issue detail (timeline, linked
record, resolution state) with create/note/assign/status/resolve actions, SUP-03
customer feedback list (app + WhatsApp channels) with mark-reviewed. Gated
`support.view`/`support.manage`. Seeder: sample issues + feedback. Audit on
writes. Tests positive + negative (gating, CSRF, validation).

**Finished 2026-07-10.** Built `CreateSupport` migration (issues, issue_events,
feedback). Models: Issue (nextRef, filtered list), IssueEvent (record()),
Feedback. `Admin\Support`: issue log (status/category filters), create issue,
detail (metadata, linked-record deep-link to order/picker/run, event timeline,
resolution), add note, assign (→ in_progress), resolve, customer feedback list
(app + WhatsApp channels), mark-reviewed. Views for all. Routes gated
`support.view`/`support.manage`. `SupportSeeder` (3 issues + 3 feedback). Audit
on writes.
Testing — 21 checks, all pass. **Positive:** issue log/detail/feedback; create;
note; assign (status→in_progress); resolve (timeline + resolution); mark feedback
reviewed. **Negative security / validation:** empty-title issue rejected; create/
resolve→403 for finance (no `support.*`); support/feedback→403 for finance;
CSRF-less note→403; anon→302. Fixed a real bug: `consoleAgents()` used Shield's
UserModel which returns entities — the view indexed them as arrays (500); added
`->asArray()` and guarded the assignee access. Visual QA matches PRD SUP-02.

## Module 10 — Reports  (REP)
**Started 2026-07-10.** Final module — read-only aggregation across all domains
(no new schema). Plan: `Admin\Reports`: REP-01 home (headline figures + entry
cards), REP-02 daily KPI series (per business plan §10.2), REP-03 orders by store,
REP-04 orders by suburb, REP-05 picker performance leaderboard, REP-06 SKU
performance (top movers + OOS leaders), REP-07 savings (Smart Split), REP-08
substitution & OOS, REP-09 refunds & adjustments, REP-10 CSV export of any report.
Gated `reports.view`/`reports.export`. Postgres aggregate queries (FILTER,
GROUP BY). Tests positive + negative (gating, export permission, CSRF n/a for GET).

**Finished 2026-07-10.** Built `Admin\Reports` (no new schema — read-only
aggregation). Reports home (headline GMV/orders/savings/refunds + entry cards),
daily KPI series, orders by store (bar mix), orders by suburb, picker
performance leaderboard (legs/on-time/rating/subs & refund rates), SKU
performance (top movers + OOS leaders), savings (Smart Split totals + top
savers), substitution & OOS rates, refunds by reason, and CSV export. Postgres
aggregate queries (COUNT FILTER, GROUP BY, string_agg). Views for all. Routes
gated `reports.view`; export gated `reports.export`. Audit on export.
Testing — 22 checks, all pass. **Positive:** home + all 8 report pages render
with live data (retailer mix, picker leaderboard, savings total); CSV export
returns text/csv with header row. **Negative security:** export→403 for
picker-manager (has `reports.view` but NOT `reports.export`) while it CAN still
view — proves capability granularity; support (no `reports.*`)→403 across pages
and export; unknown export report → redirect; anon→302. Visual QA matches PRD.

---

## 🎉 Build complete — all 11 modules (0–10) done
Every module built, tested (positive + negative security), and visually QA'd.
Totals: 8 domain migrations, ~40 tables, ~35 controllers, the JWT-less console +
the service-key ingestion API, and ~230 automated checks across the module test
scripts — all green. Full 102-screen console per the PRD. Remaining future work
(explicitly out of the console scope): live map tracking, and SMTP-backed email
invites for console users.

---

## Picker mobile API (`/api/v1`)  (TASK-001)
**Started 2026-07-12.** JWT picker application surface for the Flutter app:
refresh/logout, PIN reset (email OTP), profile GET/PATCH, substitute ask/poll,
receipt upload, handoff, driver call mask, device deregister. New migration
`CreatePickerApiSupport` (refresh tokens, pin resets, substitute_requests,
order_leg_receipts, picker pin/notif prefs, driver ETA/location fields). Filters:
`apiauth`, `pickerguard`, `apithrottle`. SMTP wired via entrypoint + `Email` config.

**Finished 2026-07-12.** All 11 endpoints live under `/api/v1`. PHPUnit unit/feature
tests green (integration skipped without host `pgsql`). Docker smoke: profile 200,
no-auth 401, refresh rotation 200. TestSprite bootstrap timed out — results logged
in `docs/testsprite_run.md`. Dev token helper: `php spark picker:issue-tokens`.

---

## Dashboard live ops map
**Started / Finished 2026-07-13.** Replaced stub KPI dashes + foundation alert with
live open-order / on-duty metrics and a full-width Constantia Leaflet map. Map
layers: stores (suburb anchors), open deliveries, store pickups, pickers en route,
multi-store highlights. KPI buttons filter layers. Unit tests:
`tests/unit/OpsDashboardMapTest.php`. Browser smoke on `/admin` with owner login.

---

## Scraper status lamps (Scrape runs)
**Started / Finished 2026-07-14.** Extended `POST/GET /ingest/v1/status` so each
retailer scraper has its own heartbeat (`host=scraper:{slug}`) and optional
`scrapers[]` batch. Scrape runs page shows three lamps (green ready / orange busy
/ red error). Unit: `tests/unit/PipelineHeartbeatModelTest.php`.
