Skip to content

Latest commit

 

History

History
129 lines (99 loc) · 23.1 KB

tasks.md

File metadata and controls

129 lines (99 loc) · 23.1 KB

ELMS — Task Breakdown & Tracking

Tracking lives in this file: flip [ ] → [x] as tasks land; update the milestone table. Sized for a solo developer; order respects dependencies (each milestone builds on the previous). Requirement IDs refer to the PRD; design references to the TDD.

Milestone status

# Milestone Proves Status
M0 Repo scaffold & tooling make dev runs, CI skeleton green ✅ done
M1 Database & data layer migrations + sqlc + seed against real PG ✅ done
M2 Domain core allocation + pricing + codes fully unit-tested ✅ done
M3 Services & Postgres adapter store/retrieve use cases pass integration tests ✅ done
M4 HTTP API & auth full contract serves; API tests green ✅ done
M5 Level 4 concurrency proof race test: N≫M, zero double-assign, -race clean ✅ done
M6 Web SPA (kiosk/agent/admin) all three journeys clickable end-to-end ✅ done
M7 E2E & full CI pipeline Playwright suite green in CI ⬜ not started
M8 Ship polish docker compose up demo-ready; docs match reality 🟨 in progress
M9 Physical dimensions lockers and packages measured in mm; 3-D fit with clearance; box templates ✅ done
M10 Audit log every mutation leaves an event in the same tx; admin Activity screen ✅ done

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

M0 — Repo scaffold & tooling

  • T-01 git init; root layout per TDD §2 (server/, web/, docs/); .gitignore, .editorconfig
  • T-02 Go module; pin toolchain; golangci-lint config
  • T-03 Makefile targets: dev, gen (sqlc + oapi + TS client), test, test-int, e2e, lint, seed
  • T-04 docker-compose.yml: postgres 17 (healthcheck) + api + web stubs
  • T-05 CI skeleton (.github/workflows/ci.yml): lint + build jobs on push/PR

M1 — Database & data layer

  • T-10 Migration 0001: enums, users, sessions, stations, lockers, packages, payments, pricing_config per ERD — incl. partial unique index packages(locker_id) WHERE status='stored', payments(package_id) WHERE status='pending', CHECK (pricing_config.id = 1) (FR-12 backstop)
  • T-11 Migrate-on-boot in cmd/api; fail fast on dirty version
  • T-12 sqlc setup; first queries (stations, lockers CRUD); generated code committed
  • T-13 Idempotent seed: admin + agent users (bcrypt), 1 station, S/M/L locker grid, pricing X=10.00 USD
  • T-14 testcontainers-go harness: migrated PG per test run, tx-rollback helper

M2 — Domain core (pure; TDD §4)

  • T-20 Size/rank + FitsIn; typed sentinel errors — unit tests (FR-5)
  • T-21 ChargeFor: day counting max(1, ceil(h/24)), tier math in minor units, breakdown struct — table-driven boundary tests: 3 h, exactly 24 h, 24 h+1 s, day 5/6, day 10/11, 12 d (FR-10, US-D2 examples)
  • T-22 Pickup code: crypto/rand 6-digit no-bias generation; bcrypt hash/verify helpers — tests incl. uniqueness-retry path (FR-6)
  • T-23 Clock port + frozen-clock test helper (US-D1 testability)

M3 — Services & Postgres adapter

  • T-30 Ports (TxRunner, Repo) + pgx tx runner implementation
  • T-31 Allocation query with FOR UPDATE SKIP LOCKED + size-rank ordering (TDD §7); sqlc integration
  • T-32 StorePackage service: tx = claim locker → insert package → occupy locker → return label + one-time code; no-locker → ErrNoLockerAvailable (FR-5..7) — integration tests incl. atomicity on induced failure
  • T-33 QuotePickup: lookup by station+label+code (generic failure), attempt counter + 15-min block, charge via ChargeFor, pending payment (TTL 15 min, partial-unique; re-quote returns existing pending) (FR-8, FR-10) — integration tests
  • T-34 ConfirmPickup: tx = lock package row → payment paid → package retrieved (+final charge) → locker available; expired quote → ErrQuoteExpired (FR-9) — integration tests incl. reuse-attempt after retrieval
  • T-35 Admin services: stations/lockers CRUD rules (delete-empty-only, occupied-cannot-OOS), pricing get/put, dashboard aggregation (FR-1..4, FR-11, FR-14) — integration tests
  • T-36 Auth service: login (bcrypt verify) → session create; validate; logout; expiry sweep (FR-13)

M4 — HTTP API & auth

  • T-40 openapi.yaml: full contract per TDD §8.1 incl. problem+json error schemas
  • T-41 oapi-codegen strict-server wiring; chi router; middleware: request ID, slog access logs, panic recovery
  • T-42 Error mapper: typed errors → status table (TDD §8.3); generic 404 for pickup; 429 + Retry-After
  • T-43 Session middleware + role guards; login/logout/me endpoints; cookie flags per TDD §8.2
  • T-44 Implement all handlers over services
  • T-45 API test suite (httptest): auth gates, each endpoint happy + error path, no-enumeration check
  • T-46 /healthz (DB ping) + graceful shutdown

M5 — Level 4 concurrency proof

  • T-50 Race integration test: 50 goroutines × 10 suitable lockers → exactly 10 stored, distinct lockers, 40 clean rejections; final state audit (US-E1..E3)
  • T-51 Store⇄retrieve interleaving test: freed locker becomes allocatable, never double-visible
  • T-52 Whole server test suite under -race in CI; document proof in README

M6 — Web SPA

  • T-60 Vite + React + TS scaffold; Tailwind; router with 3 areas; TanStack Query; generated TS client from openapi.yaml (make gen)
  • T-61 Auth plumbing: login page, useMe guard, role-based routing, problem+json → toast/inline errors
  • T-62 Agent area: station picker → availability grid → store form → code-shown-once modal (US-B1..B3)
  • T-63 Kiosk area: station select → label+code form → quote with breakdown → mock-pay → "locker open" receipt; invalid/blocked/expired states (US-C1, US-C2)
  • T-64 Admin area: stations & lockers CRUD + maintenance toggle; pricing editor; occupancy dashboard (US-A1..A6, US-D3)
  • T-65 Frontend unit tests (Vitest): quote rendering, code modal, guards

M7 — E2E & full CI

  • T-70 Playwright setup running against compose stack
  • T-71 Scenarios: full store→pickup happy path; wrong code ×5 → blocked; station full → 409 UX; maintenance locker excluded; pricing change reflected in next quote; from T-97 — store by box template end to end, and a custom package nothing on the estate fits (the fit pass stamps too big and names the largest cell the station runs, the store answers 409); from T-103 — the Activity screen lists the store and pickup the run just performed, newest first
  • T-72 CI pipeline complete: lint → server tests (-race) → web build/test → e2e → docker build; branch protection on green

M8 — Ship polish

  • T-80 README: quickstart (≤5 steps), demo credentials, architecture summary linking /docs, L4 proof pointer
  • T-81 Compose hardening: healthchecks, restart policies, first-run seed flag — all three services restart: unless-stopped; the distroless api probes itself (/api -healthcheck → /api/v1/healthz, so DB loss flips the container unhealthy); web waits on api healthy, not started, because nginx proxies /api there; SEED=true (default) was already wired end to end and stays the first-run switch
  • T-82 Docs sync pass: PRD/TDD/ERD/flowcharts vs implementation; update deltas
  • T-83 Demo walkthrough script (agent stores → customer retrieves → admin dashboard)
  • T-84 Public demo deploy: single-container build (go:embed SPA into the API binary, root Dockerfile) + Cloud Run/Neon guide (demo.md) — verified end-to-end against Neon
  • T-85 Tests for internal/webui: asset caching, missing-asset 404, SPA fallback, non-GET rejection (T-84 shipped without them)

M9 — Physical dimensions

Replaces the small | medium | large enum with real millimetres on both sides of the fit test, and gives the agent standard box templates. Full design: spec.

  • T-90 Migration 0002 (expand): locker_types + package_templates with 4 types and 7 templates as reference data (sorted-dimension and volume generated columns); nullable lockers.locker_type_id and packages.length_mm/width_mm/height_mm/template_id, both backfilled from the old enum; make gen to pick the new columns up in the sqlc models. Queries wait for T-92, which has the first consumer. Additive on purpose — size is read by six queries, the seeder, two services, the handlers, the spec and the SPA, so dropping it here would leave the tree uncompilable until T-94 (FR-2, FR-15)

  • T-91 Domain: Dimensions, ClearanceMM = 20, FitsIn as a sorted-triple comparison (any of 6 orientations), SelectLocker ordering by interior volume then label — table-driven tests incl. the exact clearance boundary and entry-order invariance. The size-based FitsIn/SelectLocker go here, since nothing outside the domain called them; Size/Rank/ParseSize/ErrInvalidSize survive in size.go until T-94, because fifteen call sites and two NOT NULL columns still speak the enum — the same reason 000002 was additive (FR-5)

  • T-92 sqlc: catalog CRUD queries, ClaimSmallestFittingLocker with FOR UPDATE OF l SKIP LOCKED, fit-preview reads; locker reads join locker_types so app.Locker carries an interior and Locker.ForAllocation is back; the locker writes derive locker_type_id from the size class with 000002's own mapping, so the enum path keeps working while every new row gets a type (T-93 turns that around); Go↔SQL cross-check rewritten against dimensions, retiring T-91's canonical-interior bridge; both halves of the L4 proof moved onto a single-type grid, plus TestClaimDoesNotLockTheLockerTypeRow — the race test only catches a plain FOR UPDATE about three runs in four, so the deterministic proof holds three transactions open instead (FR-12)

  • T-93 Services: StorePackage takes template_id or dimensions — never both, never neither — resolves the template inside the transaction that claims on it, and freezes the three numbers onto the package, so correcting a template next month cannot rewrite what a package stored last month was measured at; FitPreview(station, dims) answers {fits, available_count, smallest_type, largest_free_type} holding no locks, since the binding decision stays with the claim query; locker-type and template CRUD on AdminService, deletion restricted by the foreign key (→ ErrNotEmpty) with retiring as the way out for a template that has history. Editing stays unrestricted on purpose — dimensions are copied at store time, so a correction cannot reach history (US-A7). Two transitional pieces earn their keep until T-94, both in size_bridge.go so removing them is a deletion: DimensionsForSize, which the handler calls because the wire contract still carries a size class, and sizeForDimensions, which fills the still-NOT NULL packages.size from the package rather than from the cell it landed in — a small parcel in a Large cell is still small. The locker writes keep deriving the type from the size (T-92's direction): turning that around needs a size→type lookup at the HTTP boundary that T-94 would delete the same day, so it moves there, and the seed's Tower XL pair with it (FR-5, FR-7, FR-15, FR-16)

  • T-94 Migration 0003 (contract) sets the four columns NOT NULL, drops both size columns, drops lockers_allocation_idx and DROP TYPE locker_size; its down half re-derives each enum value from millimetres, which cannot be lossless and says so. openapi.yaml drops Size and gains Dimensions, LockerType, PackageTemplate, FitPreview, the eight catalog endpoints and GET /stations/{id}/fit; Locker.size → locker_type, the locker filter → ?locker_type_id=, and stored/quote/dashboard payloads carry dimensions + nullable template_name. StorePackageRequest keeps template_id and dimensions as plain optional fields rather than a oneOf: a oneOf generates a union type in both clients for a rule the server has to enforce anyway. domain/size.go, ErrInvalidSize and app/size_bridge.go are deleted whole; ErrInvalidDimensions takes the 400 row. Locker writes take a locker_type_id, so the seed grid gains T-01/T-02 and reaches eighteen — which is what makes smallest-fit visibly about litres: a medium parcel spilling out of the Mediums now lands in a 240 L tower, not a 324 L Large. The SPA moves with the contract to keep the tree green — formatDimensions/freeByType replace SIZE_TEXT/SIZE_ORDER, the grid groups by cell type, the locker form picks a type and the store form offers templates or three numbers; T-95..T-97 build the real screens on top (FR-3, FR-15, FR-16)

  • T-95 A Catalogs tab holding both CRUD screens: cell types (create, delete, ordered by interior volume with litres shown) and box templates (create, retire/offer, delete). One tab rather than two, because both are short reference lists an admin reaches for together. Deletion is offered plainly and allowed to fail — the server refuses a referenced row and the screen shows the reason (US-A7). proposeLabel in lib/lockers.ts suggests <type initial>-<next seq> when a cell type is chosen, one past the highest in use so a gap left by a deletion is never silently reused; still overridable, because the label is what a customer reads off the door (US-A2, US-A7). The shared not-empty detail was reworded — it is now raised by four restricted deletes, and "empty it before deleting it" was only true of the first

  • T-96 The store form becomes a grid of template cards — each naming its box and its three numbers, because an agent reads a box off a shelf and a name alone does not say which one they are holding — plus a Custom card that reveals three millimetre inputs. Under it, a live fit preview debounced 250 ms (useDebounced), so typing 400 asks once rather than three times and cannot settle on the answer to 40. It reads "2 lockers fit — smallest is Tower XL, 400 × 400 × 1500 mm", or names the biggest free cell when nothing fits, so the answer to a parcel that does not go in is "bring something this size" rather than "no". A role="status" live region: it changes under an agent who is looking at the measurements. The rule is answered by the server, never recomputed here (US-B3, FR-16)

  • T-97 The kiosk quote names the box the agent picked, or its three numbers when it was measured by hand — something a customer can check against what they are handed; the dashboard column does the same and is headed Package rather than Size. Docs sync: the flowchart's store path now shows the fit preview and the FOR UPDATE OF l claim, its admin path gains the catalogs; the README's smallest-fit table is rewritten in millimetres — including the curtain pole a three-word enum would have called "large" and handed a 900 mm locker — and its L4 section carries the real query. PRD, TDD §8.1 and the ERD were already written for M9 and needed no change. The two E2E scenarios move to T-71: Playwright is not set up until T-70 (M7, not started), so there is no harness to write them against (US-C1, FR-14)

  • T-98 The agent's locker wall comes down and a verdict takes its place. FitPreview now answers with one of four words, judged class-relative against the in-service estate — occupied cells count, out-of-service cells do not exist: perfect_fit when the cell it would claim is the tightest class the station has for the parcel, oversize_cell when every tighter cell is taken (storing stays allowed — the agent is told, never blocked), station_full when a class takes it but no such cell is free, and too_big when nothing here ever will — so a full station is no longer mistakable for a small one. Class-relative on purpose: no slack threshold to tune, and the verdict falls straight out of allocation semantics. Two new reads back it (TightestFittingType, LargestPresentType); LargestAvailableType and the wire's largest_free_type retire in favour of tightest_type + largest_type, and the verdict is computed server-side by fitVerdict — the one place the rule lives (FR-16). The form renders it as a boarding-pass docket (FitPass): mono body carrying package, cell, tightest-axis clearance and free count, a perforated stub stamping the verdict in the status triad, and a decorative barcode that encodes nothing. Clearance on the pass is display arithmetic on two figures the server sent; the judgement never left the server. LockerGrid is deleted with its wall test — the agent never picks a cell, so the wall was scenery, and the estate overview belongs to the admin dashboard (US-B2 rewritten, PRD decisions log + TDD §8.1 + flowchart synced). The app fixture's takeAllLockers now occupies instead of OOSing: the verdict made that distinction load-bearing, and a full station's estate still exists (FR-16, US-B2, US-B3)

  • T-99 Storing gains its confirmation: submit freezes the docket into a two-stage dialog — read back to the customer, Cancel free, the claim fired only by Confirm & store — so the one-time reveal stops arriving unannounced. Nothing is reserved: the pass names a class, never a cell, and smallest-fit re-runs under FOR UPDATE OF l SKIP LOCKED at confirm, so a cell taken while the pass was being read lands the package one size up (the docket's own oversize_cell word) and a station filled meanwhile is the 409 shown inside the dialog with the form intact behind it. The reveal keeps every code-shown-once rule and gains the production note — the code would go to the customer directly (SMS/email); the agent sees it only because the demo has no delivery channel. Focus lands on each stage's heading, never a button: Enter held from submit cannot click through to a claim, nor from confirm through the only showing of the code. Docket moves out of FitPass so the dialog prints the same slip the form does; CodeHandover becomes the reveal stage of StoreDialog. Full design: spec (US-B3, FR-6, FR-7)

M10 — Audit log (FR-17)

  • T-100 Migration 0004: audit_events per ERD — bigserial PK (insertion order), event_type, nullable actor_user_id/station_id/locker_id/package_id FKs all ON DELETE SET NULL, details jsonb, occurred_at; indexes (occurred_at), (event_type, occurred_at), (station_id, occurred_at); sqlc insert + filtered/paginated list queries (US-G1) — event_type is text, not an enum: a new recorded action must not need a migration, and nothing branches on it in SQL. The list orders occurred_at DESC, id DESC — the bigserial breaks same-tick ties so the feed never flaps between page loads — with every filter optional (sqlc.narg) and both time bounds inclusive, since they arrive as day boundaries; CountAuditEvents answers "how many pages" under the same filters. Integration tests pin newest-first, each filter, the tie-break, and that an event outlives all four entities it references (delete package → locker → station → user; the row survives with nulled refs and details still naming the label). T-101 owns the recorder; the queries deliberately expose no update or delete
  • T-101 Recorder: InsertAuditEvent on Repo; every mutating service method writes its event inside its own transaction — store, failed/blocked pickup attempt, confirm/retrieve, station/locker/catalog CRUD, pricing change — actor from the session, never client input; details snapshots labels/names and old → new values. Integration tests: induced failure after the mutation → no event; committed mutation → exactly one; no pickup code ever lands in details (US-G1) — the actor rides the context: app.WithActor is stamped in one place, the session guard, so a request body can never nominate its own author and kiosk events carry a null actor by construction. recordEvent marshals the details and fails the use case if the log write fails — an action that cannot be recorded does not happen. Edits record changed-fields-only as {"old","new"} pairs (changes.add); deletions reference nothing and carry the name in details, since the row is gone. An attempt against an unknown label is still recorded (a probe sweep is what the trail is for) but an attempt naming a nonexistent station is not — the event's station FK has nowhere to point, which the uniformity test caught as a leaked audit_events_station_id_fkey. Quote success stays uneventful on purpose: US-G1 enumerates actions an operator answers for, and a priced offer is not yet one
  • T-102 GET /admin/audit-events in openapi.yaml: admin-only, newest first, paginated, filters event_type/station_id/from/to (TDD §8.1); handler over the list query; API tests: role gate, each filter, page bounds (US-G2) — the response is a page object {events, total, limit, offset}: total under the same filters in the same transaction as the page, so the two cannot disagree about a feed being appended to. Limit defaults to 50, caps at 200, negatives and oversizes answer 400 from the service (ErrInvalidInput) rather than trusting spec validation alone; past-the-end is an empty page, not an error. Landing the endpoint tripped both drift-detectors by design — the §8.1 contract table test and the guard-map test each gained their listAuditEvents: admin row
  • T-103 Admin Activity screen: reverse-chronological table, filter bar (event type, station, date range), pagination; Vitest: row rendering per event shape, filter wiring (US-G2) — a fifth admin tab. Every row renders from the event's details snapshot alone (lib/audit.ts: eventLabel + summarize), never a lookup, because the snapshot is the one part that still means something after its entities are deleted; edits read as field old → new, money through formatMoney, and an unrecorded event type still renders humanized rather than blank — the log may be ahead of the screen. The event stamp is toned: failed/blocked attempts in signal, stores/retrievals strong, admin housekeeping quiet. The By column says operator/kiosk (uuid in the tooltip) — the wire carries no username and a uuid is not a name. Changing any filter resets to page one; paging keeps the previous page on screen (placeholderData) so the feed advances instead of blinking. Date bounds go up as T00:00:00Z/T23:59:59Z against the server's inclusive bounds. Verified in the running stack via Playwright: store → wrong code → retrieve → all five event shapes on screen, filter narrows to one

Traceability

Challenge level Covered by
L1 storage & allocation T-20, T-31, T-32, T-44, T-62 · dimensions: T-90..T-93, T-96 · fit verdict: T-98 · confirm gate: T-99
L2 retrieval T-33, T-34, T-44, T-63
L3 charges T-21, T-23, T-33, T-34, T-64 (pricing editor)
L4 concurrency (optional — included) T-10 (backstop), T-31, T-50..T-52 · preserved across M9 by T-92
Quality bar (PRD §8) T-45, T-52, T-65, T-70..T-72, T-80..T-85