54 Commits

Author SHA1 Message Date
julian 4a02e5fed3 docs(wiki): site device installation — the field runbook for what to know before the booth
Build & push images / images (push) Successful in 2m50s
Per device (Dingtian relay board, DT-008 readers, Hikvision G3H camera, radar,
K200L / Rongta / Cashino printers): factory address and login, the tool needed,
what the wizard configures by itself on assign vs what is done by hand on the
device, how to verify, and the traps already paid for (unauthenticated Dingtian
CGI + session_en brick, duplicate reader IP, reader settings lost on reset, sub
stream mandatory, Alarm Server after assign + Vehicle target filter, close the
camera web UI, radar idle level, "Test" only probes a printer). Bring list, the
10.0.10.x address plan beside each factory address, the on-site order of work,
and the gaps still unrecorded. Linked from appliance-provisioning and the K200L
page; log entries for the day.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 12:34:59 +02:00
julian 552d87d75b feat(devices): K200L printer driver — live cover/paper status from the J-Speed LAN board
The park-buzi printer is a K200L (Xprinter/ICS XP-K200L; its LAN board and USB
descriptor call it "POS-80"). Its board serves the Rongta's five-row status table
under /prt_status.htm — but the reply carries no HTTP status line or headers, which
node:http rejects, so the Rongta driver could never read it and the unit was filed
in July as "no status page → generic driver" (reachability only).

New `k200l` driver (printer-k200l.ts): prints through the generic ESC/POS device
(same bytes, TCP 9100 or usblp) and reads the page over a raw socket, tolerant of
both the headerless and a proper HTTP reply. Mapping mirrors the Rongta: board
unreachable → offline; page not understood → degraded, never ready; any fault →
degraded naming it; USB → reachability floor. The Rongta driver is untouched.
Tests replay the captured headerless page (devices suite 76). Live against the
lab unit: ready; with the cover open the board reports cover open, paper out,
off-line.

Wiki: new k200l-printer entity (names, network setup from factory
192.168.123.100, board quirks, status page, what it means for park-buzi — over
USB the app never saw cover/paper state at all), cross-links on the Rongta,
status-monitoring, USB-transport and WSL-networking pages (parking-net pinned to
eth1 while the LAN NIC is eth0), index.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 12:34:59 +02:00
julian 7e21cf057e chore(komodo): park-lab stack for the printer bench; wiki: cover-open bug bench result
Build & push images / images (push) Successful in 3m3s
park-lab re-added to resources.toml for the reproduction box: copied from park-2 then
corrected — the copy carried park-2's review outbox (collector URL, booth-2 id, booth-2's
token), which would have fed the training pool under a booth's identity; removed, the
outbox is off on the bench. Pinned to the booth's stage-2d9bb15, comments say what the
lab is for.

Wiki, printer-usb-transport: the failing printer identified (USB 1fc9:2016 "POS-80", NXP
controller, no brand in the descriptor); attached to WSL via usbipd and cover-cycled — no
disconnect, no re-enumeration, so the stale /dev/usb bind-mount hypothesis is falsified
for this unit; the next discriminator is the monitor's offline detail text on park-buzi
(EBUSY / open timeout / EIO). WSL caveat: Microsoft's kernel lacks CONFIG_USB_PRINTER.
fleet-deployment-komodo: park-lab row updated. Log: both entries of the day.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 10:19:18 +02:00
julian 4fd175e0e4 docs(wiki): subscription recurring billing + party ledger — design only
Subscription page: the one-window model can't express prepaid/postpaid, calendar or
anniversary anchoring, grace or an expiry notice; and renewal is OFF-BOOK today (a PUT
that appends no payment — the same hole closed for the first sale on 2026-06-20).
Designed: plan billing rule, per-day pricing so both anchors share one formula, open-
ended agreement, subscription_periods where each period is a ledger charge and a renewal
= paying the next period, one subscriptionAccess() gate function, expiry notice derived
not stored.

New decision page party-ledger: a counterparty sub-ledger for who-owes-whom across
modules — parties + signed charge / settlement / write_off events, balance derived never
stored, aging + statements + CSV; lands postpaid subscriptions, hotel guest-nights,
fleet washes on account, supplier/utility bills. Sub-ledger only: no bookkeeping, a
statement is not a fiscal invoice, parties per appliance. validation-sponsorship's
sponsor table marked superseded; open-questions #17; index.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 10:19:09 +02:00
julian 535244209a docs(wiki): seed-admin on a booth — container-name pattern, prompting form, idempotence, re-seed after a reset
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 15:06:19 +02:00
julian 0845e87ddd bump(resources): park-2 and wash-collector to stage-2d9bb15
Build & push images / images (push) Successful in 2m48s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:57:47 +02:00
julian 2d9bb15d4c fix(db): reset-db categorises the Car Wash tables and role_jobs — the drift guard refused every reset on a booth carrying them
Build & push images / images (push) Successful in 2m47s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:37:49 +02:00
julian 29594f8bad chore(desktop): bump version to 0.2.0 — first feature release of the bundle (Car Wash, tills, roles jobs, vision category, review outbox)
Build desktop / desktop (push) Successful in 5m19s
CI / check (push) Successful in 55s
Build & push images / images (push) Successful in 2m51s
Release desktop / bundle (push) Successful in 5m21s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:34:33 +02:00
julian 4ff31557a8 feat(trainer): training from the collector UI — the trainer becomes a job service, the review page gains a Training section
Trainer: `parking-trainer serve` — a stdlib HTTP job API on the compose network (never
published): /health, /readiness, /versions, /versions/<v>/report, /jobs. One job at a
time; each job runs the CLI as a subprocess with its output captured, state + log
persisted under /out/jobs/ so a restart keeps history. `publish` takes its URL from
TRAINER_PUBLISH_URL. Dockerfile: CMD serve, EXPOSE 8091, healthcheck.

Collector: COLLECTOR_TRAINER_URL + /api/training/{status,jobs,jobs/:id,versions/:v/report}
— a reviewer-gated proxy that forwards a fixed set of paths and whitelisted knobs and
passes the trainer's status codes through (409 while a job runs; 503 unconfigured, 502
unreachable). /review gains the Training section: labels per class vs the minimum with
Train disabled until two classes clear it, mode / backbone / floor, the running job's
live log, the versions with Report / Evaluate / Publish (publish confirms), and the
reminder that pinning stays a git commit. Fixed on the way: an apostrophe in the page's
inline script broke the whole page — a test now parses the script.

Compose: `trainer` is a service (restart: unless-stopped, read-only data volume, its own
trainer-out volume), the `train` profile and TRAINER_OUT are gone; the Docker-socket
route was rejected (root on the host for a service booths upload to). Verified with both
images running together: a Train started through the proxy finished, version and report
came back, the page rendered.

Wiki: bodytype-classifier-training (loop, running it, operating notes superseded),
vision-review-outbox, fleet-deployment-komodo, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:34:13 +02:00
julian 3e77a4ad7c docs(wiki): trainer as a compose profile — one running service is expected, first-run pull/login, why the collector TAG bump mattered, park-2 needs no bump until a pin
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 11:30:28 +02:00
julian 3f16925fe0 bump(resources): update stage tag to stage-f7a262a in resources.toml
Build & push images / images (push) Successful in 2m59s
2026-09-07 11:26:28 +02:00
julian f7a262ac9a feat(trainer): phase-B body-type classifier — trainer job on the collector host + the classifier stage on the booth
Build & push images / images (push) Successful in 6m31s
apps/trainer (parking-trainer): inspect / train / evaluate / publish. Reads the wash
collector's SQLite + crops read-only off its volume; time split (validation = newest
slice); thin classes dropped; damped class weights; `features` mode (frozen ImageNet
backbone, on-disk feature cache, seconds to retrain) and `finetune` mode (light
augmentation). CPU-only torch from PyTorch's wheel index. ONNX export checked against
the torch model; NO model file below the validation floor (exit 3, report still written);
exit 2 = not enough labels. `evaluate` scores a shipped model on labels reviewed after
training + the unlabelled pile; `publish` PUTs a version folder to a Gitea generic package.
Light core deps; the `train` extra is heavy — CI syncs without it, torch tests skip.

apps/vision: BodyTypeClassifier (bodytype.onnx + sidecar = the preprocessing contract:
crop margin, input size, RGB 0-255, normalisation inside the graph) and
RefinedVehicleDetector over YOLOX — refines only `car` or a class the classifier trained
on, min-confidence, `detector_class` on the result; path set but no file = phase B off
without an error; a broken file is a health detail. models/bodytype.version (tracked,
empty) pins the published version the Dockerfile fetches at build (BuildKit secret;
a pin that cannot be fetched fails the build). Verified: a trainer model gives identical
probabilities inside the vision service; both images built and smoke-tested.

Delivery: parking-trainer image in build-images.yml, the `trainer` compose profile on the
collector stack (CPU, read-only data, TRAINER_OUT), commented TRAINER_OUT/PUBLISH_TOKEN in
the wash-collector stack, .dockerignore for both Python contexts, trainer deps synced in CI.

Wiki: bodytype-classifier-training rewritten as built (+ one fleet model not per site,
secrets/access, where the crops live), opencv-anpr-service §Phase B, vision-review-outbox,
vision-service-packaging, fleet-deployment-komodo, index, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 11:14:50 +02:00
julian f9cb973fe9 docs(wiki): collector live on park-2; secrets shape, DNS vs bind, token format, every-entry sampling, CI extra rule
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 10:01:39 +02:00
julian 1a0fe59488 docs(wiki): phase B training path and hardware decisions — recorded, not built
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 10:00:18 +02:00
julian 8bfc29db2a bump(resources): update stage tag to stage-dbbb051 in resources.toml for booth park-2 and wash-collector.
Build & push images / images (push) Successful in 3m37s
2026-09-07 09:40:29 +02:00
julian dbbb051ebd feat(carwash): entry-stream sampling for the review outbox; park-2 wired to the collector
Build & push images / images (push) Successful in 4m22s
The wash stream is small; the entry camera photographs every car in exactly the view the
classifier is trained on. The booth can now queue entry vehicle reads as pure training
material — crop + the camera's class, no order, no operator, no category.

- Core announces every vehicle read (deviceEvents.emitVehicleRead from snapshot.ts); the
  Car Wash module listens, samples entry reads in-process (sampleEntry: exactly one in N)
  and queues them (enqueueEntry). CARWASH_REVIEW_ENTRY_SAMPLE=N; 1 = every entry (storage
  and bandwidth are not the limit — user); 0/unset = off. Forwarded by compose.
- Packages carry kind: "wash" | "entry". Collector: kind column, entry meta validated
  without the operator fields, review screen shows an entry sample as such, export has a
  kind column, operator agreement computed from wash items only. Setup line shows
  "1 in N entries sampled"; status carries entrySample.
- komodo: park-2's four review lines enabled (collector URL by Netbird DNS name, booth-2,
  the shared per-booth secret, every entry sampled) — the collector is up on the overlay.
- Tests on both sides. Wiki: vision-review-outbox (entry stream + the internet-feed
  assessment), log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 09:38:55 +02:00
julian 3e57af5abc ui(carwash): Setup → Car wash as a two-column grid — the master-data card takes the width
The wrapping flex row capped the left card at max-w-2xl; once each category row grew its
camera-class chips it was squeezed while the right side sat empty (user, park-2). Now the
master data takes the remaining width and the sponsorship card keeps a fixed readable
column; stacks on narrow screens.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 09:38:55 +02:00
julian ef55d1c6a9 fix(resources): bump stage tag to stage-0411b71 in resources.toml both for park-2 and wash-collector stacks.
Build & push images / images (push) Successful in 3m29s
2026-09-07 08:57:13 +02:00
julian 0411b71c2d fix(vision): CI has no numpy — the vehicle-stage tests need it without the alpr extra
Build & push images / images (push) Successful in 4m7s
CI syncs the service with `uv sync --frozen` (no extra), so test_vehicle.py's module-level
numpy import broke collection in ci.yml and both build-images runs. numpy joins the dev
group (the service imports it lazily); the one test that resizes with OpenCV skips when
cv2 is absent. Reproduced locally in a CI-identical env: 13 passed, 1 skipped; ruff + mypy clean.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 08:51:53 +02:00
julian b485e9870b feat(collector): review collector skeleton — apps/collector, its own Komodo stack on the reviewer's host
CI / check (push) Failing after 40s
Build & push images / images (push) Failing after 32s
Build desktop / desktop (push) Successful in 5m24s
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small
Fastify + SQLite service in the monorepo (shares the payload contract and the class
vocabulary via @parking/shared), delivered to art-docker-station by its own stack so
nothing booth-side lands there and nothing of it on a booth.

- POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart
  meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on
  the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row.
- /review + /api/*: the reviewer's screen served by the process (Basic auth, one login):
  one pending crop at a time, operator's pick and camera's pick beside it, one button/key
  per vocabulary class + unusable + skip; stats per booth and per hashed operator
  (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's
  category).
- GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are
  neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host.
- Booth payload now carries operatorCategory.classes so the comparison needs no site setup.
- Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml
  (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build
  step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one
  secret per booth referenced from both the collector's token list and the booth's own
  stack (park-2 lines templated, commented, DNS name for the URL).
- Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image
  built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 08:26:22 +02:00
julian ec44547122 docs(wiki): close the session's loose threads — bay printer question, vocabulary-in-code decision, training note, guard family, OQ #16 settled
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 22:37:57 +02:00
julian e67f0ccef0 feat(carwash): review outbox, booth side — plate-blurred vehicle crop + the operator's choice, queued for a trusted remote reviewer
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash
order with a vehicle read queues a package for a trusted reviewer over the private overlay
(Netbird); the verdict becomes the phase-B training label and the per-operator error rate.
wiki/concepts/vision-review-outbox.md.

- Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and
  plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a
  downscaled copy); vehicleForIdentity() returns them.
- carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 %
  margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a
  pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no
  bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped);
  400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided
  orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured.
- Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every
  CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close.
- GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash.
- Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in
  .env.example and forwarded by compose.
- Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms,
  queue/drain/backoff/abandon, through the app). Wiki: new concept page, index,
  venue-modules As built, log. The collector is not built.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 22:33:43 +02:00
julian 78ca58d264 ui(carwash): mapping chips show the camera's canonical class ids, not translations
The vocabulary is a code constant (the model's contract), the site only maps it; a chip
reading VETURË beside a site category named Veture blurred exactly that (user, 2026-09-06).
Translation stays as the tooltip; a hint names where the list lives.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 21:25:08 +02:00
julian 20a3cb3e80 feat(vision): vehicle stage, phase A — YOLOX-S (Apache-2.0 ONNX) beside the plate recognizer
Fills /analyze vehicle.body_type + confidence (car / motorcycle / bus / truck from COCO,
mapped to the shared vocabulary) for the Car Wash desk's category suggestion
(venue-modules.md §Vehicle category from vision). Advisory: the operator decides, a
confident downgrade is flagged, nothing is gated on it.

- vision_service/vehicle.py: pure numpy/cv2 letterbox (pad 114, raw BGR), stride-grid
  decode, class-agnostic NMS, one vehicle per frame (the box holding the plate's centre,
  else the largest); YoloxVehicleDetector on onnxruntime CPU, 2 intra-op threads.
- recognizer.py: WithVehicle composes the stage over any plate recognizer (stub included);
  a failing stage yields vehicle=null + a "vehicle: …" note in /health.detail — never
  costs the plate read. model_version reads "<plate>+yolox:yolox_s.onnx@640".
- settings: VISION_VEHICLE_MODEL_PATH (unset = off), _INPUT_SIZE (640), _MIN_CONFIDENCE
  (0.4, the detector's floor; the flag threshold is site config).
- Dockerfile bakes yolox_s.onnx (best-effort curl at build; no network → stage off) and
  sets the path; compose forwards it (empty = off); .env.example documents it.
- Measured on four real dev entry frames (DS-2CD1047G3H, 2560×1440): car at 0.83–0.88 in
  ~240–330 ms; empty lane with a person → none.
- tests/test_vehicle.py: decode/NMS/pick/letterbox on synthetic tensors, the composition,
  and a missing-model /health. Wiki: opencv-anpr-service, venue-modules, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 19:53:11 +02:00
julian 5e1395db18 feat(carwash): advisory vehicle category from the entry camera — mapping, pre-select, downgrade flag
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the
open half (no bundled recognizer emits body_type yet, so the desk shows nothing until
phase A lands in the vision service).

- Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT,
  reason code carwash.categoryDowngrade; settings/order/lookup views carry the read.
- Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node
  client normalises to the vocabulary and drops the rest.
- Record: snapshot.ts stores the read in the plate's device_events row (or its own when
  the plate was unreadable); vehicleForIdentity() resolves it like the plate.
- Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"),
  carwash_config.vision_threshold (signed config_change when it moves), four vision
  columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId.
- Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup
  offers class chips per category and the threshold. Operator decides.
- Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen
  one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on
  the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no
  reason prompt (user, 2026-09-06).

Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 13:37:34 +02:00
julian 50c18405b6 feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix):

- `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from
  (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in
  @parking/shared surface a followed job whose bundle grew past the role in a later
  release; the roles list shows a "behind <job>" badge with a one-click "Update to job"
  (the union, nothing removed) and the editor lints it. Never a runtime union: the grid
  stays the explicit enforcement layer and an update never widens a role without a click.
- Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
  name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes
  now takes the ledger.
- booth-supervisor already carries subscription:*; the stale open note is closed.

Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:52:47 +02:00
julian e14e31a840 feat(tills): per-till activity log, wash bucket on the booth Z-report, wash-desk printer role
Closes the three known follow-ups of the Tills decision (venue-modules.md):

- Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events
  by payload till, other events by their owning module's till, everything else booth),
  applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today"
  panel and the booth feed (history + live pushes). The events route admits a role that
  holds a module feed permission without event:read and returns only that module's
  event types — the live-socket rule.
- Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by
  module; the ticket bucket excludes them (Bileta = parking only); printed
  "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:".
- Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling
  back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the
  one reading of the role field (the entry/booth loaders treated any non-booth role as
  an entry dispenser). Footer label "at wash desk".

Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new
requireAnyPermission) — the Wash operator job could not load the desk's category and
service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules,
log) updated.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:37:26 +02:00
julian ea304bbfd1 chore(resources): update TAG to stage-2aa1045 for deployment
Build desktop / desktop (push) Successful in 4m44s
Build & push images / images (push) Successful in 2m54s
CI / check (push) Successful in 45s
2026-09-06 10:51:05 +02:00
julian 2aa1045ddc fix(modules): Car Wash depends on parking only — the discount engine is core, not the validation module
Build & push images / images (push) Successful in 2m51s
A site entitled to parking,carwash had the wash silently dropped as dependency-broken.
The validation program routes (compose/read) leave the validation module gate; the
merchant scan routes (mine/lookup/apply/void) stay behind it.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 10:40:01 +02:00
julian acde3bba5b feat(resources): entitle park-2 to only have the Booth and the Car Wash module. Not the Merchant Validations.
Build & push images / images (push) Successful in 2m51s
2026-09-06 10:33:48 +02:00
julian 9a13528611 fix(deploy): forward MODULES_ENTITLED into the server container (default parking,validation)
Build & push images / images (push) Successful in 3m19s
The Komodo stack env alone is compose interpolation input; only variables in the
service's environment: block reach the container. Without it every booth on 55d6242
had Car Wash entitled (unset = every module).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 10:29:36 +02:00
julian 6f88026d3e testing(resources): remove carwash and merchan validations modules from park-2 stack.
Build & push images / images (push) Successful in 3m19s
2026-09-06 10:18:08 +02:00
julian c481c1e788 chore(resources): entitle park-2 to the Car Wash module
Build & push images / images (push) Successful in 2m52s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 14:54:40 +02:00
julian 3a7c3fae11 bump(resources park-2): bump TAG to stage-55d6242
Build & push images / images (push) Successful in 2m53s
2026-09-05 14:52:41 +02:00
julian 55d6242c7d feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
CI / check (push) Successful in 46s
Build & push images / images (push) Successful in 2m58s
Build desktop / desktop (push) Successful in 4m53s
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix",
open-questions #16) — the grid stays the enforcement layer:

- Move 1: each desk's money is guarded by that desk's own permissions. Manifest
  tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create
  (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes
  resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot
  touch the booth by construction. Replaces the session:read borrowing (tillPermission).
  /api/shift/tills lists the role's readable tills with canWork; history/movements
  without a till filter return the union of readable tills.
- Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor,
  merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and
  "partial job" lints (warnings, never blocks).
- Move 3: the live WebSocket admits any watch permission (event/session/device read or
  a module's feedPermission) and filters every push per role; report:read is the
  reports screen only.

Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves
the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's
role applies on the next request and a deleted user's session ends with 401.

Tests: till guards + look-only role, feed rules, every job's permissions exist, role
reassignment without re-login. 353/353.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 14:45:48 +02:00
julian a9ccf9e20c feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
  (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
  Finished list). Orders freeze names + price; their life is signed (carwash_order,
  carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
  config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
  chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
  signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
  price off the fee (floored at 0), resolved at done and anchored at the order's intake
  (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
  the wash. Long durations render y/d/h/m.

Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
  re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
  vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
  till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
  open shift with till badges + filter; drawer hub switches tills.

Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.

Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 13:23:09 +02:00
julian 23d6379be8 feat(modules): venue-module registry — entitled ∩ activated, requireModule, Setup panel
Groundwork for the Car Wash pilot (wiki/decisions/venue-modules.md, build-order
steps 1 + 3). No Car Wash code yet; validation is the first module behind the
seam, unchanged in behaviour.

- @parking/shared: MODULE_IDS, ModuleManifest, MODULES (parking required;
  validation dependsOn parking), parseEntitledModules / resolveModuleActivation
  / effectiveModules as pure functions.
- DB: site_config.modules_json (migration 0026, hand-written + journal;
  additive, nullable = everything entitled).
- Server: modules.ts (entitledModules from MODULES_ENTITLED env, activated
  from site_config, effective set, requireModule preHandler → 403
  module_disabled); modules/index.ts registers folder-based modules by
  iterating the registry (modules/validation); site-config GET exposes
  modules/modulesEntitled/modulesActivated, PUT takes the full desired set,
  enforces entitlement + dependency rules (400 with reason) and signs one
  config_change per module that actually flips; /api/auth/me carries the
  effective set; validation routes guarded requireModule → requirePermission.
- Web: lib/modules.ts + modules/{index,validation}; router.tsx spreads
  WEB_MODULES into nav + route tree (validate route no longer named there);
  Setup → Site "Modules" panel (required shown disabled, dependencies as
  hints, server refusal shown verbatim); validation sections + programs fetch
  gated on the module; App invalidates the router whenever the session
  changes (route-context consumers only re-read on navigation — the nav was
  stale after a flip, and after every other setUser too).
- Lavazh validation station retired (STATIONS = ["bar"]; rows untouched).
- Deploy: MODULES_ENTITLED=parking,validation explicit in both booth stacks;
  documented in .env.example.
- Tests: modules.test.ts (7); suite 329/329; web build clean; Playwright
  round-trip on /setup/site verified live.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 11:04:39 +02:00
julian db9c3e0e31 docs(wiki): venue modules design — Car Wash pilot, Parking as a peer module (open decision)
Records the 2026-09-04/05 design sessions on wiki/decisions/venue-modules.md:
manifest-registry module system (folder per module, always-migrated schema,
one ledger union with prefixed event types, relations only via manifest
dependsOn + ledger events), enablement as entitled ∩ activated (vendor-set
Komodo env, site-admin site-config toggle recorded as config_change; server
enforces with requireModule, web only hides; disabling never deletes),
Parking recast as one module on a venue POS/audit core, Car Wash as the
pilot (inside the parking, entry snapshot as identity, bay camera for the
unrecorded-wash anti-fraud signals, v1 scope + build order), and vision
vehicle category as an advisory anomaly flag. Name stays parking-system;
validation stays for the Bar, only the Lavazh station retires with Car Wash.
Open-questions #15, index, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 10:24:20 +02:00
julian d86bffa500 Merge branch 'stage' into dev
Build desktop / desktop (push) Successful in 5m5s
Build & push images / images (push) Successful in 2m58s
CI / check (push) Successful in 48s
Brings dev level with stage: runtime-configurable backend (v0.1.5), WS ticket
auth + desktop log channel (v0.1.6), per-installer latest.json (v0.1.7), TAG
bumps, and the admin-only update decision.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 18:11:42 +02:00
julian 9c05f86c86 docs(desktop): updates are admin-only — keep the polkit prompt; AppImage rejected on field evidence
Decision (user, 2026-09-04) after the first successful self-update
(v0.1.6 → v0.1.7): a .deb update runs pkexec dpkg -i and asks for an admin
password the operator does not have — that prompt is the intended gate.
The AppImage was tried as the no-root path and aborts on the 26.04 booth
(bundled 24.04 glib/WebKitGTK vs host gvfs/Mesa: EGL_BAD_PARAMETER), and it
discards the distro-maintained WebKitGTK the platform decision rests on.
Passwordless polkit for dpkg is root for the operator — rejected.

- update.prompt (en + sq) now says the install needs the administrator
  password.
- desktop-shell-tauri.md: decision, evidence, rejected alternatives, and the
  deferred fleet-grade option (root systemd timer in the .deb, minisign-
  verified, notify-only in-app).
- standing-decisions.md: ship the .deb; runtime backend; updates admin-only.
- appliance-provisioning.md: drop the stale "hardcoded to localhost" note.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 18:11:41 +02:00
julian 54e691a4c9 fix(release): latest.json entry per installer type — .deb booths could never self-update
Release desktop / bundle (push) Successful in 5m26s
tauri-plugin-updater resolves the download target as {os}-{arch}-{installer}
first (linux-x86_64-deb — the bundler stamps the installer type into the
binary, verified with `strings` on a local .deb) and only then bare
linux-x86_64. Our manifest carried only the bare key, pointing at the
AppImage. A .deb install therefore downloaded the AppImage, verified its
signature, then failed install_deb()'s is_deb check with
InvalidUpdaterFormat — after the download, before any relaunch. This, not
version drift or swallowed errors, is why v0.1.0→v0.1.6 never self-updated.

latest.json now carries linux-x86_64-deb, linux-x86_64-rpm (when built) and
linux-x86_64 (AppImage), each with its own .sig. A .deb update ends in a
polkit password prompt (pkexec dpkg -i) — the intended admin gate on a
root-installed package. README + wiki updated; wiki also records the v0.1.6
LIVE field verification.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 15:28:08 +02:00
julian 52862db8ad chore(resources): bump stage TAG to 8fa66c9
Build & push images / images (push) Successful in 2m48s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 12:16:16 +02:00
julian 8fa66c9911 fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server
Build & push images / images (push) Successful in 2m51s
Release desktop / bundle (push) Successful in 41m19s
The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's
preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which
tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never
send. Every desktop handshake 401'd and use-live-feed reconnected every 10s
(confirmed in the park-2 server log).

- routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s,
  single-use, in-memory ticket; the WS preHandler accepts it via an
  x-ws-ticket header after the Origin check, then the same report:read
  role check. Browser cookie path unchanged; JWT stays out of JS.
- platform-ws.ts: fetch a ticket before connect, send it with the Origin
  header; connect failures now go through logClient (rate-limited).
- logger.ts: flush read the CSRF token from document.cookie, null on
  desktop, so every desktop POST /api/logs 403'd and was dropped silently —
  no desktop client log had ever reached app_logs. Stash moved to a
  dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts.
- backend-config.ts: ConnectScreen probe uses the unauthenticated /health
  (now also returns app: "parking-system") instead of accepting any 401.
- README: local-AppImage release gate — tauri dev runs at
  http://localhost:5173, not tauri://localhost, so none of these
  origin-dependent bugs reproduce there.
- wiki: new section + log entry; four citation corrections.

Requires the server image with this commit deployed before the new desktop
build connects (the ticket endpoint must exist).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 12:09:30 +02:00
julian 70e1e9939f chore(resources): bump stage TAG to 5c6a21e
Build & push images / images (push) Successful in 2m50s
Promotes park-buzi + park-2 to the runtime-configurable desktop backend
address (ConnectScreen) and the desktop CSRF fix. build-images.yml
confirmed green for this sha before bumping.
2026-09-04 11:27:17 +02:00
julian 5c6a21e2c3 feat(desktop): runtime-configurable backend server address
Build & push images / images (push) Successful in 3m19s
Release desktop / bundle (push) Successful in 4m57s
The desktop shell is one generic .deb/.AppImage distributed via
mca/public_releases, not built per-booth, but the backend origin was baked
in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the
same installer could never point at a different appliance without a
rebuild.

Adds ConnectScreen (shown before Login in Tauri when no backend is saved),
backed by tauri-plugin-store persisting the operator-entered URL across
restarts. CSP's connect-src tightens to 'self' only — all backend traffic
already routes through tauri-plugin-http/websocket, which run Rust-side
and are outside connect-src's reach anyway — and the real access boundary
moves to capabilities/default.json's http:default scope, wildcarded so an
operator-chosen host is actually reachable. Adds a "Change server" control
in Setup (desktop-only) to repoint an already-configured install.

While tracing the desktop auth path for this: tauri-plugin-http's fetch()
runs through Rust's reqwest, which keeps its own cookie jar separate from
the webview, so document.cookie on tauri://localhost never sees the
parking_csrf cookie the server sets (open upstream bug,
tauri-apps/tauri#13045/#11518). This means the desktop app has likely been
silently sending no CSRF header on every mutation since the shell was
first built — pre-existing, independent of this change. Fixed by having
sessionView() (routes/auth.ts) also echo the CSRF value in the login/me
JSON body; the desktop client stashes it in memory and echoes that instead
of reading document.cookie. assertCsrf() itself is untouched.

Verified end-to-end against a real LAN-bound dev server: login returns a
csrfToken matching the cookie, a mutation using the body-sourced token in
X-CSRF-Token succeeds (200), and the same mutation without it still
correctly 403s.
2026-09-04 10:32:03 +02:00
julian 969bf2b191 chore(resources): bump stage TAG to 7d67934
Build & push images / images (push) Successful in 2m49s
Promotes park-buzi + park-2 to the WS_ALLOWED_ORIGINS fix and the desktop
version badge. build-images.yml confirmed green for this sha before bumping.
2026-09-03 18:22:07 +02:00
julian 7d67934a10 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-09-03 17:29:31 +02:00
julian 56904422af feat(desktop): show the installed app's own version in the UI
Build desktop / desktop (push) Successful in 4m47s
Build & push images / images (push) Successful in 2m54s
CI / check (push) Successful in 42s
Nothing displayed which desktop build was actually installed — debugging
a stuck update meant inferring the current version backwards from the
update prompt's target version. Added DesktopVersionBadge (next to the
existing server-side VersionBadge) using @tauri-apps/api's getVersion(),
the real running app version baked in from tauri.conf.json. No-ops in a
browser. Exported inTauri() from origin.ts instead of redefining it again.
2026-09-03 16:31:48 +02:00
julian 8bcdea9e4a Merge remote-tracking branch 'origin/dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-09-03 16:24:44 +02:00
julian 7804285dec fix(desktop): route update-failure logging through logClient, not console
Build desktop / desktop (push) Successful in 4m44s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
console.error/console.warn only forward to the server when the client log
level is debug/trace (default: info) — the earlier error-logging fix never
actually surfaced anything, and a real update failure produced zero logs
anywhere. desktop-updater.ts now calls logClient() directly, unconditionally,
plus download-progress events. Also documents the resource-sync-park-systems
branch misconfig (pointed at dev, Stacks are stage-tier) found while chasing
this — full writeup on fleet-deployment-komodo.md.
2026-09-03 16:23:02 +02:00
julian 4a7029cea6 chore(resources): bump stage TAG to 7317042
Build & push images / images (push) Successful in 2m49s
Promotes park-buzi + park-2 to the just-merged desktop-app fixes (login,
mixed-content routing, WS origin) and the WS_ALLOWED_ORIGINS fix — none of
this was on stage before. Wait for build-images.yml to confirm the image
actually exists before syncing/deploying in Komodo.
2026-09-03 16:04:57 +02:00
julian 7317042e8d fix(desktop): WS live feed offline — native plugin sends no Origin header
Build desktop / desktop (push) Successful in 4m42s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 4m43s
Build & push images / images (push) Successful in 2m46s
Login worked after the mixed-content fix, but the live feed 403'd silently:
tauri-plugin-websocket's connect() runs on Tauri's Rust side, not inside the
webview page, so it never auto-attaches Origin the way a browser WebSocket
would — routes/ws.ts's anti-CSWSH check rejects a missing Origin before
auth. platform-ws.ts now sets Origin: tauri://localhost explicitly.

Also fixes a second, independent gap the above alone wouldn't have caught:
komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in
production despite .env.example documenting it as required for desktop.
Needs a Komodo sync + redeploy to reach a live booth.
2026-09-03 15:35:04 +02:00
julian 439b11d16d fix(desktop): route fetch + WebSocket through native Tauri plugins (mixed-content)
Build desktop / desktop (push) Successful in 4m33s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 5m13s
Fixing VITE_API_BASE got login to build a correct absolute URL, but it still
failed with WebKit's generic "Load failed" — WebKitGTK treats tauri://localhost
as a secure origin, so http://127.0.0.1:3000 (and ws://) from inside it is
blocked as mixed content, a WebKit limitation CSP's connect-src can't override.

Added tauri-plugin-http (genuine fetch() drop-in, wired via a new
platformFetch() in origin.ts, used by api.ts + logger.ts) and
tauri-plugin-websocket (not a drop-in — adapted behind a native-WebSocket-
shaped interface in the new platform-ws.ts so use-live-feed.ts needed no
changes). Both route through Tauri's Rust side instead of the webview's own
fetch/WebSocket. Capabilities scoped to 127.0.0.1:3000/localhost:3000, matching
the existing CSP allowlist.
2026-09-03 14:57:45 +02:00
julian 276b048fa9 fix(desktop): sync tauri.conf.json version to the release tag, stop swallowing install failures
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m48s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m37s
v0.1.1 was tagged but tauri.conf.json's own "version" field (what Tauri
bakes into the bundle filename/internal version) stayed at 0.1.0 — the
signed binary didn't match what latest.json claimed to describe, so every
update download failed signature verification. desktop-updater.ts's single
catch{} swallowed that identically to "offline", so it looked like nothing
happened at all. release.yml now syncs tauri.conf.json's version from the
git tag before building; the updater now logs a real post-accept failure
instead of silently reverting.
2026-09-03 12:24:04 +02:00
174 changed files with 17513 additions and 823 deletions
+37 -1
View File
@@ -1,6 +1,6 @@
name: Build & push images name: Build & push images
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the # Build the SERVER (API + SPA), COLLECTOR (wash review), VISION (ANPR) and TRAINER (phase-B job) container images and push them to the
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage, # house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage,
# main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle). # main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle).
# Mirrors the house pattern (cf. trm/processor build.yml). See # Mirrors the house pattern (cf. trm/processor build.yml). See
@@ -13,6 +13,8 @@ on:
- 'apps/server/**' - 'apps/server/**'
- 'apps/web/**' - 'apps/web/**'
- 'apps/vision/**' - 'apps/vision/**'
- 'apps/collector/**'
- 'apps/trainer/**'
- 'packages/**' - 'packages/**'
- 'package.json' - 'package.json'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
@@ -60,6 +62,11 @@ jobs:
working-directory: apps/vision working-directory: apps/vision
run: uv sync --frozen run: uv sync --frozen
- name: Sync trainer deps
# Light core only — NOT the `train` extra (CPU torch, ~200 MB); the torch tests skip.
working-directory: apps/trainer
run: uv sync --frozen
# Don't publish a broken image — run the same checks as ci.yml first. # Don't publish a broken image — run the same checks as ci.yml first.
- name: Build + lint + test (Turbo) - name: Build + lint + test (Turbo)
run: pnpm turbo run build lint test run: pnpm turbo run build lint test
@@ -100,18 +107,47 @@ jobs:
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max
- name: Build & push COLLECTOR (wash review)
uses: docker/build-push-action@v5
with:
context: .
file: apps/collector/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache,mode=max
- name: Build & push VISION (ANPR) - name: Build & push VISION (ANPR)
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: apps/vision context: apps/vision
file: apps/vision/Dockerfile file: apps/vision/Dockerfile
push: true push: true
# The phase-B body-type classifier is fetched from the Gitea generic package registry
# at build when apps/vision/models/bodytype.version pins a version (empty = none). The
# registry user's credentials double as the fetch auth (BuildKit secret, never a layer).
secrets: |
bodytype_auth=${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_PASSWORD }}
tags: | tags: |
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }} ${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }} ${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache,mode=max cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache,mode=max
- name: Build & push TRAINER (phase-B job)
uses: docker/build-push-action@v5
with:
context: apps/trainer
file: apps/trainer/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/parking-trainer:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-trainer:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-trainer:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-trainer:buildcache,mode=max
# Optional: trigger a Komodo stack redeploy (cf. trm/processor). Enable by setting the # Optional: trigger a Komodo stack redeploy (cf. trm/processor). Enable by setting the
# KOMODO_* secrets; left guarded so it no-ops until the parking stack is wired. # KOMODO_* secrets; left guarded so it no-ops until the parking stack is wired.
- name: Trigger Komodo redeploy - name: Trigger Komodo redeploy
+5
View File
@@ -48,6 +48,11 @@ jobs:
working-directory: apps/vision working-directory: apps/vision
run: uv sync --frozen run: uv sync --frozen
- name: Sync trainer deps
# Same rule: light core only, not the `train` extra (CPU torch); torch tests skip.
working-directory: apps/trainer
run: uv sync --frozen
- name: Build + lint (Turbo) - name: Build + lint (Turbo)
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en # Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
# key fails the build), AND the vision service's ruff lint via uv. # key fails the build), AND the vision service's ruff lint via uv.
+72 -17
View File
@@ -75,6 +75,27 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Sync tauri.conf.json version to the git tag
# tauri.conf.json's own "version" field is what Tauri bakes into the
# bundle filename, the app's internal version, AND the updater's
# "current vs. new" comparison — it is NOT derived from the git tag
# automatically. Hit in v0.1.1: the tag was bumped but this file
# wasn't, so the signed binary + its .sig were still built (and
# named) as 0.1.0 while latest.json (built from TAG below) claimed
# 0.1.1 — the updater found the "update", downloaded a file whose
# signature didn't match what the manifest claimed to sign, and
# silently failed (a separate bug in desktop-updater.ts's error
# handling made this invisible — also fixed). Patch it here so the
# checked-in value is only ever a placeholder for local dev builds;
# a real release's version is always driven by the tag.
run: |
set -e
VERSION="${TAG#v}"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json
grep '"version"' apps/desktop/src-tauri/tauri.conf.json
env:
TAG: ${{ github.ref_name }}
- name: Build + sign desktop bundle - name: Build + sign desktop bundle
env: env:
# Updater signing key (Gitea repo/org secrets). Without these the # Updater signing key (Gitea repo/org secrets). Without these the
@@ -108,8 +129,23 @@ jobs:
# The Tauri updater fetches a manifest describing the newest version, its # The Tauri updater fetches a manifest describing the newest version, its
# notes, and per-target {signature, url}. The URL points at the MIRROR # notes, and per-target {signature, url}. The URL points at the MIRROR
# repo (mca/public_releases) — that's the unauthenticated endpoint field # repo (mca/public_releases) — that's the unauthenticated endpoint field
# appliances actually reach; see the workflow header for why. Adjust the # appliances actually reach; see the workflow header for why.
# platform keys you actually ship. #
# ONE ENTRY PER INSTALLER TYPE — this is what made every in-app update
# v0.1.0→v0.1.6 fail. tauri-plugin-updater looks up
# `{os}-{arch}-{installer}` FIRST (linux-x86_64-deb / -rpm / -appimage,
# from the running app's detected bundle type) and only then the bare
# `linux-x86_64`. The booths run the .deb, and the manifest used to
# carry ONLY `linux-x86_64` → the AppImage. So a .deb install found the
# "update", downloaded the AppImage, verified its signature fine, then
# handed the bytes to install_deb(), which checks they're a .deb
# (infer::archive::is_deb) and bails with InvalidUpdaterFormat — after
# the download, before any relaunch, with the error swallowed client-
# side until v0.1.6. Now each installer gets its own signed asset; the
# bare key stays for an AppImage install. .deb/.rpm updates run
# `pkexec dpkg -i` / `rpm -U`, so the operator sees a polkit password
# prompt — intended: updating a root-installed package IS an admin
# action on this box (see wiki/decisions/desktop-shell-tauri.md).
env: env:
SERVER_URL: ${{ github.server_url }} SERVER_URL: ${{ github.server_url }}
MIRROR_REPO: mca/public_releases MIRROR_REPO: mca/public_releases
@@ -117,22 +153,41 @@ jobs:
run: | run: |
set -e set -e
VERSION="${TAG#v}" VERSION="${TAG#v}"
APPIMAGE=$(cd dist && ls *.AppImage | head -1) ASSET_BASE="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest"
SIG=$(cat "dist/${APPIMAGE}.sig") cat > /tmp/latest.js <<'JS'
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}" const fs = require("fs");
cat > dist/latest.json <<JSON const [version, tag, base] = process.argv.slice(2);
const files = fs.readdirSync("dist");
const pick = (ext) => files.find((f) => f.endsWith(ext));
const entry = (f) => ({
signature: fs.readFileSync(`dist/${f}.sig`, "utf8").trim(),
url: `${base}/${f}`,
});
const deb = pick(".deb"), rpm = pick(".rpm"), appimage = pick(".AppImage");
if (!deb || !appimage) {
console.error(`missing bundle in dist/: deb=${deb} appimage=${appimage}`);
process.exit(1);
}
const platforms = {
"linux-x86_64-deb": entry(deb),
...(rpm ? { "linux-x86_64-rpm": entry(rpm) } : {}),
"linux-x86_64": entry(appimage),
};
fs.writeFileSync(
"dist/latest.json",
JSON.stringify(
{ {
"version": "${VERSION}", version,
"notes": "Parking System ${TAG}", notes: `Parking System ${tag}`,
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
"platforms": { platforms,
"linux-x86_64": { },
"signature": "${SIG}", null,
"url": "${ASSET_URL}" 2,
} ) + "\n",
} );
} JS
JSON node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}"
echo "latest.json:"; cat dist/latest.json echo "latest.json:"; cat dist/latest.json
- name: Create release + upload assets (Gitea API) - name: Create release + upload assets (Gitea API)
+5
View File
@@ -28,3 +28,8 @@ dist/
graphify-out/ graphify-out/
parking.sqlite*.bak-* parking.sqlite*.bak-*
questions.txt questions.txt
# session planning files (planning-with-files skill)
task_plan.md
findings.md
progress.md
+16
View File
@@ -0,0 +1,16 @@
# Car Wash review collector (wiki/concepts/vision-review-outbox.md). Runs on the
# reviewer's host (art-docker-station), reachable by the booths ONLY over the Netbird
# overlay. Deployed by its own Komodo stack (komodo/resources.toml, "wash-collector").
# COLLECTOR_HOST=0.0.0.0 # in Docker the compose file binds the published port to the overlay IP
# COLLECTOR_PORT=8090
# COLLECTOR_DATA_DIR=/data # collector.sqlite + crops/<booth>/<item>.jpg
# One bearer token per booth: "<boothId>:<token>" pairs, comma- or newline-separated. The
# booth id is the pseudonymous CARWASH_REVIEW_BOOTH_ID that booth was deployed with — never
# a site name. Generate tokens with: openssl rand -hex 32
COLLECTOR_BOOTH_TOKENS=booth-7:REPLACE,booth-9:REPLACE
# The reviewer's login for the review screen and the export (HTTP Basic over the overlay).
COLLECTOR_REVIEWER_USER=reviewer
COLLECTOR_REVIEWER_PASS=REPLACE
+48
View File
@@ -0,0 +1,48 @@
# parking-collector — the Car Wash review collector (wiki/concepts/vision-review-outbox.md).
# Built from the monorepo root (context: .) like the server image, so it shares the
# lockfile and @parking/shared. Runs on the REVIEWER's host (not a booth), delivered by
# its own Komodo stack (docker-compose.collector.yml). Data on /data: collector.sqlite +
# crops/<booth>/<item>.jpg — the trainer on the same host reads the crops off that volume.
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++ # node-gyp for better-sqlite3
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/server/package.json apps/server/
COPY apps/web/package.json apps/web/
COPY apps/vision/package.json apps/vision/
COPY apps/collector/package.json apps/collector/
COPY packages/db/package.json packages/db/
COPY packages/devices/package.json packages/devices/
COPY packages/shared/package.json packages/shared/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm fetch
FROM deps AS build
ENV CI=true
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --offline
RUN pnpm turbo run build --filter=@parking/collector
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter=@parking/collector --legacy deploy --prod /deploy
FROM node:22-alpine AS runtime
WORKDIR /app
ARG BUILD_VERSION=""
ENV BUILD_VERSION=$BUILD_VERSION
ENV NODE_ENV=production
RUN apk add --no-cache libstdc++ wget # better-sqlite3 native runtime; wget for the healthcheck
RUN addgroup -S app && adduser -S -G app app
COPY --from=build --chown=app:app /deploy ./
ENV COLLECTOR_DATA_DIR=/data
ENV COLLECTOR_HOST=0.0.0.0
ENV COLLECTOR_PORT=8090
RUN mkdir -p /data && chown app:app /data
VOLUME ["/data"]
USER app
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- "http://localhost:${COLLECTOR_PORT:-8090}/health" >/dev/null 2>&1 || exit 1
CMD ["node", "dist/index.js"]
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@parking/collector",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Car Wash review collector: receives plate-blurred vehicle crops + the operator's category choice from booths over the private overlay, serves the reviewer's screen, exports labels for training. See wiki/concepts/vision-review-outbox.md.",
"scripts": {
"build": "tsc -b",
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
"start": "node --env-file-if-exists=.env dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@fastify/multipart": "^9.2.1",
"@parking/shared": "workspace:*",
"better-sqlite3": "12.10.1",
"fastify": "5.8.5"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
"@types/node": "25.9.3",
"tsx": "4.22.4",
"typescript": "6.0.3",
"vitest": "^4.1.9"
}
}
+153
View File
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildCollector, type CollectorApp } from "./app.js";
import { parseBoothTokens } from "./config.js";
// The collector: one ingest surface (bearer per booth, idempotent), one review surface
// (Basic), one export. Exercised over app.inject with a hand-built multipart body.
let app: CollectorApp;
let dir: string;
const TOKENS = new Map([["booth-7", "0123456789abcdef0123456789abcdef"], ["booth-9", "fedcba9876543210fedcba9876543210"]]);
const REVIEWER = { user: "julian", pass: "review-pass-123" };
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: TOKENS, reviewer: REVIEWER, trainerUrl: null }, { dbFile: ":memory:" });
await app.ready();
});
afterEach(async () => {
await app.close();
await rm(dir, { recursive: true, force: true });
});
/** A minimal JPEG-looking blob (SOI marker + padding) — the collector checks the magic only. */
const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(200, 1)]);
function meta(over: Record<string, unknown> = {}) {
return {
v: 1, booth: "booth-7", item: "item-1", order: "o-1", at: "2026-09-06T10:00:00.000Z", operator: "ab12cd34ef56ab12",
operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan", "hatchback"] }, service: "Standard",
vision: { class: "suv", confidence: 0.91, categoryId: "suv" }, downgraded: true,
image: { width: 320, height: 200, plateBlurred: true },
...over,
};
}
function multipart(fields: Record<string, string>, file: Buffer | null): { body: Buffer; type: string } {
const b = "----collector-test";
const parts: Buffer[] = [];
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
if (file) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="image"; filename="x.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`), file, Buffer.from("\r\n"));
parts.push(Buffer.from(`--${b}--\r\n`));
return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };
}
async function ingest(m: Record<string, unknown>, token = TOKENS.get("booth-7")!, file: Buffer | null = JPEG, extra: Record<string, string> = {}) {
const { body, type } = multipart({ meta: JSON.stringify(m) }, file);
return app.inject({ method: "POST", url: "/ingest", headers: { authorization: `Bearer ${token}`, "content-type": type, ...extra }, payload: body });
}
describe("ingest", () => {
it("stores the crop and the decision under the token's booth; retries are idempotent", async () => {
const r = await ingest(meta());
expect(r.statusCode).toBe(201);
const row = app.collectorDb.get("item-1")!;
expect(row).toMatchObject({ booth: "booth-7", operatorCategoryName: "Vetura", visionClass: "suv", downgraded: 1, plateBlurred: 1, imagePath: "crops/booth-7/item-1.jpg" });
expect(JSON.parse(row.operatorClasses)).toEqual(["car", "sedan", "hatchback"]);
const again = await ingest(meta());
expect(again.statusCode).toBe(200);
expect(again.json()).toEqual({ ok: true, duplicate: true });
expect((await app.inject({ method: "GET", url: "/health" })).json()).toMatchObject({ ok: true, booths: 1, pending: 1 });
});
it("refuses a bad token, a booth mismatch, a non-JPEG, and malformed meta", async () => {
expect((await ingest(meta(), "nope-nope-nope-nope-nope")).statusCode).toBe(401);
expect((await ingest(meta({ booth: "booth-9" }))).statusCode).toBe(422); // token is booth-7's
expect((await ingest(meta(), TOKENS.get("booth-7")!, JPEG, { "x-booth-id": "booth-9" })).statusCode).toBe(403);
expect((await ingest(meta(), TOKENS.get("booth-7")!, Buffer.alloc(300, 7))).statusCode).toBe(415);
expect((await ingest(meta(), TOKENS.get("booth-7")!, null)).statusCode).toBe(400);
expect((await ingest(meta({ vision: { class: "spaceship", confidence: 0.5, categoryId: null } }))).statusCode).toBe(422);
expect((await ingest(meta({ item: "../../etc/passwd" }))).statusCode).toBe(422);
expect((await ingest(meta({ v: 2 }))).statusCode).toBe(422);
expect(app.collectorDb.stats().booths).toEqual([]);
});
});
describe("review + export", () => {
it("the reviewer lists pending items, sees the crop, labels it; stats compare the label with the operator's category; the export lists usable labels only", async () => {
await ingest(meta());
await ingest(meta({ item: "item-2", operator: "ab12cd34ef56ab12", vision: { class: "car", confidence: 0.8, categoryId: "car" }, downgraded: false }));
await ingest(meta({ item: "item-3", booth: "booth-9", operator: "9999999999999999" }), TOKENS.get("booth-9")!);
// No login → 401 with a challenge; nothing without a configured reviewer is tested in config.
const anon = await app.inject({ method: "GET", url: "/api/items" });
expect(anon.statusCode).toBe(401);
expect(anon.headers["www-authenticate"]).toContain("Basic");
expect((await app.inject({ method: "GET", url: "/review", headers: { authorization: basic } })).headers["content-type"]).toContain("text/html");
const list = (await app.inject({ method: "GET", url: "/api/items?status=pending", headers: { authorization: basic } })).json();
expect(list.items.map((i: { id: string }) => i.id)).toEqual(["item-1", "item-2", "item-3"]);
expect(list.items[0].imagePath).toBeUndefined();
const img = await app.inject({ method: "GET", url: "/api/items/item-1/image", headers: { authorization: basic } });
expect(img.statusCode).toBe(200);
expect(img.headers["content-type"]).toBe("image/jpeg");
expect(img.rawPayload.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
// item-1: operator said Vetura (car/sedan/hatchback), reviewer says suv → disagree.
// item-2: reviewer says sedan → inside Vetura → agree. item-3: unusable.
const post = (id: string, label: string) =>
app.inject({ method: "POST", url: `/api/items/${id}/review`, headers: { authorization: basic, "content-type": "application/json" }, payload: { label } });
expect((await post("item-1", "suv")).json()).toMatchObject({ reviewLabel: "suv", reviewer: "julian" });
expect((await post("item-2", "sedan")).statusCode).toBe(200);
expect((await post("item-3", "unusable")).statusCode).toBe(200);
expect((await post("item-3", "spaceship")).statusCode).toBe(400);
expect((await post("nope", "suv")).statusCode).toBe(404);
const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
expect(stats.booths).toEqual([
{ booth: "booth-7", received: 2, pending: 0, reviewed: 2, entries: 0 },
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1, entries: 0 },
]);
expect(stats.operators).toEqual([
{ booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 },
{ booth: "booth-9", operatorRef: "9999999999999999", reviewed: 1, agree: 0, disagree: 0, unusable: 1 },
]);
const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } });
expect(csv.statusCode).toBe(200);
const lines = csv.body.trim().split("\n");
expect(lines[0]).toBe("item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at");
expect(lines).toHaveLength(3); // header + 2 usable labels; the unusable one is left out
expect(lines[1]).toContain('"item-1","booth-7","wash","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"');
// An ENTRY sample: no order, no operator — accepted, reviewable, in the export, and
// never counted in any operator's agreement.
const entry = await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-1", at: "2026-09-06T11:00:00.000Z", vision: { class: "car", confidence: 0.7 }, image: { width: 300, height: 180, plateBlurred: true } });
expect(entry.statusCode).toBe(201);
expect((await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-2", at: "x", vision: { class: "car", confidence: 0.7 }, image: { width: 1, height: 1, plateBlurred: true } })).statusCode).toBe(422);
expect((await post("entry-1", "suv")).statusCode).toBe(200);
const stats2 = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
expect(stats2.booths[0]).toEqual({ booth: "booth-7", received: 3, pending: 0, reviewed: 3, entries: 1 });
expect(stats2.operators.find((o: { booth: string }) => o.booth === "booth-7")).toMatchObject({ reviewed: 2, agree: 1, disagree: 1 });
const csv3 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
expect(csv3).toContain('"entry-1","booth-7","entry","crops/booth-7/entry-1.jpg","suv","","","car"');
// A booth-supplied name that looks like a spreadsheet formula is neutralised in the export.
await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } }));
await post("item-4", "car");
const csv2 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
expect(csv2).toContain(`"'=HYPERLINK(""http://evil"")"`);
});
});
describe("config", () => {
it("parses booth:token pairs and refuses short tokens", () => {
expect([...parseBoothTokens("a:0123456789abcdef, b:fedcba9876543210\nc:0000000000000000").keys()]).toEqual(["a", "b", "c"]);
expect(() => parseBoothTokens("a:short")).toThrow(/too short/);
expect(() => parseBoothTokens("nocolon")).toThrow(/bad pair/);
});
});
+291
View File
@@ -0,0 +1,291 @@
import { timingSafeEqual } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import multipart from "@fastify/multipart";
import { isVehicleClass } from "@parking/shared";
import type { CollectorConfig } from "./config.js";
import { CollectorDb, type ItemRow, type ReviewVerdict } from "./db.js";
import { reviewPage } from "./review-page.js";
// The collector — the far end of the booth's review outbox
// (wiki/concepts/vision-review-outbox.md). Three surfaces and nothing else:
// POST /ingest one package from one booth (bearer token per booth; idempotent)
// /review + /api/* the reviewer's screen (HTTP Basic, one login)
// GET /export/labels.csv the training set: reviewed, usable rows (crops sit beside it on
// the volume, so the trainer on this host reads them directly)
// /api/training/* the Training section: a thin proxy to the trainer's job API on
// the compose network (never published), behind the reviewer login
// It deliberately has no fleet features and no path back into a booth.
/** The package's `meta` part, as the booth sends it (review-outbox.ts). */
interface IngestMeta {
v: number;
/** "wash" (default when absent) = a desk decision; "entry" = a sampled entry read with
* no order and no operator — crop + the camera's class only. */
kind?: "wash" | "entry";
booth: string;
item: string;
order?: string;
at: string;
operator?: string;
operatorCategory?: { id: string; name: string; classes?: string[] };
service?: string;
vision: { class: string; confidence: number; categoryId?: string | null };
downgraded?: boolean;
image: { width: number; height: number; plateBlurred: boolean };
}
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
function str(v: unknown, max = 200): string | null {
return typeof v === "string" && v.length > 0 && v.length <= max ? v : null;
}
/** Validate the meta part; returns a message on the first problem. */
function checkMeta(m: unknown, booth: string): { ok: true; meta: IngestMeta } | { ok: false; why: string } {
if (!m || typeof m !== "object") return { ok: false, why: "meta must be an object" };
const x = m as Record<string, unknown>;
if (x.v !== 1) return { ok: false, why: "unsupported meta version" };
if (x.booth !== booth) return { ok: false, why: "meta.booth does not match the token's booth" };
if (!str(x.item, 64) || !ID_RE.test(x.item as string)) return { ok: false, why: "bad item id" };
if (!str(x.at, 40) || Number.isNaN(Date.parse(x.at as string))) return { ok: false, why: "bad timestamp" };
const kind = x.kind === undefined ? "wash" : x.kind;
if (kind !== "wash" && kind !== "entry") return { ok: false, why: "bad kind" };
const v = x.vision as Record<string, unknown> | undefined;
if (!v || !isVehicleClass(v.class) || typeof v.confidence !== "number" || v.confidence < 0 || v.confidence > 1) return { ok: false, why: "bad vision read" };
if (v.categoryId != null && !str(v.categoryId, 64)) return { ok: false, why: "bad vision.categoryId" };
if (kind === "wash") {
if (!str(x.order, 64)) return { ok: false, why: "bad order ref" };
if (!str(x.operator, 64)) return { ok: false, why: "bad operator ref" };
const oc = x.operatorCategory as Record<string, unknown> | undefined;
if (!oc || !str(oc.id, 64) || !str(oc.name, 120)) return { ok: false, why: "bad operatorCategory" };
if (oc.classes !== undefined && (!Array.isArray(oc.classes) || !oc.classes.every(isVehicleClass))) return { ok: false, why: "bad operatorCategory.classes" };
if (!str(x.service, 120)) return { ok: false, why: "bad service" };
if (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" };
}
const im = x.image as Record<string, unknown> | undefined;
if (!im || typeof im.width !== "number" || typeof im.height !== "number" || typeof im.plateBlurred !== "boolean") return { ok: false, why: "bad image meta" };
return { ok: true, meta: x as unknown as IngestMeta };
}
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
return ba.length === bb.length && timingSafeEqual(ba, bb);
}
export interface CollectorApp extends FastifyInstance {
collectorDb: CollectorDb;
}
export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: string } = {}): Promise<CollectorApp> {
await mkdir(path.join(cfg.dataDir, "crops"), { recursive: true });
const db = new CollectorDb(opts.dbFile ?? path.join(cfg.dataDir, "collector.sqlite"));
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, bodyLimit: 64 * 1024 }) as unknown as CollectorApp;
app.collectorDb = db;
await app.register(multipart, { limits: { fileSize: MAX_IMAGE_BYTES, files: 1, fields: 4, parts: 6 } });
app.addHook("onClose", async () => db.close());
/** Which booth this bearer token belongs to, or null. Constant-time per candidate. */
function boothForToken(req: FastifyRequest): string | null {
const h = req.headers.authorization ?? "";
if (!h.startsWith("Bearer ")) return null;
const token = h.slice(7).trim();
let found: string | null = null;
for (const [booth, t] of cfg.boothTokens) if (safeEqual(token, t)) found = booth;
return found;
}
/** HTTP Basic for the reviewer. */
async function requireReviewer(req: FastifyRequest, reply: FastifyReply): Promise<void> {
if (!cfg.reviewer) return reply.code(503).send({ error: "reviewer login not configured" });
const h = req.headers.authorization ?? "";
if (h.startsWith("Basic ")) {
const [user, ...rest] = Buffer.from(h.slice(6), "base64").toString("utf8").split(":");
const pass = rest.join(":");
if (user && safeEqual(user, cfg.reviewer.user) && safeEqual(pass, cfg.reviewer.pass)) return;
}
return reply.code(401).header("www-authenticate", 'Basic realm="wash review", charset="UTF-8"').send({ error: "unauthorized" });
}
app.get("/health", async () => {
const s = db.stats();
return { ok: true, booths: s.booths.length, pending: s.booths.reduce((n, b) => n + b.pending, 0) };
});
// --- Ingest (booths) -----------------------------------------------------------------
app.post("/ingest", async (req, reply) => {
const booth = boothForToken(req);
if (!booth) return reply.code(401).send({ error: "unauthorized" });
const claimed = req.headers["x-booth-id"];
if (typeof claimed === "string" && claimed !== booth) return reply.code(403).send({ error: "booth id does not match the token" });
if (!req.isMultipart()) return reply.code(415).send({ error: "multipart/form-data expected" });
let metaRaw: string | null = null;
let image: Buffer | null = null;
try {
for await (const part of req.parts()) {
if (part.type === "file" && part.fieldname === "image") {
image = await part.toBuffer();
} else if (part.type === "field" && part.fieldname === "meta") {
metaRaw = String(part.value);
}
}
} catch (err) {
const code = (err as { code?: string }).code;
return reply.code(code === "FST_REQ_FILE_TOO_LARGE" ? 413 : 400).send({ error: (err as Error).message });
}
if (!metaRaw) return reply.code(400).send({ error: "meta part missing" });
if (!image || image.length < 100) return reply.code(400).send({ error: "image part missing" });
if (!(image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff)) return reply.code(415).send({ error: "image must be a JPEG" });
let parsed: unknown;
try {
parsed = JSON.parse(metaRaw);
} catch {
return reply.code(400).send({ error: "meta is not JSON" });
}
const checked = checkMeta(parsed, booth);
if (!checked.ok) return reply.code(422).send({ error: checked.why });
const meta = checked.meta;
// Idempotent on the item id: a booth retrying after a lost 2xx must not duplicate.
if (db.get(meta.item)) return reply.code(200).send({ ok: true, duplicate: true });
const rel = path.posix.join("crops", booth, `${meta.item}.jpg`);
await mkdir(path.join(cfg.dataDir, "crops", booth), { recursive: true });
await writeFile(path.join(cfg.dataDir, rel), image);
const kind = meta.kind ?? "wash";
db.insert({
id: meta.item,
booth,
kind,
orderRef: meta.order ?? "",
at: meta.at,
operatorRef: meta.operator ?? "",
operatorCategoryId: meta.operatorCategory?.id ?? "",
operatorCategoryName: meta.operatorCategory?.name ?? "",
operatorClasses: JSON.stringify(meta.operatorCategory?.classes ?? []),
service: meta.service ?? "",
visionClass: meta.vision.class,
visionConfidence: meta.vision.confidence,
visionCategoryId: meta.vision.categoryId ?? null,
downgraded: meta.downgraded ? 1 : 0,
imageWidth: meta.image.width,
imageHeight: meta.image.height,
plateBlurred: meta.image.plateBlurred ? 1 : 0,
imagePath: rel,
receivedAt: new Date().toISOString(),
});
req.log.info(`ingest: ${booth} ${kind} ${meta.item} (${meta.vision.class}${kind === "wash" ? ` → ${meta.operatorCategory!.name}` : ""})`);
return reply.code(201).send({ ok: true });
});
// --- Review (the trusted person) -----------------------------------------------------
const page = reviewPage();
app.get("/", { preHandler: requireReviewer }, async (_req, reply) => reply.redirect("/review"));
app.get("/review", { preHandler: requireReviewer }, async (_req, reply) => reply.type("text/html; charset=utf-8").send(page));
app.get<{ Querystring: { status?: string; limit?: string; booth?: string } }>(
"/api/items",
{ preHandler: requireReviewer },
async (req) => {
const status = req.query.status === "reviewed" ? "reviewed" : "pending";
const limit = Math.min(Math.max(Number(req.query.limit) || 25, 1), 200);
return { items: db.list(status, limit, req.query.booth || undefined).map(publicItem) };
},
);
app.get<{ Params: { id: string } }>("/api/items/:id/image", { preHandler: requireReviewer }, async (req, reply) => {
const row = db.get(req.params.id);
if (!row) return reply.code(404).send({ error: "not found" });
return reply.type("image/jpeg").header("cache-control", "private, max-age=3600").send(createReadStream(path.join(cfg.dataDir, row.imagePath)));
});
app.post<{ Params: { id: string }; Body: { label?: unknown } }>("/api/items/:id/review", { preHandler: requireReviewer }, async (req, reply) => {
const label = req.body?.label;
if (label !== "unusable" && !isVehicleClass(label)) return reply.code(400).send({ error: "label must be a vehicle class or 'unusable'" });
if (!db.get(req.params.id)) return reply.code(404).send({ error: "not found" });
const row = db.review(req.params.id, label as ReviewVerdict, cfg.reviewer!.user);
return publicItem(row!);
});
app.get("/api/stats", { preHandler: requireReviewer }, async () => db.stats());
// --- Export (the training set) --------------------------------------------------------
app.get("/export/labels.csv", { preHandler: requireReviewer }, async (_req, reply) => {
const rows = db.labelled();
// Quote every cell; a cell starting like a spreadsheet formula (=, +, -, @, tab, CR)
// gets a leading apostrophe — the category/service names are booth-supplied text and
// the reviewer will open this in a spreadsheet (CSV formula injection).
const q = (s: string | number | null) => {
let v = String(s ?? "");
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
return `"${v.replace(/"/g, '""')}"`;
};
const head = "item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at";
const lines = rows.map((r) =>
[r.id, r.booth, r.kind, r.imagePath, r.reviewLabel, r.operatorCategoryName, JSON.parse(r.operatorClasses).join("|"), r.visionClass, r.visionConfidence, r.downgraded, r.at, r.reviewedAt].map(q).join(","),
);
return reply.type("text/csv; charset=utf-8").header("content-disposition", 'attachment; filename="labels.csv"').send([head, ...lines].join("\n") + "\n");
});
// --- Training (proxy to the trainer's job API) ----------------------------------------
// The trainer is a sibling container reading the same volume; it is reachable only on the
// compose network, so the reviewer's login here is the only gate. The proxy forwards a
// fixed set of paths and passes the trainer's status codes through (409 = a job runs).
const trainer = cfg.trainerUrl;
async function viaTrainer(reply: FastifyReply, tpath: string, init?: RequestInit): Promise<unknown> {
if (!trainer) return reply.code(503).send({ error: "trainer not configured" });
let r: Response;
try {
r = await fetch(trainer + tpath, { ...init, signal: AbortSignal.timeout(15_000) });
} catch (err) {
return reply.code(502).send({ error: `trainer unreachable: ${(err as Error).message}` });
}
const ctype = r.headers.get("content-type") ?? "application/json";
return reply.code(r.status).type(ctype).send(Buffer.from(await r.arrayBuffer()));
}
app.get("/api/training/status", { preHandler: requireReviewer }, async (_req, reply) => {
if (!trainer) return { configured: false };
try {
const get = async (p: string) => {
const r = await fetch(trainer + p, { signal: AbortSignal.timeout(15_000) });
if (!r.ok) throw new Error(`${p} → HTTP ${r.status}`);
return r.json() as Promise<Record<string, unknown>>;
};
const [health, readiness, versions, jobs] = await Promise.all([get("/health"), get("/readiness"), get("/versions"), get("/jobs")]);
return { configured: true, reachable: true, health, readiness, versions: versions.versions, jobs: jobs.jobs, current: jobs.current };
} catch (err) {
return reply.code(200).send({ configured: true, reachable: false, error: (err as Error).message });
}
});
app.post<{ Body: Record<string, unknown> }>("/api/training/jobs", { preHandler: requireReviewer }, async (req, reply) => {
const b = req.body && typeof req.body === "object" ? req.body : {};
const kind = b.kind;
if (kind !== "train" && kind !== "evaluate" && kind !== "publish") return reply.code(400).send({ error: "kind must be train, evaluate or publish" });
// Only the knobs the UI offers cross over; the trainer validates their values.
const allowed = ["kind", "mode", "backbone", "minAccuracy", "minPerClass", "epochs", "version"];
const body: Record<string, unknown> = {};
for (const k of allowed) if (b[k] !== undefined) body[k] = b[k];
return viaTrainer(reply, "/jobs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
});
app.get<{ Params: { id: string } }>("/api/training/jobs/:id", { preHandler: requireReviewer }, async (req, reply) => {
if (!ID_RE.test(req.params.id)) return reply.code(400).send({ error: "bad job id" });
return viaTrainer(reply, `/jobs/${encodeURIComponent(req.params.id)}`);
});
app.get<{ Params: { v: string } }>("/api/training/versions/:v/report", { preHandler: requireReviewer }, async (req, reply) => {
if (!ID_RE.test(req.params.v)) return reply.code(400).send({ error: "bad version" });
return viaTrainer(reply, `/versions/${encodeURIComponent(req.params.v)}/report`);
});
return app;
}
/** The row as the review screen sees it (no server paths). */
function publicItem(r: ItemRow): Omit<ItemRow, "imagePath"> {
const { imagePath: _p, ...rest } = r;
return rest;
}
+40
View File
@@ -0,0 +1,40 @@
export interface CollectorConfig {
readonly host: string;
readonly port: number;
readonly dataDir: string;
/** boothId → bearer token. */
readonly boothTokens: ReadonlyMap<string, string>;
/** The single reviewer login; null = review screen and export refuse (503). */
readonly reviewer: { readonly user: string; readonly pass: string } | null;
/** The trainer's job API on the compose network (http://trainer:8091); null = the
* Training section is hidden and /api/training/* answers 503. */
readonly trainerUrl: string | null;
}
/** "booth-7:abc,booth-9:def" (commas, whitespace or newlines between pairs). */
export function parseBoothTokens(raw: string): Map<string, string> {
const out = new Map<string, string>();
for (const pair of raw.split(/[,\s]+/)) {
if (!pair) continue;
const i = pair.indexOf(":");
if (i <= 0) throw new Error(`COLLECTOR_BOOTH_TOKENS: bad pair "${pair}" (want boothId:token)`);
const booth = pair.slice(0, i).trim();
const token = pair.slice(i + 1).trim();
if (!booth || token.length < 16) throw new Error(`COLLECTOR_BOOTH_TOKENS: token for "${booth}" too short (>=16 chars)`);
out.set(booth, token);
}
return out;
}
export function configFromEnv(env: NodeJS.ProcessEnv = process.env): CollectorConfig {
const user = (env.COLLECTOR_REVIEWER_USER ?? "").trim();
const pass = env.COLLECTOR_REVIEWER_PASS ?? "";
return {
host: env.COLLECTOR_HOST ?? "0.0.0.0",
port: Number(env.COLLECTOR_PORT ?? 8090),
dataDir: env.COLLECTOR_DATA_DIR ?? "/data",
boothTokens: parseBoothTokens(env.COLLECTOR_BOOTH_TOKENS ?? ""),
reviewer: user && pass.length >= 8 ? { user, pass } : null,
trainerUrl: (env.COLLECTOR_TRAINER_URL ?? "").trim().replace(/\/+$/, "") || null,
};
}
+186
View File
@@ -0,0 +1,186 @@
import Database from "better-sqlite3";
import type { VehicleClass } from "@parking/shared";
// One table. Each row is one booth decision: what the camera saw, what the operator
// chose, and (once reviewed) what a trusted person says the vehicle is. The crop itself
// lives on disk beside the DB (crops/<booth>/<item>.jpg) so the trainer on the same host
// reads it straight off the volume.
export interface ItemRow {
id: string;
booth: string;
/** "wash" = a desk decision (operator fields set); "entry" = a sampled entry read (pure
* training material: crop + the camera's class, operator fields empty). */
kind: "wash" | "entry";
orderRef: string;
at: string;
operatorRef: string;
operatorCategoryId: string;
operatorCategoryName: string;
/** The vision classes the operator's category covers at that site (its mapping) — what
* lets a reviewer's CLASS be compared with an operator's CATEGORY. JSON array. */
operatorClasses: string;
service: string;
visionClass: string;
visionConfidence: number;
visionCategoryId: string | null;
downgraded: number;
imageWidth: number;
imageHeight: number;
plateBlurred: number;
imagePath: string;
receivedAt: string;
reviewLabel: string | null; // a VehicleClass, or "unusable"
reviewedAt: string | null;
reviewer: string | null;
}
export type ReviewVerdict = VehicleClass | "unusable";
export class CollectorDb {
readonly #db: Database.Database;
constructor(file: string) {
this.#db = new Database(file);
this.#db.pragma("journal_mode = WAL");
this.#db.exec(`
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
booth TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'wash',
order_ref TEXT NOT NULL,
at TEXT NOT NULL,
operator_ref TEXT NOT NULL DEFAULT '',
operator_category_id TEXT NOT NULL DEFAULT '',
operator_category_name TEXT NOT NULL DEFAULT '',
operator_classes TEXT NOT NULL DEFAULT '[]',
service TEXT NOT NULL,
vision_class TEXT NOT NULL,
vision_confidence REAL NOT NULL,
vision_category_id TEXT,
downgraded INTEGER NOT NULL DEFAULT 0,
image_width INTEGER NOT NULL,
image_height INTEGER NOT NULL,
plate_blurred INTEGER NOT NULL,
image_path TEXT NOT NULL,
received_at TEXT NOT NULL,
review_label TEXT,
reviewed_at TEXT,
reviewer TEXT
);
CREATE INDEX IF NOT EXISTS items_pending ON items (reviewed_at, received_at);
CREATE INDEX IF NOT EXISTS items_booth ON items (booth, received_at);
`);
}
close(): void {
this.#db.close();
}
static #map(r: Record<string, unknown>): ItemRow {
return {
id: r.id as string,
booth: r.booth as string,
kind: r.kind === "entry" ? "entry" : "wash",
orderRef: r.order_ref as string,
at: r.at as string,
operatorRef: r.operator_ref as string,
operatorCategoryId: r.operator_category_id as string,
operatorCategoryName: r.operator_category_name as string,
operatorClasses: r.operator_classes as string,
service: r.service as string,
visionClass: r.vision_class as string,
visionConfidence: r.vision_confidence as number,
visionCategoryId: (r.vision_category_id as string | null) ?? null,
downgraded: r.downgraded as number,
imageWidth: r.image_width as number,
imageHeight: r.image_height as number,
plateBlurred: r.plate_blurred as number,
imagePath: r.image_path as string,
receivedAt: r.received_at as string,
reviewLabel: (r.review_label as string | null) ?? null,
reviewedAt: (r.reviewed_at as string | null) ?? null,
reviewer: (r.reviewer as string | null) ?? null,
};
}
get(id: string): ItemRow | null {
const r = this.#db.prepare("SELECT * FROM items WHERE id = ?").get(id) as Record<string, unknown> | undefined;
return r ? CollectorDb.#map(r) : null;
}
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
this.#db
.prepare(
`INSERT INTO items (id, booth, kind, order_ref, at, operator_ref, operator_category_id, operator_category_name,
operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded,
image_width, image_height, plate_blurred, image_path, received_at)
VALUES (@id, @booth, @kind, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
)
.run(row);
}
list(status: "pending" | "reviewed", limit: number, booth?: string): ItemRow[] {
const where = [status === "pending" ? "reviewed_at IS NULL" : "reviewed_at IS NOT NULL"];
const params: unknown[] = [];
if (booth) {
where.push("booth = ?");
params.push(booth);
}
const order = status === "pending" ? "received_at ASC" : "reviewed_at DESC";
const rows = this.#db
.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`)
.all(...params, limit) as Record<string, unknown>[];
return rows.map((r) => CollectorDb.#map(r));
}
review(id: string, label: ReviewVerdict, reviewer: string): ItemRow | null {
this.#db
.prepare("UPDATE items SET review_label = ?, reviewed_at = ?, reviewer = ? WHERE id = ?")
.run(label, new Date().toISOString(), reviewer, id);
return this.get(id);
}
/** Per booth: received / pending / reviewed. Per operator (booth + hash): how often the
* reviewer's class fell inside the operator's chosen category (agree) or outside
* (disagree) — the honest-mistake / fraud rate the outbox exists for. */
stats(): {
booths: { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[];
} {
const booths = this.#db
.prepare(
`SELECT booth, COUNT(*) AS received,
SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed,
SUM(CASE WHEN kind = 'entry' THEN 1 ELSE 0 END) AS entries
FROM items GROUP BY booth ORDER BY booth`,
)
.all() as { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
// Operator agreement is a WASH thing — an entry sample has no operator decision.
const reviewed = this.#db
.prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL AND kind = 'wash'")
.all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[];
const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>();
for (const r of reviewed) {
const key = `${r.booth} ${r.operator_ref}`;
let o = ops.get(key);
if (!o) ops.set(key, (o = { booth: r.booth, operatorRef: r.operator_ref, reviewed: 0, agree: 0, disagree: 0, unusable: 0 }));
o.reviewed += 1;
if (r.review_label === "unusable") o.unusable += 1;
else if ((JSON.parse(r.operator_classes) as string[]).includes(r.review_label)) o.agree += 1;
else o.disagree += 1;
}
return { booths, operators: [...ops.values()].sort((a, b) => b.disagree - a.disagree) };
}
/** Reviewed, usable rows — the training set. */
labelled(): ItemRow[] {
const rows = this.#db
.prepare("SELECT * FROM items WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY reviewed_at")
.all() as Record<string, unknown>[];
return rows.map((r) => CollectorDb.#map(r));
}
}
+16
View File
@@ -0,0 +1,16 @@
import { buildCollector } from "./app.js";
import { configFromEnv } from "./config.js";
const cfg = configFromEnv();
const app = await buildCollector(cfg);
if (cfg.boothTokens.size === 0) app.log.warn("COLLECTOR_BOOTH_TOKENS is empty — no booth can ingest");
if (!cfg.reviewer) app.log.warn("COLLECTOR_REVIEWER_USER/PASS not set — the review screen and export refuse");
app.log.info(`collector: ${cfg.boothTokens.size} booth token(s), data in ${cfg.dataDir}, trainer ${cfg.trainerUrl ?? "not configured"}`);
await app.listen({ host: cfg.host, port: cfg.port });
const stop = async () => {
await app.close();
process.exit(0);
};
process.on("SIGTERM", () => void stop());
process.on("SIGINT", () => void stop());
+234
View File
@@ -0,0 +1,234 @@
import { VEHICLE_CLASSES } from "@parking/shared";
// The reviewer's screen: one pending crop at a time, the operator's pick and the camera's
// pick beside it, one button per vocabulary class + "unusable". Served by the collector
// itself (no build step, no framework) — this is deliberately the whole UI.
export function reviewPage(): string {
const classes = JSON.stringify(VEHICLE_CLASSES);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wash review</title>
<style>
:root { --bg:#111; --panel:#1b1b1b; --text:#e8e8e8; --muted:#9a9a9a; --amber:#e0a030; --green:#4caf50; --red:#e05050; }
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, sans-serif; }
header { display:flex; justify-content:space-between; align-items:center; padding:.6rem 1rem; border-bottom:1px solid #333; }
header b { letter-spacing:.08em; text-transform:uppercase; color:var(--amber); font-size:.75rem; }
main { max-width:960px; margin:0 auto; padding:1rem; display:grid; gap:1rem; }
.card { background:var(--panel); border:1px solid #333; border-radius:6px; padding:1rem; }
img { max-width:100%; max-height:60vh; display:block; margin:0 auto; background:#000; border-radius:4px; }
dl { display:grid; grid-template-columns:max-content 1fr; gap:.2rem .8rem; margin:0; font-variant-numeric:tabular-nums; }
dt { color:var(--muted); }
.buttons { display:flex; flex-wrap:wrap; gap:.4rem; }
button { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.5rem .8rem; font:inherit; cursor:pointer; }
button:hover { border-color:var(--amber); }
button.mono { font-family:ui-monospace, monospace; }
button.hint { border-color:var(--amber); }
button.unusable { color:var(--red); }
button.skip { color:var(--muted); }
.muted { color:var(--muted); }
.warn { color:var(--amber); }
table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; }
td, th { text-align:left; padding:.2rem .5rem; border-bottom:1px solid #2a2a2a; }
th { color:var(--muted); font-weight:normal; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; }
kbd { background:#2a2a2a; border:1px solid #444; border-radius:3px; padding:0 .3rem; font-size:.75rem; }
h2 { font-size:.8rem; letter-spacing:.08em; text-transform:uppercase; color:var(--amber); margin:0 0 .6rem; }
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; }
select, input { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.4rem .5rem; font:inherit; }
input[type=number] { width:5rem; }
label { color:var(--muted); font-size:.8rem; }
pre { background:#0d0d0d; border:1px solid #2a2a2a; border-radius:4px; padding:.6rem; max-height:22rem; overflow:auto; font-size:.75rem; white-space:pre-wrap; margin:.6rem 0 0; }
.ok { color:var(--green); }
.bad { color:var(--red); }
button:disabled { opacity:.45; cursor:not-allowed; }
button.small { padding:.25rem .5rem; font-size:.75rem; }
</style>
</head>
<body>
<header><b>Wash review</b><span id="counts" class="muted"></span></header>
<main>
<section class="card" id="item">
<p class="muted">Loading…</p>
</section>
<section class="card">
<table id="stats"><thead><tr><th>booth</th><th>operator</th><th>reviewed</th><th>agree</th><th>disagree</th><th>unusable</th></tr></thead><tbody></tbody></table>
</section>
<section class="card" id="training" hidden>
<h2>Training</h2>
<div id="tr-body"></div>
</section>
<p class="muted">Keys: <kbd>1</kbd>–<kbd>9</kbd>, <kbd>0</kbd> pick a class in order · <kbd>u</kbd> unusable · <kbd>s</kbd> skip. Skipped items come back after a reload. Your verdict is the training label; the operator's pick is only compared against it.</p>
</main>
<script>
const CLASSES = ${classes};
const skipped = new Set();
let current = null;
async function api(path, init) {
const r = await fetch(path, init);
if (!r.ok) throw new Error(path + ' → HTTP ' + r.status);
return r.json();
}
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
async function loadStats() {
const s = await api('/api/stats');
const pending = s.booths.reduce((n, b) => n + b.pending, 0);
const reviewed = s.booths.reduce((n, b) => n + b.reviewed, 0);
document.getElementById('counts').textContent = pending + ' waiting · ' + reviewed + ' reviewed';
const tb = document.querySelector('#stats tbody');
tb.innerHTML = s.operators.map(o => '<tr><td>' + esc(o.booth) + '</td><td class="mono">' + esc(o.operatorRef) + '</td><td>' + o.reviewed + '</td><td>' + o.agree + '</td><td' + (o.disagree ? ' class="warn"' : '') + '>' + o.disagree + '</td><td>' + o.unusable + '</td></tr>').join('') || '<tr><td colspan="6" class="muted">nothing reviewed yet</td></tr>';
}
async function next() {
const { items } = await api('/api/items?status=pending&limit=25');
current = items.find(i => !skipped.has(i.id)) || null;
const el = document.getElementById('item');
if (!current) { el.innerHTML = '<p class="muted">Nothing waiting for review.</p>'; return; }
const it = current;
const opClasses = JSON.parse(it.operatorClasses || '[]');
el.innerHTML =
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
'<dl style="margin-top:.8rem">' +
(it.kind === 'entry'
? '<dt>sample</dt><dd><span class="muted">entry stream — no wash, no operator decision; label the vehicle</span></dd>'
: '<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>') +
'<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' +
(it.kind === 'entry' ? '<dt>booth</dt><dd class="mono">' + esc(it.booth) + '</dd>' :
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>') +
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
'</dl>' +
'<div class="buttons" style="margin-top:.8rem">' +
CLASSES.map((c, i) => '<button class="mono' + (c === it.visionClass ? ' hint' : '') + '" data-label="' + c + '" title="key ' + ((i + 1) % 10) + '">' + c + '</button>').join('') +
'<button class="unusable" data-label="unusable">unusable</button>' +
'<button class="skip" data-skip="1">skip</button>' +
'</div>';
el.querySelectorAll('button[data-label]').forEach(b => b.addEventListener('click', () => verdict(b.dataset.label)));
el.querySelector('button[data-skip]').addEventListener('click', skip);
}
async function verdict(label) {
if (!current) return;
await api('/api/items/' + encodeURIComponent(current.id) + '/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) });
await Promise.all([next(), loadStats()]);
}
function skip() { if (current) { skipped.add(current.id); next(); } }
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'u') verdict('unusable');
else if (e.key === 's') skip();
else if (/^[0-9]$/.test(e.key)) { const i = e.key === '0' ? 9 : Number(e.key) - 1; if (CLASSES[i]) verdict(CLASSES[i]); }
});
next().catch(e => { document.getElementById('item').innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; });
loadStats().catch(() => {});
// ---- Training: the trainer's job API, proxied by the collector -------------------------
// Readiness (labels per class vs the minimum), one job at a time with a live log, the
// versions a run produced (written or refused) with Report / Evaluate / Publish. Pinning a
// published version into the vision image stays a git commit — that is the deploy control.
let trPoll = null;
let trShownReport = null;
const trDefaults = { mode: 'features', backbone: 'resnet18', minAccuracy: 0.85 };
function pct(x) { return x == null ? '—' : Math.round(x * 100) + ' %'; }
async function training() {
const box = document.getElementById('training');
const el = document.getElementById('tr-body');
let s;
try { s = await api('/api/training/status'); } catch (e) { box.hidden = false; el.innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; return; }
if (!s.configured) { box.hidden = true; return; }
box.hidden = false;
if (!s.reachable) { el.innerHTML = '<p class="warn">trainer not reachable: ' + esc(s.error || '') + '</p>'; schedule(true); return; }
const r = s.readiness, run = r.run || {}, minPer = run.minPerClass || 20;
const byClass = (r.labelled && r.labelled.byClass) || {};
const classes = Object.keys(byClass);
const cur = s.current;
const readyLine = r.ready
? '<span class="ok">enough labels to train</span> — classes this run: ' + esc((run.classes || []).join(', '))
: '<span class="warn">not enough labels yet</span> — a class needs ' + minPer + ' reviewed crops; two classes must clear it';
let html = '<p>' + readyLine + ' <span class="muted">(' + (r.labelled ? r.labelled.total : 0) + ' labelled, ' + (r.missingCrops || 0) + ' missing crop files)</span></p>';
html += '<table><thead><tr><th>class</th><th>reviewed</th><th>train</th><th>val</th><th></th></tr></thead><tbody>' +
(classes.map(c => '<tr><td class="mono">' + esc(c) + '</td><td>' + byClass[c] + '</td><td>' + ((run.train || {})[c] ?? '—') + '</td><td>' + ((run.val || {})[c] ?? '—') + '</td><td class="muted">' + (byClass[c] < minPer ? 'below ' + minPer + ' — dropped' : '') + '</td></tr>').join('') || '<tr><td colspan="5" class="muted">no labels yet — review crops above</td></tr>') +
'</tbody></table>';
const d = Object.assign({}, trDefaults, r.defaults || {});
html += '<div class="row" style="margin-top:.8rem">' +
'<label>mode <select id="tr-mode">' + (r.modes || ['features', 'finetune']).map(m => '<option' + (m === d.mode ? ' selected' : '') + '>' + m + '</option>').join('') + '</select></label>' +
'<label>backbone <select id="tr-backbone">' + (r.backbones || ['resnet18']).map(b => '<option' + (b === d.backbone ? ' selected' : '') + '>' + b + '</option>').join('') + '</select></label>' +
'<label>floor <input id="tr-floor" type="number" min="0" max="1" step="0.01" value="' + d.minAccuracy + '"></label>' +
'<button id="tr-train"' + (r.ready && !cur ? '' : ' disabled') + '>Train</button>' +
(cur ? '<span class="warn">running: ' + esc(cur.kind) + ' ' + esc(cur.id) + '</span>' : '') +
'</div>';
const last = cur || (s.jobs && s.jobs[0]);
if (last) {
const cls = last.status === 'done' ? 'ok' : last.status === 'running' ? 'warn' : 'bad';
html += '<p style="margin:.8rem 0 0"><span class="' + cls + '">' + esc(last.status) + '</span> <span class="mono">' + esc(last.kind) + ' ' + esc(last.id) + '</span> <span class="muted">' + esc(last.startedAt || '') + (last.exitCode != null ? ' · exit ' + last.exitCode : '') + '</span> <button class="small" data-job="' + esc(last.id) + '">log</button></p>' +
'<pre id="tr-log" hidden></pre>';
}
const vs = s.versions || [];
html += '<h2 style="margin-top:1rem">Versions</h2>';
html += vs.length
? '<table><thead><tr><th>version</th><th>model</th><th>accuracy</th><th>classes</th><th>mode</th><th></th></tr></thead><tbody>' +
vs.map(v => '<tr><td class="mono">' + esc(v.version) + '</td><td>' + (v.written ? '<span class="ok">written</span>' : '<span class="bad">refused</span>') + '</td><td>' + pct(v.accuracy) + (v.floor != null ? ' <span class="muted">/ floor ' + pct(v.floor) + '</span>' : '') + '</td><td class="muted">' + esc((v.classes || []).join(', ')) + '</td><td class="muted">' + esc(v.mode || '') + '</td><td>' +
'<button class="small" data-report="' + esc(v.version) + '">report</button> ' +
(v.written ? '<button class="small" data-eval="' + esc(v.version) + '"' + (cur ? ' disabled' : '') + '>evaluate</button> <button class="small" data-publish="' + esc(v.version) + '"' + (cur ? ' disabled' : '') + '>publish</button>' : '') +
'</td></tr>').join('') + '</tbody></table>'
: '<p class="muted">no runs yet</p>';
html += '<pre id="tr-report" hidden></pre>';
html += '<p class="muted" style="margin:.8rem 0 0">A written model is only a file here. To put it on a booth: publish, then pin the version in <span class="mono">apps/vision/models/bodytype.version</span>, commit, and bump the TAG of the booth.</p>';
el.innerHTML = html;
const trainBtn = document.getElementById('tr-train');
if (trainBtn) trainBtn.addEventListener('click', () => startJob({ kind: 'train', mode: document.getElementById('tr-mode').value, backbone: document.getElementById('tr-backbone').value, minAccuracy: Number(document.getElementById('tr-floor').value) }));
el.querySelectorAll('button[data-eval]').forEach(b => b.addEventListener('click', () => startJob({ kind: 'evaluate', version: b.dataset.eval })));
el.querySelectorAll('button[data-publish]').forEach(b => b.addEventListener('click', () => { if (confirm('Publish ' + b.dataset.publish + ' to the package registry?')) startJob({ kind: 'publish', version: b.dataset.publish }); }));
el.querySelectorAll('button[data-job]').forEach(b => b.addEventListener('click', () => showLog(b.dataset.job)));
el.querySelectorAll('button[data-report]').forEach(b => b.addEventListener('click', () => showReport(b.dataset.report)));
if (cur) showLog(cur.id).catch(() => {});
if (trShownReport) showReport(trShownReport).catch(() => {});
schedule(!!cur);
}
function schedule(soon) {
if (trPoll) clearTimeout(trPoll);
trPoll = setTimeout(() => training().catch(() => {}), soon ? 4000 : 60000);
}
async function startJob(body) {
try {
const r = await fetch('/api/training/jobs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) { const e = await r.json().catch(() => ({})); alert('trainer: ' + (e.error || ('HTTP ' + r.status))); }
} catch (e) { alert(e.message); }
training().catch(() => {});
}
async function showLog(id) {
const j = await api('/api/training/jobs/' + encodeURIComponent(id));
const pre = document.getElementById('tr-log');
if (!pre) return;
pre.hidden = false;
pre.textContent = j.log || '(no output yet)';
pre.scrollTop = pre.scrollHeight;
}
async function showReport(v) {
const r = await fetch('/api/training/versions/' + encodeURIComponent(v) + '/report');
const pre = document.getElementById('tr-report');
if (!pre) return;
trShownReport = v;
pre.hidden = false;
pre.textContent = r.ok ? await r.text() : 'no report for ' + v + ' (HTTP ' + r.status + ')';
}
training().catch(() => {});
</script>
</body>
</html>`;
}
+126
View File
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildCollector, type CollectorApp } from "./app.js";
// The Training section's proxy: reviewer-gated, forwards a fixed set of paths to the
// trainer's job API, passes its status codes through, and degrades cleanly when the trainer
// is not configured or not reachable. The trainer is faked with a bare node http server.
const REVIEWER = { user: "julian", pass: "review-pass-123" };
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
let dir: string;
let fake: Server;
let fakeUrl: string;
let seen: { method: string; url: string; body: string }[];
let app: CollectorApp;
async function start(trainerUrl: string | null): Promise<void> {
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: new Map(), reviewer: REVIEWER, trainerUrl }, { dbFile: ":memory:" });
await app.ready();
}
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
seen = [];
fake = createServer((req: IncomingMessage, res: ServerResponse) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
seen.push({ method: req.method ?? "", url: req.url ?? "", body });
const json = (code: number, obj: unknown) => {
res.writeHead(code, { "content-type": "application/json" });
res.end(JSON.stringify(obj));
};
if (req.url === "/health") return json(200, { ok: true, busy: false });
if (req.url === "/readiness") return json(200, { ready: false, labelled: { total: 3 } });
if (req.url === "/versions") return json(200, { versions: [{ version: "v1", written: true }] });
if (req.url === "/jobs" && req.method === "GET") return json(200, { jobs: [{ id: "j1" }], current: null });
if (req.url === "/jobs" && req.method === "POST") return body.includes('"busy"') ? json(409, { error: "a job is already running" }) : json(202, { id: "j2", status: "running" });
if (req.url === "/jobs/j1") return json(200, { id: "j1", status: "done", log: "ok" });
if (req.url === "/versions/v1/report") {
res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
return res.end("# Body-type classifier v1\n");
}
return json(404, { error: "not found" });
});
});
await new Promise<void>((r) => fake.listen(0, "127.0.0.1", r));
const a = fake.address() as { port: number };
fakeUrl = `http://127.0.0.1:${a.port}`;
});
afterEach(async () => {
await app?.close();
await new Promise<void>((r) => fake.close(() => r()));
await rm(dir, { recursive: true, force: true });
});
describe("review page script", () => {
it("parses as JavaScript (an apostrophe in a template literal once broke the whole page)", async () => {
const { reviewPage } = await import("./review-page.js");
const html = reviewPage();
const script = html.slice(html.indexOf("<script>") + 8, html.lastIndexOf("</script>"));
expect(() => new Function(script)).not.toThrow();
});
});
describe("training proxy", () => {
it("is hidden when no trainer is configured", async () => {
await start(null);
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
expect(s.json()).toEqual({ configured: false });
const j = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "train" } });
expect(j.statusCode).toBe(503);
});
it("aggregates status and forwards jobs and reports behind the reviewer login", async () => {
await start(fakeUrl);
expect((await app.inject({ method: "GET", url: "/api/training/status" })).statusCode).toBe(401);
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
expect(s.statusCode).toBe(200);
const body = s.json();
expect(body.configured).toBe(true);
expect(body.reachable).toBe(true);
expect(body.readiness.labelled.total).toBe(3);
expect(body.versions[0].version).toBe("v1");
expect(body.jobs[0].id).toBe("j1");
const j = await app.inject({
method: "POST",
url: "/api/training/jobs",
headers: { authorization: basic },
payload: { kind: "train", mode: "features", minAccuracy: 0.9, secret: "nope", version: "v2" },
});
expect(j.statusCode).toBe(202);
expect(j.json().id).toBe("j2");
const posted = seen.find((r) => r.method === "POST")!;
expect(JSON.parse(posted.body)).toEqual({ kind: "train", mode: "features", minAccuracy: 0.9, version: "v2" }); // unknown keys dropped
const busy = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "evaluate", version: "busy" } });
expect(busy.statusCode).toBe(409); // the trainer's answer passes through
const bad = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "rm-rf" } });
expect(bad.statusCode).toBe(400);
const one = await app.inject({ method: "GET", url: "/api/training/jobs/j1", headers: { authorization: basic } });
expect(one.json().status).toBe("done");
expect((await app.inject({ method: "GET", url: "/api/training/jobs/..%2Fx", headers: { authorization: basic } })).statusCode).toBe(400);
const rep = await app.inject({ method: "GET", url: "/api/training/versions/v1/report", headers: { authorization: basic } });
expect(rep.statusCode).toBe(200);
expect(rep.headers["content-type"]).toContain("text/markdown");
expect(rep.body).toContain("# Body-type classifier v1");
});
it("reports an unreachable trainer without failing the page", async () => {
await start("http://127.0.0.1:9"); // nothing listens on the discard port
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
expect(s.statusCode).toBe(200);
expect(s.json().reachable).toBe(false);
const j = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "train" } });
expect(j.statusCode).toBe(502);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["src/**/*.test.ts"], env: { LOG_LEVEL: "silent" } },
});
+29
View File
@@ -44,6 +44,35 @@ see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The upd
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
committed. committed.
**The manifest carries one entry per installer type** (`linux-x86_64-deb`, `linux-x86_64-rpm`,
and bare `linux-x86_64` for AppImage). The updater picks the entry matching how the running app
was installed — a `.deb` install will only ever accept a signed `.deb`. Booths run the `.deb`,
so an in-app update ends in a **polkit password prompt** (`pkexec dpkg -i`): that is expected,
and it is the right gate — the package lives in `/usr/bin`, root-owned, and the operator is not
supposed to be able to replace it silently. Cancel the prompt and the app keeps running the old
version; the failure is logged to the server's Logs viewer.
## Release gate — run the REAL bundle locally before tagging
`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle
loads it from `tauri://localhost`, a *secure* custom-scheme origin — and every desktop-only bug
found in the field on 2026-09-03/04 (relative-URL DOMException, mixed content, missing WS
`Origin`, the reqwest-vs-webview cookie split, the WS handshake that can't carry the cookie)
depends on that difference. **Dev mode cannot reproduce any of them**, so "works in `tauri dev`"
carries no information about a release. Before pushing a `vX.Y.Z` tag:
1. `pnpm --filter @parking/server dev` (local backend; `.env` must have `COOKIE_SECURE=0` and
`tauri://localhost` in `WS_ALLOWED_ORIGINS`).
2. `pnpm --filter @parking/desktop bundle` and run the produced AppImage from
`src-tauri/target/release/bundle/appimage/` (WSLg is enough).
3. On the ConnectScreen enter `127.0.0.1:3000`, **Test** must say reachable, then **Save**.
4. Log in. The booth header must show **LIVE** (not "JASHTË LINJË") within a few seconds.
5. Perform one mutation (e.g. change your UI language) — it must succeed (proves CSRF).
6. Open Setup → Logs and confirm a `frontend`-sourced row from this desktop session exists
(proves the desktop log channel; historically it was silently 403'd).
Only then tag. If a release still fails in the field, the gap is in this list — fix the list.
## Not here (deliberately) ## Not here (deliberately)
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
+578 -8
View File
@@ -318,6 +318,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -346,10 +363,39 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [ dependencies = [
"percent-encoding",
"time", "time",
"version_check", "version_check",
] ]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -373,7 +419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
"libc", "libc",
@@ -386,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -399,6 +445,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -506,6 +561,18 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]] [[package]]
name = "dbus" name = "dbus"
version = "0.9.11" version = "0.9.11"
@@ -635,6 +702,15 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dom_query" name = "dom_query"
version = "0.27.0" version = "0.27.0"
@@ -721,6 +797,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1034,8 +1119,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1057,8 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1209,6 +1299,25 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1298,6 +1407,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1321,6 +1431,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots 1.0.9",
] ]
[[package]] [[package]]
@@ -1341,9 +1452,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1744,6 +1857,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1759,6 +1878,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.38.0" version = "0.38.0"
@@ -2178,8 +2303,11 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-http",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-store",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-websocket",
] ]
[[package]] [[package]]
@@ -2330,6 +2458,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "precomputed-hash" name = "precomputed-hash"
version = "0.1.1" version = "0.1.1"
@@ -2398,6 +2535,22 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.4" version = "0.39.4"
@@ -2407,6 +2560,62 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -2428,6 +2637,61 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -2503,6 +2767,49 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.9",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -2616,6 +2923,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -2625,7 +2933,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [ dependencies = [
"core-foundation", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"jni 0.22.4", "jni 0.22.4",
"log", "log",
@@ -2663,6 +2971,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -2745,7 +3059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"core-foundation", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
"security-framework-sys", "security-framework-sys",
@@ -2885,6 +3199,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.21.0" version = "3.21.0"
@@ -2948,6 +3274,17 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -2955,7 +3292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest",
] ]
@@ -3137,6 +3474,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
@@ -3157,6 +3505,27 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3178,7 +3547,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2", "block2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
@@ -3268,7 +3637,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -3367,6 +3736,54 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-http"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7241a0c762649be8fba7dd4cc84684d0e409f26b335a978ef4dd5fe78da74ce6"
dependencies = [
"bytes",
"cookie_store",
"data-url",
"http",
"regex",
"reqwest 0.12.28",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"tokio",
"url",
"urlpattern",
]
[[package]] [[package]]
name = "tauri-plugin-process" name = "tauri-plugin-process"
version = "2.3.1" version = "2.3.1"
@@ -3377,6 +3794,22 @@ dependencies = [
"tauri-plugin", "tauri-plugin",
] ]
[[package]]
name = "tauri-plugin-store"
version = "2.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c"
dependencies = [
"dunce",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "tauri-plugin-updater" name = "tauri-plugin-updater"
version = "2.10.1" version = "2.10.1"
@@ -3393,7 +3826,7 @@ dependencies = [
"minisign-verify", "minisign-verify",
"osakit", "osakit",
"percent-encoding", "percent-encoding",
"reqwest", "reqwest 0.13.4",
"rustls", "rustls",
"semver", "semver",
"serde", "serde",
@@ -3410,6 +3843,26 @@ dependencies = [
"zip", "zip",
] ]
[[package]]
name = "tauri-plugin-websocket"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca243c7f0bf935cd81123e07f82188ccb919b19fbfc74518b947eedc4619bbb"
dependencies = [
"futures-util",
"http",
"log",
"rand 0.9.5",
"rustls",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -3639,9 +4092,21 @@ dependencies = [
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.4" version = "0.26.4"
@@ -3652,6 +4117,22 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -3837,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
@@ -3877,6 +4370,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@@ -4141,6 +4652,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.5" version = "0.2.5"
@@ -4206,6 +4727,24 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -4391,6 +4930,17 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -4820,6 +5370,26 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.8" version = "0.1.8"
+15
View File
@@ -22,6 +22,21 @@ serde_json = "1"
# Auto-update: prompt the operator, download a signed update, relaunch. # Auto-update: prompt the operator, download a signed update, relaunch.
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
# HTTP client for the SPA's API/WS calls to the local Fastify server. The window
# runs at tauri://localhost, which WebKitGTK treats as a secure origin — a plain
# http://127.0.0.1:3000 fetch() from inside it is blocked as mixed content (a
# long-standing WebKit limitation, not fixable via CSP). Routing through this
# plugin sends the request via Tauri's Rust side instead of the webview's own
# fetch, sidestepping the browser mixed-content check entirely.
tauri-plugin-http = "2"
# Same mixed-content problem as above, but for the live-feed WebSocket
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
# are separate browser checks, so this needs its own plugin.
tauri-plugin-websocket = "2"
# Persists the operator-configured backend URL (host:port of the Fastify
# server this install talks to) across restarts. Read before any API call —
# see apps/web/src/lib/backend-config.ts.
tauri-plugin-store = "2"
[features] [features]
# Used by `tauri dev`/CLI for hot-reload of the Rust side. # Used by `tauri dev`/CLI for hot-reload of the Rust side.
@@ -6,6 +6,18 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"updater:default", "updater:default",
"process:default" "process:default",
"websocket:default",
"store:default",
{
"identifier": "http:default",
"//": "Backend address is operator-configured at runtime (backend-config.ts) so the exact host:port can't be allow-listed at build time. Wildcarded to any host — the CSP forces ALL backend traffic through this plugin (see tauri.conf.json), so this scope is the real boundary; a compromised/malicious page still can't reach anything the operator hasn't pointed the app at, since the app only ever calls the one configured origin. All 4 forms needed: a known Tauri scope-matching quirk drops http://*:PORT unless both bare and :* variants are listed.",
"allow": [
{ "url": "http://*" },
{ "url": "https://*" },
{ "url": "http://*:*" },
{ "url": "https://*:*" }
]
}
] ]
} }
+14 -3
View File
@@ -3,9 +3,10 @@
// Intentionally minimal: build the default Tauri app and run it. The window // Intentionally minimal: build the default Tauri app and run it. The window
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json. // config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
// No custom commands are registered — the renderer (the @parking/web SPA) reaches // No custom commands are registered — the renderer (the @parking/web SPA) reaches
// the backend over HTTP to the local Fastify server, NOT through Tauri IPC. This // the backend over HTTP to a Fastify server (address operator-configured at
// keeps the shell a thin presentation wrapper with a deny-by-default native // runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through
// surface (see wiki/decisions/desktop-shell-tauri.md). // Tauri IPC. This keeps the shell a thin presentation wrapper with a
// deny-by-default native surface (see wiki/decisions/desktop-shell-tauri.md).
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -16,6 +17,16 @@ pub fn run() {
// endpoint + signing pubkey live in tauri.conf.json. // endpoint + signing pubkey live in tauri.conf.json.
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
// Routes the SPA's fetch()/WS calls to the operator-configured Fastify
// server through Tauri's native HTTP client — see the Cargo.toml
// comment on why the webview's own fetch() can't reach it directly.
.plugin(tauri_plugin_http::init())
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
// above, but WS needs its own plugin (separate browser check).
.plugin(tauri_plugin_websocket::init())
// Persists the operator-configured backend URL across restarts (JSON
// file in the app's config dir) — see backend-config.ts.
.plugin(tauri_plugin_store::Builder::new().build())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running the Parking System desktop shell"); .expect("error while running the Parking System desktop shell");
} }
+3 -3
View File
@@ -1,13 +1,13 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Parking System", "productName": "Parking System",
"version": "0.1.0", "version": "0.2.0",
"identifier": "com.parking.desktop", "identifier": "com.parking.desktop",
"build": { "build": {
"devUrl": "http://localhost:5173", "devUrl": "http://localhost:5173",
"frontendDist": "../../web/dist", "frontendDist": "../../web/dist",
"beforeDevCommand": "pnpm --filter @parking/web dev", "beforeDevCommand": "pnpm --filter @parking/web dev",
"beforeBuildCommand": "VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build" "beforeBuildCommand": "pnpm --filter @parking/web build"
}, },
"app": { "app": {
"windows": [ "windows": [
@@ -24,7 +24,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000" "csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
} }
}, },
"bundle": { "bundle": {
+27
View File
@@ -77,3 +77,30 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay. # camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR) # VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz) # ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
# Venue modules --------------------------------------------------------------
# Comma-separated ids of the modules this site is ENTITLED to (a vendor/deployment
# decision — set in the Komodo stack env, never by a site role). The site admin then
# ACTIVATES within this set in Setup → Site; effective = entitled ∩ activated. Unset or
# blank = every registered module (parking,validation,carwash) — a DEV convenience. In
# Docker, docker-compose.yml forwards it with a default of parking,validation, so a booth
# is never entitled to a module its Komodo stack env does not name. Required modules
# (parking) are always on. See wiki/decisions/venue-modules.md.
#MODULES_ENTITLED=parking,validation
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md) -------------------------
# The operator's category choice is a hypothesis: each wash order with a vehicle read queues
# the vehicle CROP (plate blurred) + the choice for a trusted remote reviewer, drained one-way
# over the private overlay (Netbird). All three or off. URL = the collector's ingest endpoint
# (reachable only over the overlay); TOKEN = this booth's own bearer token; BOOTH_ID = a
# pseudonymous label the reviewer maps to a site (NEVER the site name — it travels with every
# item). Set in the Komodo stack env, per booth. Nothing is queued while off.
# CARWASH_REVIEW_URL=
# CARWASH_REVIEW_TOKEN=
# CARWASH_REVIEW_BOOTH_ID=
# CARWASH_REVIEW_INTERVAL_SEC=60
# Entry-stream sampling: also queue one in N ENTRY vehicle reads (no wash, no operator) as
# pure training material in the gate view — many times the wash stream, zero domain shift.
# 1 = every entry (the reviewer labels what they have time for; the rest waits and stays
# useful), N = one in N, 0/unset = off. Needs the three settings above.
# CARWASH_REVIEW_ENTRY_SAMPLE=1
+1
View File
@@ -15,6 +15,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/server/package.json apps/server/ COPY apps/server/package.json apps/server/
COPY apps/web/package.json apps/web/ COPY apps/web/package.json apps/web/
COPY apps/vision/package.json apps/vision/ COPY apps/vision/package.json apps/vision/
COPY apps/collector/package.json apps/collector/
COPY packages/db/package.json packages/db/ COPY packages/db/package.json packages/db/
COPY packages/devices/package.json packages/devices/ COPY packages/devices/package.json packages/devices/
COPY packages/shared/package.json packages/shared/ COPY packages/shared/package.json packages/shared/
+3
View File
@@ -84,6 +84,7 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 }, plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
plates: [], plates: [],
lowConfidence: false, lowConfidence: false,
vehicle: null,
modelVersion: "test", modelVersion: "test",
tookMs: 1, tookMs: 1,
}; };
@@ -177,6 +178,7 @@ describe("AnprBridge", () => {
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] }, plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [], plates: [],
lowConfidence: false, lowConfidence: false,
vehicle: null,
modelVersion: "test", modelVersion: "test",
tookMs: 1, tookMs: 1,
})), })),
@@ -207,6 +209,7 @@ describe("AnprBridge", () => {
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] }, plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [], plates: [],
lowConfidence: false, lowConfidence: false,
vehicle: null,
modelVersion: "test", modelVersion: "test",
tookMs: 1, tookMs: 1,
})), })),
+52 -3
View File
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify"; import type { FastifyReply, FastifyRequest } from "fastify";
import { eq, rolePermissions, type Db } from "@parking/db"; import { eq, rolePermissions, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared"; import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
// Local JWT auth helpers — fully local, no external identity provider // Local JWT auth helpers — fully local, no external identity provider
@@ -143,10 +143,41 @@ export function initAuth(db: Db): void {
permsCache.clear(); permsCache.clear();
} }
/** Clear the permission cache. Call after ANY write to roles / role_permissions /** Clear the permission + role caches. Call after ANY write to roles / role_permissions
* (or a user's roleId) so the change takes effect on the next request. */ * or to a user's roleId / deletion, so the change takes effect on the next request. */
export function bumpPermsCache(): void { export function bumpPermsCache(): void {
permsCache.clear(); permsCache.clear();
roleCache.clear();
}
/** userId → CURRENT roleId, cached until bumpPermsCache(). */
const roleCache = new Map<string, string | null>();
/** The user's CURRENT role. The token pins the roleId that was current at LOGIN; an
* admin reassigning a user's role (or deleting the user) must take effect on the next
* request exactly like editing a role does — otherwise the reassigned user keeps the
* old role's rights until they log out (found 2026-09-05: a user moved to a new
* wash role kept 403ing on the new role's permissions). null = the user is gone. */
export function currentRoleId(sub: string): string | null {
if (!authDb) throw new Error("auth not initialised (call initAuth)");
const hit = roleCache.get(sub);
if (hit !== undefined) return hit;
const row = authDb
.select({ roleId: users.roleId, deletedAt: users.deletedAt })
.from(users)
.where(eq(users.id, sub))
.get();
const roleId = row && row.deletedAt == null ? row.roleId : null;
roleCache.set(sub, roleId);
return roleId;
}
/** After jwtVerify: replace the token's pinned roleId with the user's current one, or
* end the session if the user no longer exists. */
function refreshRole(req: FastifyRequest): void {
const roleId = currentRoleId(req.user.sub);
if (roleId === null) throw Object.assign(new Error("session no longer valid"), { statusCode: 401 });
if (roleId !== req.user.roleId) req.user.roleId = roleId;
} }
/** The permission set for a role id, cached. `admin` is always the full set. */ /** The permission set for a role id, cached. `admin` is always the full set. */
@@ -184,12 +215,29 @@ export function requirePermission(...required: Permission[]) {
return async (req: FastifyRequest, _reply: FastifyReply) => { return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify(); // reads the token cookie (configured in server.ts) await req.jwtVerify(); // reads the token cookie (configured in server.ts)
assertCsrf(req); assertCsrf(req);
refreshRole(req);
if (!req.user || !roleHasPermissions(req.user.roleId, required)) { if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 }); throw Object.assign(new Error("forbidden"), { statusCode: 403 });
} }
}; };
} }
/**
* preHandler guard satisfied by ANY ONE of the listed permissions — for a read that
* two jobs legitimately share (a module's master data: the desk that works with it
* reads it under the module's own permission, Setup reads it under site:read).
*/
export function requireAnyPermission(...anyOf: Permission[]) {
return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify();
assertCsrf(req);
refreshRole(req);
if (!req.user || !anyOf.some((p) => roleHasPermissions(req.user!.roleId, [p]))) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
}
};
}
/** /**
* preHandler that requires a valid signed-in session but NO specific permission — * preHandler that requires a valid signed-in session but NO specific permission —
* for "about me" routes (/me, change own language) every authenticated user may * for "about me" routes (/me, change own language) every authenticated user may
@@ -201,4 +249,5 @@ export async function requireAuth(
): Promise<void> { ): Promise<void> {
await req.jwtVerify(); await req.jwtVerify();
assertCsrf(req); assertCsrf(req);
refreshRole(req);
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import {
type PrinterInstance, type PrinterInstance,
type ReceiptData, type ReceiptData,
type TicketHeader, type TicketHeader,
printerRoleOf,
} from "@parking/devices"; } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js"; import { devicesByDirection } from "./device-resolve.js";
@@ -41,7 +42,7 @@ function loadPrinters(db: Db): PrinterInstance[] {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) continue; if (!driver) continue;
const cfg = row.config as Record<string, unknown>; const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; const role = printerRoleOf(cfg);
try { try {
out.push({ out.push({
id: row.id, id: row.id,
+22 -3
View File
@@ -1,6 +1,7 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { PrinterStatus } from "@parking/devices"; import type { PrinterStatus } from "@parking/devices";
import type { LedgerEventRow } from "@parking/db"; import type { LedgerEventRow } from "@parking/db";
import type { VehicleRead } from "@parking/shared";
// Internal event bus for device-originated events (button presses, etc.). // Internal event bus for device-originated events (button presses, etc.).
// Hardware drivers / inbound device pushes emit here; business logic (entry // Hardware drivers / inbound device pushes emit here; business logic (entry
@@ -52,7 +53,7 @@ export interface ReadOutcome {
export interface PrinterStatusEvent { export interface PrinterStatusEvent {
readonly deviceId: string; // devices id readonly deviceId: string; // devices id
readonly driverId: string; readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt readonly role?: string; // entry-dispenser | booth-receipt | wash-desk
readonly status: PrinterStatus; readonly status: PrinterStatus;
} }
@@ -74,10 +75,10 @@ export interface DeviceStatusEvent {
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina": * chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay) * - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[]) * - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) * - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
* - undetermined: null (chip shows the category alone) * - undetermined: null (chip shows the category alone)
*/ */
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null; readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
readonly state: "ready" | "degraded" | "offline"; readonly state: "ready" | "degraded" | "offline";
readonly detail?: string; readonly detail?: string;
readonly checkedAt: string; // ISO-8601 readonly checkedAt: string; // ISO-8601
@@ -105,6 +106,17 @@ export interface PlateRecognizedEvent {
readonly direction: "entry" | "exit"; readonly direction: "entry" | "exit";
} }
/** Emitted when vision classified the vehicle in an entry/exit frame (advisory; stored on
* the read row like the plate). A module may sample these — the Car Wash review outbox
* queues one in N ENTRY reads for the remote reviewer, in the gate view the classifier
* will be trained on (wiki/concepts/vision-review-outbox.md). The core emits; it never
* knows who listens. */
export interface VehicleReadEvent {
readonly identity: string;
readonly direction: "entry" | "exit";
readonly read: VehicleRead;
}
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the /** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has * entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink: * confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
@@ -197,6 +209,13 @@ class DeviceEventBus extends EventEmitter {
this.on("plate-recognized", cb); this.on("plate-recognized", cb);
return () => this.off("plate-recognized", cb); return () => this.off("plate-recognized", cb);
} }
emitVehicleRead(event: VehicleReadEvent): void {
this.emit("vehicle-read", event);
}
onVehicleRead(cb: (event: VehicleReadEvent) => void): () => void {
this.on("vehicle-read", cb);
return () => this.off("vehicle-read", cb);
}
} }
/** Process-wide device event bus. */ /** Process-wide device event bus. */
+2 -1
View File
@@ -64,7 +64,7 @@ export function localIsoWithOffset(tz: string, at = new Date()): string {
* - reader/camera → the direction inherited from its bound relay (entry/exit/both) * - reader/camera → the direction inherited from its bound relay (entry/exit/both)
* - access → entry/exit/both from its relays[]; "mixed" if it spans more * - access → entry/exit/both from its relays[]; "mixed" if it spans more
* than one direction; null if it declares none yet * than one direction; null if it declares none yet
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) * - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
*/ */
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] { function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) { switch (row.category) {
@@ -89,6 +89,7 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
const role = (row.config as { role?: string }).role; const role = (row.config as { role?: string }).role;
if (role === "booth-receipt") return "booth"; if (role === "booth-receipt") return "booth";
if (role === "entry-dispenser") return "lane"; if (role === "entry-dispenser") return "lane";
if (role === "wash-desk") return "wash";
return null; return null;
} }
default: default:
+2 -1
View File
@@ -9,6 +9,7 @@ import {
type PrinterInstance, type PrinterInstance,
type TicketData, type TicketData,
type TicketHeader, type TicketHeader,
printerRoleOf,
} from "@parking/devices"; } from "@parking/devices";
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared"; import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
@@ -523,7 +524,7 @@ export class EntryFlow {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) continue; if (!driver) continue;
const cfg = row.config as Record<string, unknown>; const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; const role = printerRoleOf(cfg);
try { try {
out.push({ out.push({
id: row.id, id: row.id,
+221
View File
@@ -0,0 +1,221 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "./server.js";
import { seedUser, login } from "./test-helpers.js";
// Venue modules — entitled ∩ activated, enforced server-side (wiki/decisions/
// venue-modules.md). Boots the real app over an in-memory DB and drives it with
// app.inject, like routes.test.ts.
let db: Db;
let close: () => void;
let app: FastifyInstance;
const savedEnv = process.env.MODULES_ENTITLED;
async function boot(): Promise<void> {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
}
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
await boot();
});
afterEach(async () => {
await app.close();
close();
if (savedEnv === undefined) delete process.env.MODULES_ENTITLED;
else process.env.MODULES_ENTITLED = savedEnv;
});
async function admin() {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
return login(app, username, password);
}
describe("defaults (no env, nothing activated)", () => {
it("every registered module is entitled, activated and effective; /me carries the set", async () => {
const { cookie } = await admin();
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
expect(cfg.statusCode).toBe(200);
const body = cfg.json();
expect(body.modulesEntitled).toEqual(["parking", "validation", "carwash"]);
expect(body.modulesActivated).toEqual(["parking", "validation", "carwash"]);
expect(body.modules).toEqual(["parking", "validation", "carwash"]);
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
expect(me.json().modules).toEqual(["parking", "validation", "carwash"]);
// A module route answers normally while the module is on.
const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
expect(programs.statusCode).toBe(200);
});
});
describe("activation (site admin)", () => {
it("deactivating validation 403s its routes with module_disabled, signs a config_change, and is reversible", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking"] },
});
expect(put.statusCode).toBe(200);
expect(put.json().modules).toEqual(["parking"]);
expect(put.json().modulesActivated).toEqual(["parking"]);
// The merchant scan routes are the module → 403; the PROGRAM routes are core (the
// discount engine serves Car Wash too) → still 200 with validation off.
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
expect(off.statusCode).toBe(403);
expect(off.json().code).toBe("module_disabled");
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
expect(me.json().modules).toEqual(["parking"]);
// The flip is on the signed ledger, attributed.
const events = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
expect(events.statusCode).toBe(200);
const list = (events.json().events ?? events.json()) as Array<{ type: string; payload: Record<string, unknown> }>;
const flip = list.find((e) => e.type === "config_change" && e.payload?.setting === "modules.validation");
expect(flip).toBeTruthy();
expect(flip!.payload).toMatchObject({ value: false, prev: true, operator: "boss" });
// Nothing was deleted: re-enable and the route is back.
const back = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "validation"] },
});
expect(back.json().modules).toEqual(["parking", "validation"]);
const on = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
expect(on.statusCode).toBe(200);
});
it("required modules cannot be deactivated (parking is always included)", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: [] },
});
expect(put.statusCode).toBe(200);
expect(put.json().modules).toEqual(["parking"]);
});
it("rejects unknown ids with 400", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "bar"] },
});
expect(put.statusCode).toBe(400);
});
it("carwash runs without the validation module (the discount engine is core)", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "carwash"] },
});
expect(put.statusCode).toBe(200);
expect(put.json().modules).toEqual(["parking", "carwash"]);
// The wash's sponsorship program is still composable and readable.
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie } })).statusCode).toBe(200);
});
it("dependency rule: a module cannot be on while a module it depends on is off", async () => {
const { cookie, csrf } = await admin();
// Every non-required module depends on parking, and parking is required — so the rule
// is exercised through the effective-set helper directly.
const shared = await import("@parking/shared");
expect(shared.resolveModuleActivation(["parking", "validation", "carwash"], ["carwash"])).toMatchObject({ ok: true });
expect(shared.effectiveModules(["parking", "carwash"], ["parking", "carwash"])).toEqual(["parking", "carwash"]);
expect(cookie && csrf).toBeTruthy();
});
it("a no-op resave signs nothing", async () => {
const { cookie, csrf } = await admin();
const before = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
const countBefore = ((before.json().events ?? before.json()) as unknown[]).length;
await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "validation", "carwash"] },
});
const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore);
});
});
describe("entitlement (vendor env)", () => {
it("MODULES_ENTITLED=parking: validation is neither offered nor activatable, and its routes 403", async () => {
await app.close();
close();
process.env.MODULES_ENTITLED = "parking";
await boot();
const { cookie, csrf } = await admin();
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
expect(cfg.json().modulesEntitled).toEqual(["parking"]);
expect(cfg.json().modules).toEqual(["parking"]);
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "validation"] },
});
expect(put.statusCode).toBe(400);
expect(put.json().error).toMatch(/not entitled/);
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
expect(off.statusCode).toBe(403);
});
it("required modules are entitled even when the env omits them; unknown ids are ignored", async () => {
await app.close();
close();
process.env.MODULES_ENTITLED = "validation,bogus";
await boot();
const { cookie } = await admin();
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]);
expect(cfg.json().modules).toEqual(["parking", "validation"]);
});
});
describe("permissions matrix helpers (venue-modules.md §Permissions matrix)", async () => {
const shared = await import("@parking/shared");
it("each till is guarded by its own module's permissions", () => {
expect(shared.tillGuards("booth")).toEqual({ read: "shift:read", shift: "shift:create", cash: "drawer:create" });
expect(shared.tillGuards("carwash")).toEqual({ read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" });
const wash = new Set(["carwash:read", "carwash:cash"]);
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p))).toEqual(["carwash"]);
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p), "shift")).toEqual(["carwash"]);
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => p === "carwash:read", "shift")).toEqual([]);
// Module off → its till is not even addressable.
expect(shared.tillsFor(["parking"], () => true)).toEqual(["booth"]);
});
it("the live feed admits by watch permission and filters ledger events by their module", () => {
expect(shared.watchPermissions(["parking", "validation", "carwash"])).toEqual(
expect.arrayContaining(["event:read", "session:read", "device:read", "carwash:read"]),
);
expect(shared.watchPermissions(["parking", "validation", "carwash"])).not.toContain("report:read");
expect(shared.watchPermissions(["parking"])).not.toContain("carwash:read");
expect(shared.feedPermissionFor("carwash_payment")).toBe("carwash:read");
expect(shared.feedPermissionFor("payment")).toBe("event:read");
expect(shared.feedPermissionFor("validation")).toBe("event:read");
});
it("every job's permissions exist in the grid", () => {
for (const m of shared.MODULES) for (const j of m.jobs) for (const p of j.permissions) expect(shared.PERMISSIONS).toContain(p);
});
});
+119
View File
@@ -0,0 +1,119 @@
import type { FastifyReply, FastifyRequest } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import {
effectiveModules,
isModuleId,
isTillId,
parseEntitledModules,
tillGuards,
tillsFor,
tillsOf,
type ModuleId,
type TillGuards,
type TillId,
} from "@parking/shared";
import { requireAuth, roleHasPermissions } from "./auth.js";
declare module "fastify" {
interface FastifyRequest {
/** Set by requireTill(): the till this request addresses (already authorized). */
till?: TillId;
}
}
// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in
// @parking/shared; design in wiki/decisions/venue-modules.md).
//
// entitled MODULES_ENTITLED env (vendor, Komodo stack) — unset = everything.
// activated site_config.modules_json (site admin, Setup → Site) — null = everything
// entitled.
// effective what requireModule() enforces and what /api/auth/me + /api/site-config
// hand the SPA so it can hide nav. The web only HIDES; this file ENFORCES.
//
// Both inputs are re-read per request: one env read and one single-row SELECT on the
// site_config singleton — cheap, and it means a change takes effect on the next request
// with no cache to invalidate (the same reason the presence-bypass flags aren't cached).
/** The modules this deployment is entitled to. Unknown ids in the env are ignored
* (logged once at boot by registerModules). */
export function entitledModules(): ModuleId[] {
return parseEntitledModules(process.env.MODULES_ENTITLED).entitled;
}
/** Parse the persisted activation list off a site_config row. null = never set. A
* corrupt/unknown value is treated as "never set" rather than locking modules off. */
export function activatedModulesOf(row: { modulesJson?: string | null } | undefined): ModuleId[] | null {
const raw = row?.modulesJson;
if (raw == null) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed.filter(isModuleId);
} catch {
return null;
}
}
/** The effective set for this site right now. */
export function effectiveModulesFor(db: Db): ModuleId[] {
const row = db.select({ modulesJson: siteConfig.modulesJson }).from(siteConfig).where(eq(siteConfig.id, 1)).get();
return effectiveModules(entitledModules(), activatedModulesOf(row));
}
/** The tills available at this site right now: the booth, plus each effective
* money-taking module's own till (registry order). */
export function effectiveTillsFor(db: Db): TillId[] {
return tillsOf(effectiveModulesFor(db));
}
/** The tills a role may SEE (default) or WORK (`shift` / `cash`) here: the effective
* tills whose module guard the role holds (each desk's money is guarded by that desk's
* own permissions — venue-modules.md §"Permissions matrix"). */
export function tillsReadableBy(db: Db, roleId: string, kind: keyof TillGuards = "read"): TillId[] {
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p]), kind);
}
/** preHandler factory for the shift/drawer routes: authenticate, parse the `till`
* (query on GET, body on POST; absent = booth; 400 `bad_till` when unknown or its
* module is off), then require the role to hold THAT TILL's guard for `kind` (403
* `till_forbidden`). The authorized till lands on `req.till`. The permission is thus
* resolved from the till, never fixed: the booth checks `shift:read`/`shift:create`/
* `drawer:create`, the wash `carwash:read`/`carwash:cash`. */
export function requireTill(db: Db, kind: keyof TillGuards, from: "query" | "body") {
return async (req: FastifyRequest, reply: FastifyReply): Promise<void | FastifyReply> => {
await requireAuth(req, reply);
const raw = from === "query" ? (req.query as { till?: unknown } | undefined)?.till : (req.body as { till?: unknown } | undefined)?.till;
const till = parseTill(db, raw);
if (!till) {
await reply.code(400).send({ error: "unknown till", code: "bad_till" });
return reply;
}
if (!roleHasPermissions(req.user.roleId, [tillGuards(till)[kind]])) {
await reply
.code(403)
.send({ error: `your role cannot ${kind === "read" ? "see" : "work"} the ${till} till`, code: "till_forbidden", till });
return reply;
}
req.till = till;
};
}
/** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till
* whose module is not effective here, → null (the caller answers 400). */
export function parseTill(db: Db, raw: unknown): TillId | null {
if (raw == null || raw === "") return "booth";
if (!isTillId(raw)) return null;
return effectiveTillsFor(db).includes(raw) ? raw : null;
}
/** preHandler: reject the call when `id` is not effective at this site. Compose it
* BEFORE requirePermission in a preHandler array so a disabled module answers the
* same way for every role — 403 with code "module_disabled" — and never reaches
* the permission/CSRF path. */
export function requireModule(db: Db, id: ModuleId) {
return async (_req: FastifyRequest, _reply: FastifyReply): Promise<void> => {
if (!effectiveModulesFor(db).includes(id)) {
throw Object.assign(new Error(`module disabled: ${id}`), { statusCode: 403, code: "module_disabled" });
}
};
}
@@ -0,0 +1,646 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { deviceEvents, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
// Car Wash module, end to end over the real app (wiki/decisions/venue-modules.md):
// settings → intake against an open parking session → done applies the sponsorship
// validation → bay payment settles the parking session at zero (what the exit reader
// checks) / booth payment carries the wash as a charge line → module off = 403.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
type Auth = { cookie: string; csrf: string };
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
async function admin(): Promise<Auth> {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
return login(app, username, password);
}
/** An open transient session that has been parked long enough to owe money. */
async function openSession(identity: string, enteredMinutesAgo = 90): Promise<void> {
await makeLog(db).append({
type: "vehicle_entry",
source: "manual",
identity,
occurredAt: minutesAgo(enteredMinutesAgo),
payload: { sessionRef: identity, category: "default" },
});
}
async function seedSettings(a: Auth) {
const res = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: {
categories: [{ name: "Car" }, { name: "SUV" }],
services: [{ name: "Standard" }, { name: "Inside" }],
prices: [],
},
});
expect(res.statusCode).toBe(200);
const s = res.json();
const car = s.categories.find((c: { name: string }) => c.name === "Car").id;
const suv = s.categories.find((c: { name: string }) => c.name === "SUV").id;
const std = s.services.find((c: { name: string }) => c.name === "Standard").id;
const inside = s.services.find((c: { name: string }) => c.name === "Inside").id;
const priced = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: {
categories: s.categories, services: s.services,
prices: [
{ categoryId: car, serviceId: std, priceMinor: 50000 },
{ categoryId: suv, serviceId: std, priceMinor: 70000 },
{ categoryId: car, serviceId: inside, priceMinor: 30000 },
],
},
});
expect(priced.statusCode).toBe(200);
expect(priced.json().prices).toHaveLength(3);
return { car, suv, std, inside };
}
/** Flip the site's wash-payment policy (Setup → Car wash). */
async function setPayAt(a: Auth, payAt: "booth" | "bay") {
const res = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt } });
expect(res.statusCode).toBe(200);
expect(res.json().payAt).toBe(payAt);
}
async function seedSponsorship(a: Auth, mode: "comp" | "percent" | "doneTolerance" | "washPrice" = "comp", minutes: number | null = null) {
const res = await app.inject({
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
payload: { name: "Lavazh", mode, percent: mode === "percent" ? 50 : null, minutes, active: true, userIds: [] },
});
expect(res.statusCode).toBeLessThan(300);
}
async function events(a: Auth) {
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
return (r.json().events ?? r.json()) as Array<{ id: string; type: string; identity: string | null; payload: Record<string, unknown> }>;
}
describe("settings", () => {
it("round-trips categories, services and the price matrix; signs a config_change; unknown pairs are refused", async () => {
const a = await admin();
const ids = await seedSettings(a);
const get = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } });
expect(get.json().categories.map((c: { name: string }) => c.name)).toEqual(["Car", "SUV"]);
expect(get.json().prices.find((p: { categoryId: string; serviceId: string }) => p.categoryId === ids.suv && p.serviceId === ids.std).priceMinor).toBe(70000);
const bad = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: { prices: [{ categoryId: "nope", serviceId: ids.std, priceMinor: 1 }] },
});
expect(bad.statusCode).toBe(400);
const flips = (await events(a)).filter((e) => e.type === "config_change" && e.payload.setting === "carwash.settings");
expect(flips.length).toBeGreaterThanOrEqual(2);
});
});
describe("orders", () => {
it("intake needs an open session and a priced pair; the queue is oldest-first", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
const noSession = await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-NONE", categoryId: ids.car, serviceId: ids.std },
});
expect(noSession.statusCode).toBe(404);
await openSession("T-1");
const noPrice = await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.inside },
});
expect(noPrice.statusCode).toBe(409);
expect(noPrice.json().code).toBe("no_price");
const created = await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.std },
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({ identity: "T-1", categoryName: "SUV", serviceName: "Standard", priceMinor: 70000, payAt: "booth", status: "open", closed: false });
await openSession("T-2");
await setPayAt(a, "bay");
await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-2", categoryId: ids.car, serviceId: ids.std },
});
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
expect(queue.json().orders.map((o: { identity: string }) => o.identity)).toEqual(["T-1", "T-2"]);
const chain = (await events(a)).filter((e) => e.type === "carwash_order");
expect(chain).toHaveLength(2);
expect(chain[0]!.payload).toMatchObject({ action: "created", operator: "boss" });
});
it("pay at BOOTH: the wash rides the parking quote as a charge line and is marked paid by the booth payment", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await openSession("T-B");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-B", categoryId: ids.car, serviceId: ids.std },
})).json();
const look = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
const s = look.json();
expect(s.chargeLines).toHaveLength(1);
expect(s.chargeLines[0]).toMatchObject({ module: "carwash", ref: order.id, amountMinor: 50000 });
expect(s.chargesMinor).toBe(50000);
expect(s.amountMinor).toBeGreaterThan(50000); // parking fee + the wash
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(a), payload: { identity: "T-B", tender: "cash" } });
expect(pay.statusCode).toBeLessThan(300);
const payment = (await events(a)).find((e) => e.type === "payment" && e.identity === "T-B")!;
expect(payment.payload.chargesMinor).toBe(50000);
expect((payment.payload.chargeLines as unknown[]).length).toBe(1);
expect(payment.payload.amountMinor).toBe((payment.payload.parkingMinor as number) + 50000);
const recent = await app.inject({ method: "GET", url: "/api/carwash/orders?scope=recent", headers: { cookie: a.cookie } });
const o = recent.json().orders.find((x: { id: string }) => x.id === order.id);
expect(o.paidAt).toBeTruthy();
expect(o.paymentEventId).toBeUndefined(); // not exposed on the view
expect(o.tender).toBe("cash");
// A second lookup no longer carries the line (it's settled).
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
expect(again.json().chargeLines).toEqual([]);
// The booth's Z-report: the wash money is inside cash (it is in the drawer) but
// OUT of the ticket bucket, under its own module — Bileta is parking money only.
const parking = payment.payload.parkingMinor as number;
const z = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
expect(z).toMatchObject({ till: "booth", cashTotalMinor: parking + 50000, ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
expect(z.ticketTotalMinor + z.subscriptionTotalMinor + 50000).toBe(z.cashTotalMinor + z.cardTotalMinor);
const summary = (await app.inject({ method: "GET", url: "/api/shifts", headers: { cookie: a.cookie } })).json().shifts[0];
expect(summary).toMatchObject({ till: "booth", ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
const signed = (await events(a)).find((e) => e.type === "shift_z_report")!;
expect(signed.payload.chargesByModuleMinor).toEqual({ carwash: 50000 });
});
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await seedSponsorship(a, "comp");
await openSession("T-Y");
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-Y", categoryId: ids.suv, serviceId: ids.std },
})).json();
// Bay money needs an open CARWASH shift — the booth's shift does not count (tills).
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
const noShift = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
expect(noShift.statusCode).toBe(409);
expect(noShift.json()).toMatchObject({ code: "no_shift", till: "carwash" });
const openWash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
expect(openWash.statusCode).toBe(200);
const done = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
expect(done.statusCode).toBe(200);
expect(done.json().status).toBe("done");
expect(done.json().validationEventId).toBeTruthy();
// Sponsorship applied → the parking quote is now zero-due (comp), but NOT yet paid.
const mid = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
expect(mid.json().amountMinor).toBe(0);
expect(mid.json().paidAt).toBeNull();
const paid = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "card" } });
expect(paid.statusCode).toBe(200);
expect(paid.json().closed).toBe(true);
const evs = await events(a);
const bay = evs.find((e) => e.type === "carwash_payment")!;
expect(bay.payload).toMatchObject({ orderId: order.id, amountMinor: 70000, tender: "card", operator: "boss", till: "carwash" });
// The wash Z-report carries the bay money; the booth's carries none of it.
const washZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a), payload: { till: "carwash" } })).json();
expect(washZ).toMatchObject({ till: "carwash", cardTotalMinor: 70000, cashTotalMinor: 0, paymentCount: 1 });
const boothZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
expect(boothZ.till).toBe("booth");
expect(boothZ.cardTotalMinor).toBe(0);
expect(boothZ.paymentCount).toBe(1); // the $0 parking settlement is booth money
// The $0 parking payment exists → the exit reader's paid+grace check passes.
const parkingPay = evs.find((e) => e.type === "payment" && e.identity === "T-Y")!;
expect(parkingPay).toBeTruthy();
expect(parkingPay.payload.amountMinor).toBe(0);
const after = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
expect(after.json().paidAt).toBeTruthy();
expect(after.json().withinGrace).toBe(true);
// The queue is empty (done + paid = closed).
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
expect(queue.json().orders).toEqual([]);
});
it("pay at BAY with a PARTIAL sponsorship leaves the remainder for the booth (no $0 payment)", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await seedSponsorship(a, "percent");
await openSession("T-P");
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-P", categoryId: ids.car, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const s = (await app.inject({ method: "GET", url: "/api/session/T-P", headers: { cookie: a.cookie } })).json();
expect(s.paidAt).toBeNull();
expect(s.amountMinor).toBeGreaterThan(0);
expect(s.discountMinor).toBeGreaterThan(0);
});
it("void takes back a live sponsorship; a paid order cannot be voided", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await seedSponsorship(a, "comp");
await openSession("T-V");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-V", categoryId: ids.car, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const before = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
expect(before.validationLines).toHaveLength(1);
const voided = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/void`, headers: hdrs(a), payload: { reason: "customer left" } });
expect(voided.statusCode).toBe(200);
expect(voided.json().status).toBe("void");
const after = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
expect(after.validationLines).toEqual([]);
expect(after.chargeLines).toEqual([]);
});
});
describe("wash-only discount modes", () => {
it("doneTolerance credits only the WASH WINDOW (+ tolerance), never the parking before the order", async () => {
const a = await admin();
// 100.00 per 60-min increment, no entry grace; parked 95 min → 2 increments.
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
const ids = await seedSettings(a);
await seedSponsorship(a, "doneTolerance", 15);
await openSession("T-D", 95);
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-D", categoryId: ids.car, serviceId: ids.std },
})).json();
const before = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
expect(before.amountMinor).toBe(20000);
// Done right away: the wash window is ~0 min, so the credit is just the tolerance.
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-D")!;
expect(v.payload.mode).toBe("timeCredit");
expect(v.payload.programMode).toBe("doneTolerance");
expect(v.payload.minutes as number).toBeGreaterThanOrEqual(15);
expect(v.payload.minutes as number).toBeLessThanOrEqual(17);
// 95 − ~15 min still spans 2 increments → the long stay is NOT comped away.
const after = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
expect(after.amountMinor).toBe(20000);
expect(after.discountMinor).toBe(0);
});
it("doneTolerance with a tolerance that covers the whole stay does comp it (the credit is real)", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
const ids = await seedSettings(a);
await seedSponsorship(a, "doneTolerance", 120);
await openSession("T-D2", 95);
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-D2", categoryId: ids.car, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const after = (await app.inject({ method: "GET", url: "/api/session/T-D2", headers: { cookie: a.cookie } })).json();
expect(after.amountMinor).toBe(0);
});
it("washPrice: the wash price comes off the parking fee, floored at zero", async () => {
const a = await admin();
// 1000.00/h, parked 95 min → 2 increments = 200000 owed. Car·Standard wash = 50000.
seedTariff(db, { pricePerIncrementMinor: 100000, incrementMin: 60, gracePeriodEntryMin: 0 });
const ids = await seedSettings(a);
await seedSponsorship(a, "washPrice");
await openSession("T-W", 95);
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-W", categoryId: ids.car, serviceId: ids.std },
})).json();
const before = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const after = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
expect(after.discountMinor).toBe(50000);
expect(after.amountMinor).toBe(before.amountMinor - 50000);
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-W")!;
expect(v.payload).toMatchObject({ mode: "fixed", programMode: "washPrice", amountMinor: 50000 });
});
it("a merchant scan cannot apply a wash-only program", async () => {
const a = await admin();
seedTariff(db);
await seedSponsorship(a, "washPrice");
// Bind the admin to it so the binding check passes and the MODE check is what refuses.
await app.inject({
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
payload: { name: "Lavazh", mode: "washPrice", active: true, userIds: [(await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: a.cookie } })).json().id] },
});
await openSession("T-M");
const res = await app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(a), payload: { identity: "T-M", programId: "carwash" } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/car wash order/);
});
});
describe("module gate", () => {
it("with carwash deactivated every route 403s and the booth quote carries no wash lines", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await openSession("T-G");
await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-G", categoryId: ids.car, serviceId: ids.std },
});
const off = await app.inject({ method: "PUT", url: "/api/site-config", headers: hdrs(a), payload: { modules: ["parking", "validation"] } });
expect(off.json().modules).toEqual(["parking", "validation"]);
const q = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
expect(q.statusCode).toBe(403);
expect(q.json().code).toBe("module_disabled");
const look = (await app.inject({ method: "GET", url: "/api/session/T-G", headers: { cookie: a.cookie } })).json();
expect(look.chargeLines).toEqual([]);
});
});
describe("where the money is taken is a SITE setting", () => {
it("defaults to the booth, persists, signs a config_change, and freezes on each order", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json().payAt).toBe("booth");
await openSession("T-S1");
const o1 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S1", categoryId: ids.car, serviceId: ids.std } })).json();
expect(o1.payAt).toBe("booth");
await setPayAt(a, "bay");
const cfg = (await events(a)).find((e) => e.type === "config_change" && e.payload.setting === "carwash.payAt")!;
expect(cfg.payload).toMatchObject({ value: "bay", prev: "booth", operator: "boss" });
await openSession("T-S2");
const o2 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std } })).json();
expect(o2.payAt).toBe("bay");
expect(o1.payAt).toBe("booth"); // earlier order keeps the policy it was created under
// A stale client insisting on the other place is refused, never silently overridden.
const stale = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std, payAt: "booth" } });
expect(stale.statusCode).toBe(409);
expect(stale.json().code).toBe("pay_at_policy");
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt: "pocket" } });
expect(bad.statusCode).toBe(400);
});
});
describe("tills are gated by the module permission", () => {
it("a wash-only role works the carwash till and never the booth's; a booth role the reverse", async () => {
const a = await admin();
seedTariff(db);
await seedSettings(a);
// The wash-operator JOB: no shift:* / drawer:* at all — the wash till is guarded by
// carwash:read / carwash:cash (venue-modules.md §"Permissions matrix").
const washer = await seedUser(db, {
username: "lavazhier", roleId: "washer",
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
});
const w = await login(app, washer.username, washer.password);
// The desk's category/service pickers come from the settings read — the job has no
// site:read, so the module permission must open it (found on park dev, 2026-09-06).
const list = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: w.cookie } });
expect(list.statusCode).toBe(200);
expect(list.json().categories.length).toBeGreaterThan(0);
expect((await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(w), payload: { payAt: "bay" } })).statusCode).toBe(403);
// What the UI offers: only the wash till.
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
// The booth's shift is refused outright (the role holds no shift:*).
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
expect(booth.statusCode).toBe(403);
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
const boothState = await app.inject({ method: "GET", url: "/api/shift/current", headers: { cookie: w.cookie } });
expect(boothState.statusCode).toBe(403);
const boothCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100 } });
expect(boothCash.statusCode).toBe(403);
// The wash till works.
const wash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w), payload: { till: "carwash" } });
expect(wash.statusCode).toBe(200);
expect(wash.json().till).toBe("carwash");
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
expect(washCash.statusCode).toBe(200);
// A wash user who may look (carwash:read) but not work the till (no carwash:cash)
// sees the state and gets canWork=false; opening is refused.
const looker = await seedUser(db, { username: "looker", roleId: "wash-look", permissions: ["carwash:read"] });
const l = await login(app, looker.username, looker.password);
const lookTills = (await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: l.cookie } })).json();
expect(lookTills.tills).toMatchObject([{ till: "carwash", canWork: false }]);
expect((await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(l), payload: { till: "carwash" } })).statusCode).toBe(403);
// A booth operator (shift:*, no carwash:*) cannot touch the wash till.
const booth1 = await seedUser(db, {
username: "boothie", roleId: "booth-op",
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
});
const b = await login(app, booth1.username, booth1.password);
const noWash = await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(b), payload: { till: "carwash" } });
expect(noWash.statusCode).toBe(403);
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
});
});
describe("a role reassignment takes effect without re-login", () => {
it("a user moved from a look-only role to the wash-operator role can create an order on the next request", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await openSession("T-R");
const looker = await seedUser(db, { username: "moved", roleId: "wash-look", permissions: ["carwash:read"] });
// Materialise the target role (seedUser creates the role rows; the user itself is a throwaway).
await seedUser(db, { username: "throwaway", roleId: "wash-op", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] });
const l = await login(app, looker.username, looker.password);
const before = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
expect(before.statusCode).toBe(403);
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie: a.cookie } })).json();
const id = list.users.find((u: { username: string }) => u.username === "moved").id;
const moved = await app.inject({ method: "PUT", url: `/api/users/${id}`, headers: hdrs(a), payload: { roleId: "wash-op" } });
expect(moved.statusCode).toBe(200);
// Same cookie, no re-login: the token's pinned role is refreshed per request.
const after = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
expect(after.statusCode).toBe(201);
const me = (await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: l.cookie } })).json();
expect(me.roleId).toBe("wash-op");
});
});
describe("a shift's activity log is per till", () => {
it("/api/events?till= applies tillOfEvent; a feed-only role reads its module's events and nothing else", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await openSession("T-L");
await setPayAt(a, "bay");
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-L", categoryId: ids.suv, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 500, till: "carwash" } });
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 700 } });
const types = async (qs: string, auth: Auth = a) => {
const r = await app.inject({ method: "GET", url: `/api/events?limit=200${qs}`, headers: { cookie: auth.cookie } });
expect(r.statusCode).toBe(200);
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).map((e) => `${e.type}${e.payload?.till ? `@${e.payload.till}` : ""}`);
};
// The wash till's log: its shift, its order (no money moved, but wash-desk activity),
// its bay payment and its voucher — none of the booth's.
const wash = await types("&till=carwash");
expect(wash).toEqual(expect.arrayContaining(["shift_open@carwash", "carwash_order", "carwash_payment@carwash", "cash_in@carwash"]));
expect(wash.some((t) => t.startsWith("vehicle_entry") || t === "shift_open@booth" || t === "cash_in@booth")).toBe(false);
// The booth's log: entry, its shift, its voucher — and no wash-desk activity.
const booth = await types("&till=booth");
expect(booth).toEqual(expect.arrayContaining(["vehicle_entry", "shift_open@booth", "cash_in@booth"]));
expect(booth.some((t) => t.startsWith("carwash_") || t.endsWith("@carwash"))).toBe(false);
// No till → everything (unchanged).
const all = await types("");
expect(all.length).toBe(wash.length + booth.length);
expect((await app.inject({ method: "GET", url: "/api/events?till=bar", headers: { cookie: a.cookie } })).statusCode).toBe(400);
// A wash operator holds carwash:read but not event:read: the log opens for them
// with ONLY the module's own event types (the live-socket rule, feedPermissionFor).
const washer = await seedUser(db, { username: "lavazhier", roleId: "washer", permissions: ["carwash:read", "carwash:cash"] });
const w = await login(app, washer.username, washer.password);
const mine = await types("&till=carwash", w);
expect(mine).toEqual(expect.arrayContaining(["carwash_order", "carwash_payment@carwash"]));
expect(mine.every((t) => t.startsWith("carwash_"))).toBe(true);
// A role with neither event:read nor any module feed permission reads nothing.
const clerk = await seedUser(db, { username: "clerk", roleId: "clerk", permissions: ["session:read"] });
const c = await login(app, clerk.username, clerk.password);
expect((await app.inject({ method: "GET", url: "/api/events", headers: { cookie: c.cookie } })).statusCode).toBe(403);
});
});
describe("vision category — advisory, flagged, never authoritative", () => {
/** What snapshot.ts records when vision classifies the entry frame. */
function seeVehicle(identity: string, bodyType: string, bodyConfidence: number) {
db.insert(deviceEvents).values({
id: `read-${identity}-${bodyType}`, deviceId: "cam-1", category: "camera", kind: "read",
detail: { identity, direction: "entry", bodyType, bodyConfidence, snapshotId: "snap-1", source: "entry-exit-snapshot" },
occurredAt: new Date().toISOString(),
}).run();
}
async function mapClasses(a: Auth, ids: { car: string; suv: string }) {
const cur = (await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json();
const r = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: {
categories: cur.categories.map((c: { id: string }) => ({ ...c, visionClasses: c.id === ids.suv ? ["suv", "pickup"] : c.id === ids.car ? ["car", "sedan", "hatchback"] : [] })),
visionThreshold: 0.75,
},
});
expect(r.statusCode).toBe(200);
return r.json();
}
it("Setup maps the vocabulary onto site categories; the lookup suggests the mapped category", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
const saved = await mapClasses(a, ids);
expect(saved.categories.find((c: { id: string }) => c.id === ids.suv).visionClasses).toEqual(["suv", "pickup"]);
expect(saved.visionThreshold).toBe(0.75);
expect((await events(a)).some((e) => e.type === "config_change" && e.payload.setting === "carwash.visionThreshold")).toBe(true);
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { categories: [{ id: ids.car, name: "Car", visionClasses: ["spaceship"] }] } });
expect(bad.statusCode).toBe(400);
await openSession("T-V1");
seeVehicle("T-V1", "suv", 0.91);
const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json();
expect(look.vision).toMatchObject({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
expect(look.suggestedCategoryId).toBe(ids.suv);
// Unmapped class → shown, nothing suggested.
await openSession("T-V2");
seeVehicle("T-V2", "bus", 0.99);
const look2 = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V2", headers: { cookie: a.cookie } })).json();
expect(look2.vision.bodyType).toBe("bus");
expect(look2.suggestedCategoryId).toBeNull();
});
it("a confident downgrade signs an anomaly with both categories and the snapshot; equal, upgrade or unsure reads do not; the order is never blocked", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await mapClasses(a, ids);
const order = async (identity: string, categoryId: string) => {
const r = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity, categoryId, serviceId: ids.std } });
expect(r.statusCode).toBe(201);
return r.json();
};
// Camera: SUV (0.91) — operator picks Car (cheaper) → flagged, recorded, still created.
await openSession("T-D1"); seeVehicle("T-D1", "suv", 0.91);
const down = await order("T-D1", ids.car);
expect(down).toMatchObject({ visionClass: "suv", visionConfidence: 0.91, visionCategoryId: ids.suv, categoryId: ids.car });
expect(down.downgradeEventId).toBeTruthy();
const flag = (await events(a)).find((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")!;
expect(flag).toBeTruthy();
expect(flag.payload).toMatchObject({
visionClass: "suv", visionCategoryName: "SUV", chosenCategoryName: "Car", operator: "boss",
visionPriceMinor: 70000, chosenPriceMinor: 50000, snapshotId: "snap-1",
});
// Same category as the camera → nothing.
await openSession("T-D2"); seeVehicle("T-D2", "suv", 0.91);
expect((await order("T-D2", ids.suv)).downgradeEventId).toBeNull();
// Upgrade (camera Car, operator SUV) → recorded on the order, no anomaly.
await openSession("T-D3"); seeVehicle("T-D3", "sedan", 0.95);
const up = await order("T-D3", ids.suv);
expect(up).toMatchObject({ visionClass: "sedan", visionCategoryId: ids.car, downgradeEventId: null });
// Below the site threshold → shown, never flagged.
await openSession("T-D4"); seeVehicle("T-D4", "suv", 0.6);
expect((await order("T-D4", ids.car)).downgradeEventId).toBeNull();
// No read at all → nulls.
await openSession("T-D5");
expect(await order("T-D5", ids.car)).toMatchObject({ visionClass: null, visionCategoryId: null, downgradeEventId: null });
expect((await events(a)).filter((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")).toHaveLength(1);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { deviceEvents } from "../../device-events.js";
import type { ServerModule } from "../index.js";
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
import { carwashRoutes } from "./routes.js";
import { CarwashService } from "./service.js";
// Car Wash — the pilot venue module (wiki/decisions/venue-modules.md). Everything the
// module is lives in this folder: its service (master data, the order queue, the bay
// payment, the parking sponsorship + settlement), its routes, and the booth charge
// provider it registers with the core's PayStation. The core knows it only through the
// registry line in ../index.ts and the manifest in @parking/shared.
export const carwashModule: ServerModule = {
id: "carwash",
async register(app, deps) {
// The review outbox (wiki/concepts/vision-review-outbox.md): on when the stack env
// names a collector URL, a per-booth token and a pseudonymous booth id; off = no
// queueing at all. One-way, background, never on the intake path.
const cfg = reviewUploadConfigFromEnv();
const outbox = new ReviewOutbox(deps.db, app.log, cfg);
app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
outbox.start();
// Entry-stream sampling: one in N entry vehicle reads goes to the reviewer as pure
// training material (the gate view, no order attached). The core announces the read;
// the module decides. Off unless CARWASH_REVIEW_ENTRY_SAMPLE is set.
const offVehicleRead = deviceEvents.onVehicleRead((e) => {
if (e.direction === "entry" && outbox.sampleEntry()) void outbox.enqueueEntry(e.read);
});
app.addHook("onClose", async () => offVehicleRead());
app.addHook("onClose", async () => outbox.stop());
const service = new CarwashService(deps, app.log, outbox);
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
// the core calls back after the payment is signed so the order is marked paid.
deps.payStation.registerChargeProvider(service.chargeProvider());
await carwashRoutes(app, deps, service, outbox);
},
};
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { createTestDb } from "@parking/db/testing";
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { deviceEvents as deviceEventBus } from "../../device-events.js";
import { buildServer } from "../../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred
// vehicle crop + the operator's choice, queued off the intake path, drained one-way with
// backoff, never blocking the wash, never naming the site.
/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */
async function frame(): Promise<Buffer> {
return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } })
.composite([
{ input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 },
{ input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 },
])
.jpeg()
.toBuffer();
}
const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 };
const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 };
/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a
* blurred plate drops far below 255 as the red bleeds in. */
async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise<number> {
const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true });
let sum = 0;
for (let i = 1; i < data.length; i += info.channels) sum += data[i]!;
return sum / (data.length / info.channels);
}
describe("makeReviewCrop", () => {
it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => {
const shot = await frame();
const crop = await makeReviewCrop(shot, CAR, PLATE);
expect(crop.plateBlurred).toBe(true);
// Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling.
expect(crop.width).toBeGreaterThanOrEqual(228);
expect(crop.width).toBeLessThanOrEqual(236);
expect(crop.height).toBeGreaterThanOrEqual(135);
// The white plate is gone: over the plate strip (crop coords: the frame's 170..230 ×
// 190..206 shifted by the crop origin 84,90) the same region cut straight from the
// frame is white, the review crop is the red bleeding in.
const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer();
const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 };
expect(await meanGreen(plain, strip)).toBeGreaterThan(240);
expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180);
// Without a plate box: same crop, nothing blurred.
const noPlate = await makeReviewCrop(shot, CAR, null);
expect(noPlate.plateBlurred).toBe(false);
// A big frame is capped to the max edge.
const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer();
const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null);
expect(Math.max(capped.width, capped.height)).toBe(640);
});
});
describe("config + pseudonyms", () => {
it("needs url, token and booth id together; the operator ref is a keyed hash", () => {
expect(reviewUploadConfigFromEnv({})).toBeNull();
expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull();
const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" });
expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default
expect(operatorRef("b7", "lavazhier")).toHaveLength(16);
expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier"));
expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier");
});
});
describe("queue + drain", () => {
let db: Db;
let close: () => void;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
});
afterEach(() => close());
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60, entrySample: 0 };
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", categoryClasses: ["car", "sedan"], serviceName: "Standard", visionCategoryId: "car", downgraded: false };
async function seed(): Promise<void> {
db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
db.insert(carwashOrders).values({
id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard",
priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier",
}).run();
}
it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => {
await seed();
const calls: { url: string; init: RequestInit }[] = [];
const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url, init });
return new Response("ok", { status: 200 });
});
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
expect(await ob.enqueue(item, read)).toBe(true);
const row = db.select().from(carwashReviewOutbox).all()[0]!;
expect(row.status).toBe("queued");
expect(row.image!.length).toBeGreaterThan(500);
expect(row.payload).toMatchObject({ v: 1, kind: "wash", booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
expect(calls).toHaveLength(1);
expect(calls[0]!.url).toBe(cfg.url);
expect((calls[0]!.init.headers as Record<string, string>).authorization).toBe("Bearer secret-1");
const form = calls[0]!.init.body as FormData;
expect(JSON.parse(form.get("meta") as string).item).toBe(row.id);
expect((form.get("image") as File).type).toBe("image/jpeg");
const after = db.select().from(carwashReviewOutbox).all()[0]!;
expect(after.status).toBe("sent");
expect(after.image).toBeNull();
expect(after.sentAt).toBeTruthy();
expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 });
});
it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => {
await seed();
let status = 503;
const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status })));
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
await ob.enqueue(item, read);
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
let row = db.select().from(carwashReviewOutbox).all()[0]!;
expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" });
expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000);
// Not due yet → untouched.
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 });
// Due again: a network error defers too; a 422 abandons.
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
status = 0;
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
status = 422;
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
row = db.select().from(carwashReviewOutbox).all()[0]!;
expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" });
expect(row.image).toBeNull();
// A voided order is not a sample.
status = 200;
await ob.enqueue({ ...item, orderId: "o-1" }, read);
db.update(carwashOrders).set({ status: "void" }).run();
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
// Expired items are abandoned without a request.
await ob.enqueue(item, read);
db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run();
db.update(carwashOrders).set({ status: "open" }).run();
const before = fetchFn.mock.calls.length;
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
expect(fetchFn.mock.calls.length).toBe(before);
expect(ob.status().failed).toBe(3);
// Entry sampling: one in N entry reads becomes a package with the crop and the
// camera's class only — no order, no operator, no category.
const sampler = new ReviewOutbox(db, silentLogger(), { ...cfg, entrySample: 3 }, fetchFn);
expect([sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry()]).toEqual([false, false, true, false]);
expect(ob.sampleEntry()).toBe(false); // entrySample 0 = off
expect(await sampler.enqueueEntry(read)).toBe(true);
const entryRow = db.select().from(carwashReviewOutbox).where(eq(carwashReviewOutbox.orderId, "entry:snap-1")).get()!;
expect(entryRow.payload).toMatchObject({ v: 1, kind: "entry", booth: "booth-7", vision: { class: "car", confidence: 0.86 }, image: { plateBlurred: true } });
expect(entryRow.payload).not.toHaveProperty("operator");
expect(entryRow.payload).not.toHaveProperty("operatorCategory");
expect(entryRow.image!.length).toBeGreaterThan(500);
// No vehicle box, no snapshot, or upload off → nothing queued.
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false);
});
});
import { eq } from "@parking/db";
describe("through the app", () => {
let db: Db;
let close: () => void;
let app: FastifyInstance;
const saved = { ...process.env };
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
process.env.CARWASH_REVIEW_TOKEN = "tok";
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
process.env.CARWASH_REVIEW_ENTRY_SAMPLE = "1";
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID", "CARWASH_REVIEW_ENTRY_SAMPLE"]) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});
it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const a = await login(app, username, password);
const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf };
seedTariff(db);
const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json();
const cat = s.categories[0].id, svc = s.services[0].id;
await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } });
await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } });
db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
db.insert(deviceEvents).values({
id: "read-r", deviceId: "cam-1", category: "camera", kind: "read",
detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE },
occurredAt: new Date().toISOString(),
}).run();
const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } });
expect(order.statusCode).toBe(201);
// Enqueue is fire-and-forget: give the crop a moment.
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0, entrySample: 1 });
// An ENTRY vehicle read announced by the core (snapshot.ts) is sampled by the module
// (1 in 1 here) into an entry package; an exit read is not.
deviceEventBus.emitVehicleRead({ identity: "T-X", direction: "exit", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
deviceEventBus.emitVehicleRead({ identity: "T-R", direction: "entry", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(2));
const rows = db.select().from(carwashReviewOutbox).all();
expect(rows.map((r) => (r.payload as { kind: string }).kind).sort()).toEqual(["entry", "wash"]);
});
});
@@ -0,0 +1,376 @@
import { createHash, randomUUID } from "node:crypto";
import sharp from "sharp";
import { and, asc, carwashOrders, carwashReviewOutbox, eq, isNull, lte, or, snapshots, sql, type Db } from "@parking/db";
import type { NormBox, VehicleRead } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
// The Car Wash REVIEW OUTBOX — booth side (wiki/concepts/vision-review-outbox.md).
//
// The operator's category choice at intake is a HYPOTHESIS, not truth (the threat model:
// the operator may err or cheat). So every wash order that has a vehicle read queues a
// small package for a trusted remote reviewer: the vehicle CROP cut out of the entry
// snapshot with the plate BLURRED, the operator's choice, and what the camera thought.
// The reviewer's verdict becomes the training label for the body-type classifier (phase
// B) and, per operator, the honest-mistake / fraud rate.
//
// Rules that shape this file:
// - OFFLINE-FIRST: the wash never waits. Enqueue is fire-and-forget off the intake path;
// a background loop drains the queue when the private overlay (Netbird) is up, with
// backoff, and gives up loudly after EXPIRE_DAYS.
// - ONE-WAY: the booth POSTs; nothing ever comes back into the booth's decisions. The
// signed ledger stays the only record of what happened at the wash.
// - NOTHING THAT NAMES THE SITE LEAVES: only the crop (no walls, no camera OSD, no
// bystanders), the plate blurred in place, a per-booth pseudonymous id set at deploy,
// the operator as a keyed hash. The mapping back to people and places stays with the
// reviewer, off the collector.
// - THE NETWORK IS NOT THE AUTH: a per-booth bearer token on top of the overlay; the
// booth can do nothing at the collector but this one POST.
export interface ReviewUploadConfig {
/** The collector's ingest URL (reachable only over the overlay). */
readonly url: string;
/** Per-booth bearer token. */
readonly token: string;
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
readonly boothId: string;
readonly intervalSec: number;
/** Queue one in N ENTRY vehicle reads (no order attached) for the reviewer — the gate
* view is exactly what the classifier is trained on, and the entry stream is many times
* the wash stream. 0 = off. */
readonly entrySample: number;
}
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ReviewUploadConfig | null {
const url = (env.CARWASH_REVIEW_URL ?? "").trim();
const token = (env.CARWASH_REVIEW_TOKEN ?? "").trim();
const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
if (!url || !token || !boothId) return null;
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60);
const sample = Number(env.CARWASH_REVIEW_ENTRY_SAMPLE ?? 0);
return {
url, token, boothId,
intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60,
entrySample: Number.isInteger(sample) && sample > 0 ? sample : 0,
};
}
/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small
* enough that a day of washes is a few megabytes. */
export const CROP_MAX_EDGE = 640;
/** Margin around the detector's box, as a fraction of the box (context for the reviewer). */
const CROP_MARGIN = 0.08;
/** Items older than this are abandoned (failed "expired") — a booth cut off for two weeks
* should not resurface a fortnight of crops in one burst. */
export const EXPIRE_DAYS = 14;
/** Backoff: 1 min · 2^attempts, capped. */
const BACKOFF_BASE_MS = 60_000;
const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000;
const UPLOAD_TIMEOUT_MS = 20_000;
/** What one order contributes to the package (the service hands this over at intake). */
export interface ReviewItemInput {
readonly orderId: string;
readonly createdAt: string;
readonly createdBy: string;
readonly categoryId: string;
readonly categoryName: string;
/** The vision classes the chosen category covers at this site (its mapping) — lets the
* reviewer's class be judged against the operator's category without the site's setup. */
readonly categoryClasses: readonly string[];
readonly serviceName: string;
readonly visionCategoryId: string | null;
readonly downgraded: boolean;
}
/**
* Cut the vehicle out of the snapshot and blur the plate inside it. Boxes are fractions
* of the frame, so this works on the stored (downscaled) copy. Returns a JPEG.
*/
export async function makeReviewCrop(
snapshotBytes: Buffer,
box: NormBox,
plateBox: NormBox | null | undefined,
): Promise<{ bytes: Buffer; width: number; height: number; plateBlurred: boolean }> {
const img = sharp(snapshotBytes, { failOn: "none" }).rotate();
const meta = await img.metadata();
const W = meta.width ?? 0;
const H = meta.height ?? 0;
if (!W || !H) throw new Error("snapshot has no dimensions");
const px = (b: NormBox) => ({
left: Math.round(b.x1 * W), top: Math.round(b.y1 * H),
right: Math.round(b.x2 * W), bottom: Math.round(b.y2 * H),
});
const v = px(box);
const mw = Math.round((v.right - v.left) * CROP_MARGIN);
const mh = Math.round((v.bottom - v.top) * CROP_MARGIN);
const left = Math.max(0, v.left - mw);
const top = Math.max(0, v.top - mh);
const right = Math.min(W, v.right + mw);
const bottom = Math.min(H, v.bottom + mh);
const width = right - left;
const height = bottom - top;
if (width < 8 || height < 8) throw new Error("vehicle box too small to crop");
let crop = img.clone().extract({ left, top, width, height });
let plateBlurred = false;
if (plateBox) {
// The plate region, in CROP coordinates, padded a little so the blur eats the edges.
const p = px(plateBox);
const pad = Math.round(Math.max(p.right - p.left, p.bottom - p.top) * 0.25);
const pl = Math.max(0, p.left - pad - left);
const pt = Math.max(0, p.top - pad - top);
const pr = Math.min(width, p.right + pad - left);
const pb = Math.min(height, p.bottom + pad - top);
if (pr - pl >= 2 && pb - pt >= 2) {
const region = await sharp(await crop.clone().toBuffer())
.extract({ left: pl, top: pt, width: pr - pl, height: pb - pt })
.blur(Math.max(6, Math.round((pr - pl) / 6)))
.toBuffer();
crop = sharp(await crop.toBuffer()).composite([{ input: region, left: pl, top: pt }]);
plateBlurred = true;
}
}
const out = await crop
.resize({ width: CROP_MAX_EDGE, height: CROP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 85, mozjpeg: true })
.toBuffer({ resolveWithObject: true });
return { bytes: out.data, width: out.info.width, height: out.info.height, plateBlurred };
}
/** The operator as a keyed hash — stable per booth so the reviewer can count per person,
* meaningless anywhere else. */
export function operatorRef(boothId: string, username: string): string {
return createHash("sha256").update(`${boothId}:${username}`).digest("hex").slice(0, 16);
}
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
export interface OutboxStatus {
readonly enabled: boolean;
readonly boothId: string | null;
readonly queued: number;
readonly sent: number;
readonly failed: number;
readonly lastSentAt: string | null;
readonly lastError: string | null;
/** 0 = entry sampling off; N = one in N entry reads is queued. */
readonly entrySample: number;
}
export class ReviewOutbox {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #cfg: ReviewUploadConfig | null;
readonly #fetch: FetchLike;
#timer: NodeJS.Timeout | null = null;
#draining = false;
#entrySeen = 0;
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
this.#db = db;
this.#logger = logger;
this.#cfg = cfg;
this.#fetch = fetchFn ?? ((input, init) => fetch(input, init));
}
get enabled(): boolean {
return this.#cfg != null;
}
/** Queue one order's package. Fire-and-forget: the caller does NOT await this on the
* intake path; every failure is logged, none is thrown. Skipped when there is no
* vehicle box (nothing to crop — a frame without a detected vehicle is no training
* sample) or when upload is not configured (an unbounded queue nobody drains). */
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
if (!this.#cfg) return false;
return this.#queue(item.orderId, read, (id, crop) => ({
v: 1,
kind: "wash",
booth: this.#cfg!.boothId,
item: id,
order: item.orderId,
at: item.createdAt,
operator: operatorRef(this.#cfg!.boothId, item.createdBy),
operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] },
service: item.serviceName,
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
downgraded: item.downgraded,
image: crop,
}));
}
/** Every Nth entry read is a sample (N = entrySample); the caller queues it. Counted
* in-process, so "1 in 5" is exactly that across a booth's day. */
sampleEntry(): boolean {
const n = this.#cfg?.entrySample ?? 0;
if (n <= 0) return false;
this.#entrySeen += 1;
return this.#entrySeen % n === 0;
}
/** Queue an ENTRY sample: the crop and the camera's class only — no order, no operator,
* no category. Pure training material in the gate view; the reviewer labels it. */
async enqueueEntry(read: VehicleRead): Promise<boolean> {
if (!this.#cfg) return false;
return this.#queue(`entry:${read.snapshotId ?? "?"}`, read, (id, crop) => ({
v: 1,
kind: "entry",
booth: this.#cfg!.boothId,
item: id,
at: new Date().toISOString(),
vision: { class: read.bodyType, confidence: read.confidence },
image: crop,
}));
}
async #queue(
ref: string,
read: VehicleRead,
build: (id: string, image: { width: number; height: number; plateBlurred: boolean }) => Record<string, unknown>,
): Promise<boolean> {
if (!read.box || !read.snapshotId) return false;
try {
const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get();
if (!snap) {
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — ${ref} not queued`);
return false;
}
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
const id = randomUUID();
const payload = build(id, { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred });
this.#db
.insert(carwashReviewOutbox)
.values({ id, orderId: ref, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
.run();
return true;
} catch (err) {
this.#logger.warn(`carwash review: could not queue ${ref}: ${(err as Error).message}`);
return false;
}
}
start(): void {
if (!this.#cfg || this.#timer) return;
const tick = () => {
void this.drain().catch((err) => this.#logger.warn(`carwash review: drain failed: ${(err as Error).message}`));
};
this.#timer = setInterval(tick, this.#cfg.intervalSec * 1000);
this.#timer.unref?.();
setTimeout(tick, 5_000).unref?.();
}
stop(): void {
if (this.#timer) clearInterval(this.#timer);
this.#timer = null;
}
/** Send what is due, oldest first. Returns the tally; never throws for a single item. */
async drain(limit = 20): Promise<{ sent: number; failed: number; deferred: number }> {
const tally = { sent: 0, failed: 0, deferred: 0 };
if (!this.#cfg || this.#draining) return tally;
this.#draining = true;
try {
const now = new Date().toISOString();
const due = this.#db
.select()
.from(carwashReviewOutbox)
.where(and(eq(carwashReviewOutbox.status, "queued"), or(isNull(carwashReviewOutbox.nextAttemptAt), lte(carwashReviewOutbox.nextAttemptAt, now))))
.orderBy(asc(carwashReviewOutbox.createdAt))
.limit(limit)
.all();
for (const row of due) {
const outcome = await this.#send(row);
tally[outcome] += 1;
}
if (tally.sent || tally.failed) this.#logger.info(`carwash review: sent ${tally.sent}, failed ${tally.failed}, deferred ${tally.deferred}`);
} finally {
this.#draining = false;
}
return tally;
}
async #send(row: typeof carwashReviewOutbox.$inferSelect): Promise<"sent" | "failed" | "deferred"> {
const cfg = this.#cfg!;
const ageMs = Date.now() - Date.parse(row.createdAt);
if (ageMs > EXPIRE_DAYS * 24 * 60 * 60 * 1000) return this.#fail(row, `expired after ${EXPIRE_DAYS} days`);
// A wash voided before delivery is not a sample (and not a decision to review).
const order = this.#db.select({ status: carwashOrders.status }).from(carwashOrders).where(eq(carwashOrders.id, row.orderId)).get();
if (order?.status === "void") return this.#fail(row, "order voided");
if (!row.image) return this.#fail(row, "image missing");
const form = new FormData();
form.set("meta", JSON.stringify(row.payload));
form.set("image", new Blob([new Uint8Array(row.image)], { type: "image/jpeg" }), `${row.id}.jpg`);
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS);
try {
const res = await this.#fetch(cfg.url, {
method: "POST",
headers: { authorization: `Bearer ${cfg.token}`, "x-booth-id": cfg.boothId },
body: form,
signal: ac.signal,
});
if (res.ok) {
this.#db
.update(carwashReviewOutbox)
.set({ status: "sent", sentAt: new Date().toISOString(), image: null, lastError: null, attempts: row.attempts + 1 })
.where(eq(carwashReviewOutbox.id, row.id))
.run();
return "sent";
}
// The collector refused the package itself → no retry will help.
if ([400, 404, 413, 415, 422].includes(res.status)) return this.#fail(row, `rejected: HTTP ${res.status}`);
// Everything else (auth not yet fixed, throttled, collector down) → try again later.
return this.#defer(row, `HTTP ${res.status}`);
} catch (err) {
return this.#defer(row, (err as Error).name === "AbortError" ? "timeout" : (err as Error).message);
} finally {
clearTimeout(t);
}
}
#fail(row: typeof carwashReviewOutbox.$inferSelect, why: string): "failed" {
this.#db
.update(carwashReviewOutbox)
.set({ status: "failed", lastError: why, image: null, attempts: row.attempts + 1 })
.where(eq(carwashReviewOutbox.id, row.id))
.run();
this.#logger.warn(`carwash review: item ${row.id} (order ${row.orderId}) abandoned — ${why}`);
return "failed";
}
#defer(row: typeof carwashReviewOutbox.$inferSelect, why: string): "deferred" {
const attempts = row.attempts + 1;
const wait = Math.min(BACKOFF_BASE_MS * 2 ** Math.min(attempts, 20), BACKOFF_CAP_MS);
this.#db
.update(carwashReviewOutbox)
.set({ attempts, lastError: why, nextAttemptAt: new Date(Date.now() + wait).toISOString() })
.where(eq(carwashReviewOutbox.id, row.id))
.run();
return "deferred";
}
status(): OutboxStatus {
const count = (s: "queued" | "sent" | "failed") =>
this.#db.select({ n: sql<number>`count(*)` }).from(carwashReviewOutbox).where(eq(carwashReviewOutbox.status, s)).get()?.n ?? 0;
const lastSent = this.#db.select({ at: sql<string | null>`max(${carwashReviewOutbox.sentAt})` }).from(carwashReviewOutbox).get()?.at ?? null;
const lastErr = this.#db
.select({ e: carwashReviewOutbox.lastError })
.from(carwashReviewOutbox)
.where(sql`${carwashReviewOutbox.lastError} is not null`)
.orderBy(sql`coalesce(${carwashReviewOutbox.sentAt}, ${carwashReviewOutbox.nextAttemptAt}, ${carwashReviewOutbox.createdAt}) desc`)
.limit(1)
.get()?.e ?? null;
return {
enabled: this.enabled,
boothId: this.#cfg?.boothId ?? null,
entrySample: this.#cfg?.entrySample ?? 0,
queued: count("queued"),
sent: count("sent"),
failed: count("failed"),
lastSentAt: lastSent,
lastError: lastErr,
};
}
}
+119
View File
@@ -0,0 +1,119 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { Tender } from "@parking/shared";
import { requireAnyPermission, requirePermission } from "../../auth.js";
import { requireModule } from "../../modules.js";
import { NoShiftOpenError } from "../../shift-service.js";
import type { ServerModuleDeps } from "../index.js";
import type { ReviewOutbox } from "./review-outbox.js";
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
// FIRST (403 module_disabled), then a permission:
// settings (master data) site:read / site:update — the site admin's job
// queue / ticket lookup carwash:read — the wash desk
// intake carwash:create
// done / bay payment / void carwash:update
// The sponsorship PROGRAM itself is a validation program row (id "carwash") and is
// composed through the existing /api/validation/programs/:id route (site:update).
function sendError(reply: FastifyReply, err: unknown): FastifyReply {
if (err instanceof CarwashError) {
return reply.code(err.status).send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
}
if (err instanceof NoShiftOpenError) {
// The bay takes money on the CARWASH till: the wash operator's own shift must be
// open (the booth's does not count). The desk shows its shift control on this code.
return reply.code(409).send({ error: err.message, code: "no_shift", till: err.till });
}
throw err;
}
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService, outbox?: ReviewOutbox): Promise<void> {
const moduleOn = requireModule(deps.db, "carwash");
// The price list is the desk's working data as much as Setup's: the wash operator
// reads it under the module's own permission (the Wash operator job holds no site:*).
const settingsRead = [moduleOn, requireAnyPermission("carwash:read", "site:read")];
const settingsWrite = [moduleOn, requirePermission("site:update")];
const read = [moduleOn, requirePermission("carwash:read")];
const create = [moduleOn, requirePermission("carwash:create")];
const update = [moduleOn, requirePermission("carwash:update")];
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
// The review outbox's health (Setup → Car wash): how many decisions wait for the
// reviewer, how many went, the last error. Site admin's read.
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () =>
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null, entrySample: 0 },
);
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
try {
return await service.saveSettings(req.body ?? {}, req.user.username);
} catch (err) {
return sendError(reply, err);
}
});
app.get<{ Params: { identity: string } }>("/api/carwash/session/:identity", { preHandler: read }, async (req) =>
service.lookup(req.params.identity),
);
app.get<{ Querystring: { scope?: string; limit?: string } }>("/api/carwash/orders", { preHandler: read }, async (req) => {
if (req.query.scope === "recent") return { orders: service.recentOrders(Number(req.query.limit) || 100) };
return { orders: service.openOrders() };
});
app.post<{ Body: { identity?: string; categoryId?: string; serviceId?: string; payAt?: string } }>(
"/api/carwash/orders",
{ preHandler: create },
async (req, reply) => {
const b = req.body ?? {};
// payAt is a SITE setting now; the desk no longer sends it. Accept it only when it
// matches (the service refuses a mismatch) so a stale client cannot pick the till.
if (b.payAt !== undefined && !isPayAt(b.payAt)) return reply.code(400).send({ error: "payAt must be booth|bay" });
try {
const order = await service.createOrder({
identity: String(b.identity ?? ""),
categoryId: String(b.categoryId ?? ""),
serviceId: String(b.serviceId ?? ""),
...(b.payAt !== undefined ? { payAt: b.payAt } : {}),
actor: req.user.username,
});
return reply.code(201).send(order);
} catch (err) {
return sendError(reply, err);
}
},
);
app.post<{ Params: { id: string } }>("/api/carwash/orders/:id/done", { preHandler: update }, async (req, reply) => {
try {
return await service.markDone(req.params.id, req.user.username);
} catch (err) {
return sendError(reply, err);
}
});
app.post<{ Params: { id: string }; Body: { tender?: Tender } }>(
"/api/carwash/orders/:id/pay",
{ preHandler: update },
async (req, reply) => {
try {
return await service.payAtBay(req.params.id, (req.body?.tender ?? "cash") as Tender, req.user.username);
} catch (err) {
return sendError(reply, err);
}
},
);
app.post<{ Params: { id: string }; Body: { reason?: string } }>(
"/api/carwash/orders/:id/void",
{ preHandler: update },
async (req, reply) => {
try {
return await service.voidOrder(req.params.id, String(req.body?.reason ?? "").trim(), req.user.username);
} catch (err) {
return sendError(reply, err);
}
},
);
}
+765
View File
@@ -0,0 +1,765 @@
import { randomUUID } from "node:crypto";
import type { FastifyBaseLogger } from "fastify";
import {
and,
asc,
carwashCategories,
carwashConfig,
carwashOrders,
carwashPrices,
carwashServices,
desc,
eq,
inArray,
isNull,
type CarwashOrderRow,
type Db,
} from "@parking/db";
import {
CARWASH_PAY_AT,
CARWASH_PAY_AT_DEFAULT,
CARWASH_PROGRAM_ID,
type CarWashPayAt,
type CarwashOrderView,
type CarwashSettingsView,
type ChargeLine,
CARWASH_VISION_THRESHOLD_DEFAULT,
isVehicleClass,
reasonPayload,
type VehicleClass,
type VehicleRead,
type Tender,
type TillId,
} from "@parking/shared";
import type { EventLog } from "../../event-log.js";
import { vehicleForIdentity } from "../../plate-lookup.js";
import type { ReviewOutbox } from "./review-outbox.js";
import { effectiveModulesFor } from "../../modules.js";
import type { ChargeProvider, PayStation } from "../../pay-station.js";
import type { ShiftService } from "../../shift-service.js";
import { applyValidation, liveValidations } from "../../validations.js";
import type { ServerModuleDeps } from "../index.js";
// Car Wash — the module's whole behaviour (wiki/decisions/venue-modules.md, "Car Wash —
// the pilot module" + "v1 answers"). Master data is mutable rows; every order freezes
// what it sold (names + price) and signs its life onto the ledger; money at the bay is
// a signed `carwash_payment`; money at the booth rides the parking `payment` as a
// charge line (ChargeProvider below). The parking sponsorship is the site's "carwash"
// VALIDATION program, applied through the shared applyValidation() when a wash is done
// — the wash never touches parking code, it talks to the core through ServerModuleDeps.
/** A refusal the route maps to an HTTP status. */
/** The till bay money lands on — declared by the module manifest (MODULES). */
const CARWASH_TILL: TillId = "carwash";
export class CarwashError extends Error {
constructor(
readonly status: 400 | 404 | 409,
message: string,
readonly code?: string,
) {
super(message);
this.name = "CarwashError";
}
}
export interface SettingsBody {
categories?: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[];
services?: { id?: string; name?: string; active?: boolean }[];
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
/** Where wash money is taken at this site (site-level policy). */
payAt?: unknown;
visionThreshold?: unknown;
}
export interface CreateOrderInput {
identity: string;
categoryId: string;
serviceId: string;
/** Optional — the SITE policy decides; a stale client that sends a different value
* is refused (409 pay_at_policy) rather than silently overridden. */
payAt?: CarWashPayAt;
actor: string;
}
export interface TicketLookup {
identity: string;
found: boolean;
open: boolean;
subscription: boolean;
plate: string | null;
enteredAt: string | null;
currency: string | null;
orders: CarwashOrderView[];
/** What the camera saw at entry (advisory) and the category the site mapping
* suggests for it — the desk pre-selects it; the operator may change it. */
vision: VehicleRead | null;
suggestedCategoryId: string | null;
}
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
/** Stable slug for a new master-data row: from the name, else a random id. */
function slugify(name: string): string {
const s = name
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
return s || randomUUID();
}
export class CarwashService {
readonly #db: Db;
readonly #log: EventLog;
readonly #pay: PayStation;
readonly #shift: ShiftService;
readonly #logger: FastifyBaseLogger;
readonly #outbox: ReviewOutbox | null;
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) {
this.#db = deps.db;
this.#log = deps.eventLog;
this.#pay = deps.payStation;
this.#shift = deps.shiftService;
this.#logger = logger;
this.#outbox = outbox;
}
#enabled(): boolean {
return effectiveModulesFor(this.#db).includes("carwash");
}
// --- Settings (master data) -------------------------------------------------
settings(): CarwashSettingsView {
const categories = this.#db
.select()
.from(carwashCategories)
.where(isNull(carwashCategories.deletedAt))
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
.all()
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active, visionClasses: r.visionClasses.filter(isVehicleClass) }));
const services = this.#db
.select()
.from(carwashServices)
.where(isNull(carwashServices.deletedAt))
.orderBy(asc(carwashServices.sortOrder), asc(carwashServices.name))
.all()
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
const live = new Set([...categories.map((c) => c.id), ...services.map((s) => s.id)]);
const prices = this.#db
.select()
.from(carwashPrices)
.all()
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
.map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor }));
return { categories, services, prices, currency: this.#currency(), payAt: this.payAt(), visionThreshold: this.visionThreshold() };
}
/** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */
payAt(): CarWashPayAt {
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
return row?.payAt ?? CARWASH_PAY_AT_DEFAULT;
}
/** Confidence floor for a vision class to flag a category downgrade (site config). */
visionThreshold(): number {
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
return row?.visionThreshold ?? CARWASH_VISION_THRESHOLD_DEFAULT;
}
/** The category the site mapping suggests for a vision class (first active category
* listing it, in display order), or null when unmapped. */
#categoryForClass(cls: VehicleClass): { id: string; name: string } | null {
const rows = this.#db
.select()
.from(carwashCategories)
.where(isNull(carwashCategories.deletedAt))
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
.all();
const hit = rows.find((r) => r.active && r.visionClasses.includes(cls));
return hit ? { id: hit.id, name: hit.name } : null;
}
/** The site's currency = the active tariff's (the wash is priced in the same money
* the booth takes). null when no tariff is published yet. */
#currency(): string | null {
try {
// Any open session's quote carries it; without one, fall back to the tariff table.
const row = this.#db.select().from(carwashOrders).orderBy(desc(carwashOrders.createdAt)).limit(1).get();
if (row) return row.currency;
} catch {
/* fall through */
}
return this.#pay.activeCurrency();
}
/** Full-replacement save of the three lists. Rows missing from the body are
* soft-deleted (orders already reference names + prices by value, so nothing
* historical changes). Signs one config_change. */
async saveSettings(body: SettingsBody, actor: string): Promise<CarwashSettingsView> {
const now = new Date().toISOString();
const upsertList = (
table: typeof carwashCategories | typeof carwashServices,
items: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[] | undefined,
label: string,
): string[] => {
if (items === undefined) {
return this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all().map((r) => r.id);
}
if (!Array.isArray(items)) throw new CarwashError(400, `${label} must be an array`);
const keep: string[] = [];
let sort = 0;
const seen = new Set<string>();
for (const it of items) {
const name = String(it?.name ?? "").trim();
if (!name) throw new CarwashError(400, `${label}: every item needs a name`);
let id = typeof it.id === "string" && it.id.trim() ? it.id.trim() : slugify(name);
if (!ID_RE.test(id)) throw new CarwashError(400, `${label}: bad id "${id}"`);
// Two new items slugging to the same id → disambiguate rather than merge.
while (seen.has(id)) id = `${id}-${sort}`;
seen.add(id);
const active = it.active !== false;
// Vision mapping lives on CATEGORIES only; absent = keep what the row has.
let visionClasses: string[] | undefined;
if (table === carwashCategories && it.visionClasses !== undefined) {
if (!Array.isArray(it.visionClasses) || !it.visionClasses.every(isVehicleClass)) {
throw new CarwashError(400, `${label}: visionClasses must be an array of vehicle classes`);
}
visionClasses = [...new Set(it.visionClasses as string[])];
}
const existing = this.#db.select().from(table).where(eq(table.id, id)).get();
if (existing) {
this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null, ...(visionClasses ? { visionClasses } : {}) }).where(eq(table.id, id)).run();
} else {
this.#db.insert(table).values({ id, name, sortOrder: sort, active, ...(visionClasses ? { visionClasses } : {}) }).run();
}
keep.push(id);
sort += 1;
}
const live = this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all();
for (const r of live) {
if (!keep.includes(r.id)) {
this.#db.update(table).set({ deletedAt: now, deletedBy: actor }).where(eq(table.id, r.id)).run();
}
}
return keep;
};
const categoryIds = upsertList(carwashCategories, body.categories, "categories");
const serviceIds = upsertList(carwashServices, body.services, "services");
if (body.prices !== undefined) {
if (!Array.isArray(body.prices)) throw new CarwashError(400, "prices must be an array");
const rows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
for (const p of body.prices) {
const categoryId = String(p?.categoryId ?? "");
const serviceId = String(p?.serviceId ?? "");
const priceMinor = p?.priceMinor;
if (!categoryIds.includes(categoryId)) throw new CarwashError(400, `prices: unknown category "${categoryId}"`);
if (!serviceIds.includes(serviceId)) throw new CarwashError(400, `prices: unknown service "${serviceId}"`);
if (!Number.isInteger(priceMinor) || (priceMinor as number) < 0) {
throw new CarwashError(400, "prices: priceMinor must be a non-negative integer");
}
rows.push({ categoryId, serviceId, priceMinor: priceMinor as number });
}
this.#db.delete(carwashPrices).run();
for (const r of rows) this.#db.insert(carwashPrices).values(r).run();
}
await this.#log.append({
type: "config_change",
source: "manual",
identity: "module:carwash",
payload: {
setting: "carwash.settings",
value: { categories: categoryIds.length, services: serviceIds.length, prices: body.prices?.length ?? null },
operator: actor,
},
});
// Where the money is taken — a site policy, signed on its own when it flips (it
// decides which till the cash lands on and whether the booth barrier or the exit
// reader releases the car; fraud-relevant, so it is attributed like other config).
if (body.payAt !== undefined) {
if (!isPayAt(body.payAt)) throw new CarwashError(400, "payAt must be booth|bay");
const prev = this.payAt();
if (body.payAt !== prev) {
this.#db
.insert(carwashConfig)
.values({ id: 1, payAt: body.payAt, updatedAt: now, updatedBy: actor })
.onConflictDoUpdate({ target: carwashConfig.id, set: { payAt: body.payAt, updatedAt: now, updatedBy: actor } })
.run();
await this.#log.append({
type: "config_change",
source: "manual",
identity: "module:carwash",
payload: { setting: "carwash.payAt", value: body.payAt, prev, operator: actor },
});
}
}
if (body.visionThreshold !== undefined) {
const v = Number(body.visionThreshold);
if (!Number.isFinite(v) || v < 0 || v > 1) throw new CarwashError(400, "visionThreshold must be between 0 and 1");
const prev = this.visionThreshold();
if (v !== prev) {
this.#db
.insert(carwashConfig)
.values({ id: 1, visionThreshold: v, updatedAt: now, updatedBy: actor })
.onConflictDoUpdate({ target: carwashConfig.id, set: { visionThreshold: v, updatedAt: now, updatedBy: actor } })
.run();
await this.#log.append({
type: "config_change",
source: "manual",
identity: "module:carwash",
payload: { setting: "carwash.visionThreshold", value: v, prev, operator: actor },
});
}
}
return this.settings();
}
// --- Orders ---------------------------------------------------------------------
#view(r: CarwashOrderRow): CarwashOrderView {
return {
id: r.id,
identity: r.identity,
plate: r.plate,
categoryId: r.categoryId,
categoryName: r.categoryName,
serviceId: r.serviceId,
serviceName: r.serviceName,
priceMinor: r.priceMinor,
currency: r.currency,
payAt: r.payAt,
status: r.status,
createdAt: r.createdAt,
createdBy: r.createdBy,
doneAt: r.doneAt,
doneBy: r.doneBy,
paidAt: r.paidAt,
paidBy: r.paidBy,
tender: (r.tender as Tender | null) ?? null,
closed: r.status === "void" || (r.status === "done" && r.paidAt != null),
validationEventId: r.validationEventId,
voidBy: r.voidBy,
voidReason: r.voidReason,
visionClass: isVehicleClass(r.visionClass) ? r.visionClass : null,
visionConfidence: r.visionConfidence,
visionCategoryId: r.visionCategoryId,
downgradeEventId: r.downgradeEventId,
};
}
#row(id: string): CarwashOrderRow {
const r = this.#db.select().from(carwashOrders).where(eq(carwashOrders.id, id)).get();
if (!r) throw new CarwashError(404, "order not found");
return r;
}
/** The desk's queue: every order still needing something, oldest first. */
openOrders(): CarwashOrderView[] {
return this.#db
.select()
.from(carwashOrders)
.where(inArray(carwashOrders.status, ["open", "done"]))
.orderBy(asc(carwashOrders.createdAt))
.all()
.map((r) => this.#view(r))
.filter((o) => !o.closed);
}
/** Recent history (closed included), newest first. */
recentOrders(limit = 100): CarwashOrderView[] {
return this.#db
.select()
.from(carwashOrders)
.orderBy(desc(carwashOrders.createdAt))
.limit(Math.min(Math.max(limit, 1), 500))
.all()
.map((r) => this.#view(r));
}
#ordersFor(identity: string): CarwashOrderView[] {
return this.#db
.select()
.from(carwashOrders)
.where(eq(carwashOrders.identity, identity))
.orderBy(asc(carwashOrders.createdAt))
.all()
.map((r) => this.#view(r));
}
/** Ticket → session facts the desk needs (the parking ticket IS the customer). */
lookup(identity: string): TicketLookup {
const id = identity.trim();
const s = this.#pay.lookup(id);
const vision = s.found ? vehicleForIdentity(this.#db, id) : null;
return {
identity: id,
found: s.found,
open: s.open,
subscription: s.subscription,
plate: s.plate,
enteredAt: s.enteredAt,
currency: s.currency,
orders: this.#ordersFor(id),
vision,
suggestedCategoryId: vision ? (this.#categoryForClass(vision.bodyType)?.id ?? null) : null,
};
}
async createOrder(input: CreateOrderInput): Promise<CarwashOrderView> {
const identity = input.identity.trim();
if (!identity) throw new CarwashError(400, "identity (ticket) required");
const s = this.#pay.lookup(identity);
if (!s.found) throw new CarwashError(404, "no session for ticket");
if (!s.open) throw new CarwashError(409, "session is closed");
if (s.subscription) throw new CarwashError(409, "subscription sessions: order the wash with payAt=bay", "subscription");
const category = this.#db
.select()
.from(carwashCategories)
.where(and(eq(carwashCategories.id, input.categoryId), isNull(carwashCategories.deletedAt)))
.get();
if (!category || !category.active) throw new CarwashError(404, "category not found or inactive");
const service = this.#db
.select()
.from(carwashServices)
.where(and(eq(carwashServices.id, input.serviceId), isNull(carwashServices.deletedAt)))
.get();
if (!service || !service.active) throw new CarwashError(404, "service not found or inactive");
const price = this.#db
.select()
.from(carwashPrices)
.where(and(eq(carwashPrices.categoryId, category.id), eq(carwashPrices.serviceId, service.id)))
.get();
if (!price) throw new CarwashError(409, `no price for ${category.name} · ${service.name}`, "no_price");
// The SITE decides where wash money is taken (Setup → Car wash); the order freezes
// the policy in force. A client that still sends a different value is stale.
const payAt = this.payAt();
if (input.payAt !== undefined && input.payAt !== payAt) {
throw new CarwashError(409, `this site takes wash money at the ${payAt === "bay" ? "bay" : "booth"}`, "pay_at_policy");
}
const currency = s.currency ?? this.#pay.activeCurrency();
if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff");
// Vision, advisory: what the camera saw at entry and the category the site maps it
// to. A DOWNGRADE — the operator chose a category that prices LOWER than the mapped
// one for this service, with the read above the site threshold — is signed as an
// anomaly for the reviewer (both categories, operator, snapshot). Recorded only:
// never blocks, no reason prompt (user, 2026-09-06).
const vision = vehicleForIdentity(this.#db, identity);
const visionCategory = vision ? this.#categoryForClass(vision.bodyType) : null;
let downgradeEventId: string | null = null;
if (vision && visionCategory && visionCategory.id !== category.id && vision.confidence >= this.visionThreshold()) {
const visionPrice = this.#db
.select()
.from(carwashPrices)
.where(and(eq(carwashPrices.categoryId, visionCategory.id), eq(carwashPrices.serviceId, service.id)))
.get();
if (visionPrice && visionPrice.priceMinor > price.priceMinor) {
const ev = await this.#log.append({
type: "anomaly",
source: "manual",
identity,
payload: {
...reasonPayload("carwash.categoryDowngrade", {
visionClass: vision.bodyType,
visionCategory: visionCategory.name,
operator: input.actor,
chosenCategory: category.name,
}),
sessionRef: identity,
visionClass: vision.bodyType,
visionConfidence: vision.confidence,
visionCategoryId: visionCategory.id,
visionCategoryName: visionCategory.name,
chosenCategoryId: category.id,
chosenCategoryName: category.name,
serviceName: service.name,
visionPriceMinor: visionPrice.priceMinor,
chosenPriceMinor: price.priceMinor,
currency,
snapshotId: vision.snapshotId,
operator: input.actor,
},
});
downgradeEventId = ev.id;
}
}
const now = new Date().toISOString();
const row: CarwashOrderRow = {
id: randomUUID(),
identity,
plate: s.plate,
categoryId: category.id,
categoryName: category.name,
serviceId: service.id,
serviceName: service.name,
priceMinor: price.priceMinor,
currency,
payAt,
status: "open",
createdAt: now,
createdBy: input.actor,
doneAt: null,
doneBy: null,
paidAt: null,
paidBy: null,
tender: null,
paymentEventId: null,
validationEventId: null,
voidAt: null,
voidBy: null,
voidReason: null,
visionClass: vision?.bodyType ?? null,
visionConfidence: vision?.confidence ?? null,
visionCategoryId: visionCategory?.id ?? null,
downgradeEventId,
};
this.#db.insert(carwashOrders).values(row).run();
// Hand the decision to the remote reviewer (crop + choice), off the intake path.
if (vision && this.#outbox?.enabled) {
void this.#outbox.enqueue(
{
orderId: row.id,
createdAt: now,
createdBy: input.actor,
categoryId: category.id,
categoryName: category.name,
categoryClasses: category.visionClasses,
serviceName: service.name,
visionCategoryId: visionCategory?.id ?? null,
downgraded: downgradeEventId != null,
},
vision,
);
}
await this.#log.append({
type: "carwash_order",
source: "manual",
identity,
payload: {
sessionRef: identity,
orderId: row.id,
action: "created",
categoryName: row.categoryName,
serviceName: row.serviceName,
priceMinor: row.priceMinor,
currency,
payAt: row.payAt,
operator: input.actor,
},
});
return this.#view(row);
}
/** The wash is finished: apply the site's sponsorship program to the parking session
* (if one is configured and active), then — for a bay order already paid — settle
* the parking session so the exit reader opens. */
async markDone(id: string, actor: string): Promise<CarwashOrderView> {
const r = this.#row(id);
if (r.status === "void") throw new CarwashError(409, "order is void");
if (r.status === "done") throw new CarwashError(409, "order is already done");
const now = new Date().toISOString();
let validationEventId: string | null = null;
// Wash context for the wash-only discount modes: the WASH WINDOW in minutes — from
// the order's intake to now (= done) — and the order's frozen price. NOT the time
// since entry: a car parked for hours before it asks for a wash still pays for those
// hours (found 2026-09-05 on a long-open ticket that would have been fully comped).
// The credit lands at the start of the billed period (that is how timeCredit
// folds), so for a flat tariff the money is identical; a stepped/daily-cap tariff
// may differ by an increment. See applyValidation().
const washMinutes = Math.max(0, Math.ceil((Date.now() - Date.parse(r.createdAt)) / 60_000));
const applied = await applyValidation(this.#db, this.#log, {
programId: CARWASH_PROGRAM_ID,
identity: r.identity,
actor,
wash: { washMinutes, priceMinor: r.priceMinor },
});
if (applied.ok) validationEventId = applied.eventId;
else if (applied.status !== 404 && !/already applied/.test(applied.error)) {
// A real refusal (session closed, daily cap …) — the wash is still done; the
// customer simply gets no sponsorship. Keep it visible in the log.
this.#logger.warn(`carwash sponsorship not applied for ${r.identity}: ${applied.error}`);
}
this.#db
.update(carwashOrders)
.set({ status: "done", doneAt: now, doneBy: actor, validationEventId })
.where(eq(carwashOrders.id, id))
.run();
await this.#log.append({
type: "carwash_order",
source: "manual",
identity: r.identity,
payload: {
sessionRef: r.identity,
orderId: id,
action: "done",
categoryName: r.categoryName,
serviceName: r.serviceName,
priceMinor: r.priceMinor,
currency: r.currency,
payAt: r.payAt,
...(validationEventId ? { validationEventId } : {}),
operator: actor,
},
});
const updated = this.#row(id);
if (updated.payAt === "bay" && updated.paidAt != null) await this.#settleParkingIfFree(updated, actor);
return this.#view(updated);
}
/** Money taken AT THE BAY. Needs an open CARWASH shift (it is the wash operator's
* drawer money, never the booth's — wiki/concepts/shift.md "Tills"); signs a
* carwash_payment on that till; then, if the wash is also done, settles the
* parking session. */
async payAtBay(id: string, tender: Tender, actor: string): Promise<CarwashOrderView> {
const r = this.#row(id);
if (r.status === "void") throw new CarwashError(409, "order is void");
if (r.payAt !== "bay") throw new CarwashError(409, "this order is paid at the booth", "pay_at_booth");
if (r.paidAt != null) throw new CarwashError(409, "order is already paid");
if (tender !== "cash" && tender !== "card") throw new CarwashError(400, "tender must be cash|card");
this.#shift.requireOpenShift(CARWASH_TILL);
const ev = await this.#log.append({
type: "carwash_payment",
source: "manual",
identity: r.identity,
payload: {
sessionRef: r.identity,
orderId: id,
amountMinor: r.priceMinor,
currency: r.currency,
tender,
till: CARWASH_TILL,
categoryName: r.categoryName,
serviceName: r.serviceName,
operator: actor,
},
});
const now = new Date().toISOString();
this.#db
.update(carwashOrders)
.set({ paidAt: now, paidBy: actor, tender, paymentEventId: ev.id })
.where(eq(carwashOrders.id, id))
.run();
const updated = this.#row(id);
if (updated.status === "done") await this.#settleParkingIfFree(updated, actor, tender);
return this.#view(updated);
}
/** A bay-paid, done wash: if the sponsorship made the parking session zero-due, sign
* the $0 parking payment now — that is what the exit READER checks (a validation
* alone opens nothing; see exit-flow.ts). A remaining balance stays for the booth. */
async #settleParkingIfFree(r: CarwashOrderRow, actor: string, tender: Tender = "cash"): Promise<void> {
try {
const s = this.#pay.lookup(r.identity);
if (!s.open || s.subscription || s.paidAt != null) return;
const q = this.#pay.quote(r.identity);
if (q.amountMinor !== 0) return;
await this.#pay.pay(r.identity, tender);
this.#logger.info(`carwash: parking session ${r.identity} settled at zero after bay payment (by ${actor})`);
} catch (err) {
this.#logger.warn(`carwash: could not settle parking for ${r.identity}: ${(err as Error).message}`);
}
}
async voidOrder(id: string, reason: string, actor: string): Promise<CarwashOrderView> {
const r = this.#row(id);
if (r.status === "void") throw new CarwashError(409, "order is already void");
if (r.paidAt != null) throw new CarwashError(409, "a paid order cannot be voided", "paid");
const now = new Date().toISOString();
// Take back the sponsorship if it is still live (not consumed by a payment).
if (r.validationEventId) {
const live = liveValidations(this.#db, r.identity).find((v) => v.eventId === r.validationEventId);
if (live) {
await this.#log.append({
type: "validation",
source: "manual",
identity: r.identity,
payload: {
sessionRef: r.identity,
refId: r.validationEventId,
programId: live.programId,
programLabel: live.label,
operator: actor,
},
});
}
}
this.#db
.update(carwashOrders)
.set({ status: "void", voidAt: now, voidBy: actor, voidReason: reason || null })
.where(eq(carwashOrders.id, id))
.run();
await this.#log.append({
type: "carwash_order",
source: "manual",
identity: r.identity,
payload: {
sessionRef: r.identity,
orderId: id,
action: "void",
categoryName: r.categoryName,
serviceName: r.serviceName,
priceMinor: r.priceMinor,
currency: r.currency,
payAt: r.payAt,
reason: reason || undefined,
operator: actor,
},
});
return this.#view(this.#row(id));
}
// --- Booth settlement hook ------------------------------------------------------
/** Orders with payAt = "booth" ride the parking payment as charge lines; the core
* calls back after the payment is signed so they are marked paid. Off = no lines. */
chargeProvider(): ChargeProvider {
return {
lines: (identity) => {
if (!this.#enabled()) return [];
return this.#db
.select()
.from(carwashOrders)
.where(and(eq(carwashOrders.identity, identity), eq(carwashOrders.payAt, "booth"), isNull(carwashOrders.paidAt)))
.all()
.filter((r) => r.status !== "void")
.map((r) => ({
module: "carwash" as const,
ref: r.id,
label: `Lavazh — ${r.categoryName} · ${r.serviceName}`,
amountMinor: r.priceMinor,
}));
},
onPaid: async (_identity, lines, payment) => {
const now = new Date().toISOString();
for (const l of lines) {
if (l.module !== "carwash") continue;
this.#db
.update(carwashOrders)
.set({ paidAt: now, paidBy: payment.operator ?? "booth", tender: payment.tender, paymentEventId: payment.eventId })
.where(and(eq(carwashOrders.id, l.ref), isNull(carwashOrders.paidAt)))
.run();
}
},
};
}
}
/** Type guard for the void body etc. */
export function isPayAt(v: unknown): v is CarWashPayAt {
return typeof v === "string" && (CARWASH_PAY_AT as readonly string[]).includes(v);
}
+61
View File
@@ -0,0 +1,61 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { MODULES, parseEntitledModules, type ModuleId } from "@parking/shared";
import type { EventLog } from "../event-log.js";
import type { PayStation } from "../pay-station.js";
import type { ShiftService } from "../shift-service.js";
import { effectiveModulesFor } from "../modules.js";
import { carwashModule } from "./carwash/index.js";
import { validationModule } from "./validation/index.js";
// The server-side module registry. A module's routes live in its own folder
// (apps/server/src/modules/<id>/index.ts) and are registered by iterating
// @parking/shared's MODULES — so adding a module is one manifest entry + one folder +
// one line in SERVER_MODULES below, with nothing else in the core touched
// (wiki/decisions/venue-modules.md, "A module = a manifest + three folders").
//
// `parking` is registered in the manifest but has NO folder yet: its routes are still
// the flat list in server.ts. That is deliberate — the seam is drawn, the code moves
// across it subsystem by subsystem as each is touched, not in one big move.
/** What the core hands a module at registration. Modules reach the core ONLY through
* these (never by importing another module): the DB, the signed ledger, the booth
* settlement (to fold charges in / settle a session — PayStation.registerChargeProvider,
* quote, pay) and the shift service (money needs an open shift). */
export interface ServerModuleDeps {
db: Db;
eventLog: EventLog;
payStation: PayStation;
shiftService: ShiftService;
}
export interface ServerModule {
id: ModuleId;
register(app: FastifyInstance, deps: ServerModuleDeps): Promise<void>;
}
const SERVER_MODULES: Partial<Record<ModuleId, ServerModule>> = {
validation: validationModule,
carwash: carwashModule,
};
/** Register every folder-based module in registry order, then log what this site
* is entitled to / has effective, so a "why is X missing" question is answerable
* from the container log alone. */
export async function registerModules(app: FastifyInstance, deps: ServerModuleDeps): Promise<void> {
for (const manifest of MODULES) {
const impl = SERVER_MODULES[manifest.id];
if (impl) {
if (impl.id !== manifest.id) throw new Error(`module registry mismatch: ${impl.id} registered under ${manifest.id}`);
await impl.register(app, deps);
}
}
const { entitled, unknown } = parseEntitledModules(process.env.MODULES_ENTITLED);
if (unknown.length > 0) {
app.log.warn({ unknown }, "MODULES_ENTITLED names unknown module ids — ignored");
}
app.log.info(
{ entitled, effective: effectiveModulesFor(deps.db) },
"venue modules (entitled = MODULES_ENTITLED env; effective = entitled ∩ site activation)",
);
}
@@ -0,0 +1,13 @@
import { validationRoutes } from "../../routes/validations.js";
import type { ServerModule } from "../index.js";
// Merchant-scan ticket validation as a venue module. Kept for the Bar until a Bar
// module absorbs it (wiki/decisions/venue-modules.md, decision 1). The routes
// themselves still live in routes/validations.ts (unchanged location, now guarded by
// requireModule("validation")); this folder is the registry hook.
export const validationModule: ServerModule = {
id: "validation",
async register(app, { db, eventLog }) {
await validationRoutes(app, db, eventLog);
},
};
+85 -4
View File
@@ -1,5 +1,5 @@
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db"; import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared"; import { BOOTH_TILL, priceSession, type ChargeLine, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js"; import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
@@ -29,6 +29,18 @@ export class NoTariffError extends Error {
} }
} }
/**
* A module that folds its own charges into a booth settlement (venue-modules.md):
* `lines(identity)` returns the open charges for the session (e.g. wash orders with
* payAt = "booth"); after the `payment` is signed, `onPaid` lets the module mark them
* settled. Registered by the module at boot (registerChargeProvider) — PayStation
* never imports a module.
*/
export interface ChargeProvider {
lines(identity: string): ChargeLine[];
onPaid(identity: string, lines: ChargeLine[], payment: { eventId: string; tender: Tender; operator?: string }): Promise<void>;
}
export interface Quote { export interface Quote {
readonly identity: string; readonly identity: string;
/** Vehicle entry time (the session's original entry; for display/audit). */ /** Vehicle entry time (the session's original entry; for display/audit). */
@@ -39,8 +51,14 @@ export interface Quote {
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT * is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
* "full stay minus paid" (which a daily cap collapses toward zero). */ * "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string; readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */ /** Amount owed now: the parking fee for [periodStart → now] NET of merchant
* validations, PLUS any module charge lines (a wash paid at the booth). */
readonly amountMinor: number; readonly amountMinor: number;
/** The parking-only net (amountMinor − chargesMinor). */
readonly parkingMinor: number;
/** Non-parking charges folded in by modules (see ChargeProvider). */
readonly chargeLines: ChargeLine[];
readonly chargesMinor: number;
/** The pre-validation fee (= amountMinor when no validations apply). */ /** The pre-validation fee (= amountMinor when no validations apply). */
readonly grossMinor: number; readonly grossMinor: number;
/** Total the merchant validations took off (gross − net). */ /** Total the merchant validations took off (gross − net). */
@@ -133,12 +151,16 @@ export interface SessionLookup {
readonly grossMinor: number | null; readonly grossMinor: number | null;
readonly discountMinor: number | null; readonly discountMinor: number | null;
readonly validationLines: ValidationLine[]; readonly validationLines: ValidationLine[];
/** Module charge lines folded into `amountMinor` (e.g. a wash paid at the booth). */
readonly chargeLines: ChargeLine[];
readonly chargesMinor: number | null;
} }
export class PayStation { export class PayStation {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
readonly #chargeProviders: ChargeProvider[] = [];
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) { constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db; this.#db = db;
@@ -146,6 +168,30 @@ export class PayStation {
this.#logger = logger; this.#logger = logger;
} }
/** Let a module fold its charges into booth settlements (see ChargeProvider). */
registerChargeProvider(p: ChargeProvider): void {
this.#chargeProviders.push(p);
}
/** The currency of the tariff in force right now (null = none published). Modules
* price their own goods in the same money the booth takes. */
activeCurrency(): string | null {
return this.#tariffVersionFor(new Date().toISOString())?.currency ?? null;
}
#chargeLines(identity: string): ChargeLine[] {
const out: ChargeLine[] = [];
for (const p of this.#chargeProviders) {
try {
out.push(...p.lines(identity));
} catch (err) {
// A module's fault must never block a parking settlement — log and price without it.
this.#logger.error(`charge provider failed for ${identity}: ${(err as Error).message}`);
}
}
return out;
}
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a /** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period * paid session whose walk-back grace has lapsed (the car re-parked, or a new period
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its * began) — the customer is billed for a FRESH period from grace-expiry→now, with its
@@ -184,11 +230,16 @@ export class PayStation {
category, category,
validations, validations,
); );
const chargeLines = this.#chargeLines(identity);
const chargesMinor = chargeLines.reduce((sum, l) => sum + l.amountMinor, 0);
return { return {
identity, identity,
enteredAt: entry.occurredAt, enteredAt: entry.occurredAt,
periodStart: p.periodStart, periodStart: p.periodStart,
amountMinor: p.amountMinor, amountMinor: p.amountMinor + chargesMinor,
parkingMinor: p.amountMinor,
chargeLines,
chargesMinor,
grossMinor: p.grossMinor, grossMinor: p.grossMinor,
discountMinor: p.discountMinor, discountMinor: p.discountMinor,
validationLines: p.validationLines, validationLines: p.validationLines,
@@ -246,6 +297,7 @@ export class PayStation {
amountMinor, amountMinor,
currency: subWindow.currency ?? undefined, currency: subWindow.currency ?? undefined,
tender, tender,
till: BOOTH_TILL,
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}), ...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
subscriptionWindowCharge: true, subscriptionWindowCharge: true,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}), ...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
@@ -258,7 +310,7 @@ export class PayStation {
const q = this.quote(identity); const q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor; const amountMinor = overrideMinor ?? q.amountMinor;
await this.#log.append({ const paymentEvent = await this.#log.append({
type: "payment", type: "payment",
source: "manual", source: "manual",
identity, identity,
@@ -267,7 +319,19 @@ export class PayStation {
amountMinor, amountMinor,
currency: q.currency, currency: q.currency,
tender, tender,
// Parking money is BOOTH money (a wash paid at the booth rides along as
// chargeLines, so it is booth money too). See wiki/concepts/shift.md "Tills".
till: BOOTH_TILL,
tariffVersionId: q.tariffVersionId, tariffVersionId: q.tariffVersionId,
// Module charges (e.g. a wash paid at the booth): frozen as lines so the
// receipt reproduces and reporting can split parking from the rest.
...(q.chargeLines.length
? {
chargeLines: q.chargeLines.map((l) => ({ ...l })),
chargesMinor: q.chargesMinor,
parkingMinor: q.parkingMinor,
}
: {}),
// The exit flow reads graceExitMin off the payment to validate the // The exit flow reads graceExitMin off the payment to validate the
// walk-back window without re-resolving the tariff. // walk-back window without re-resolving the tariff.
graceExitMin: q.graceExitMin, graceExitMin: q.graceExitMin,
@@ -294,6 +358,17 @@ export class PayStation {
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`); this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
} }
// Let each module mark the charge lines it contributed as settled by this payment.
if (q.chargeLines.length) {
for (const p of this.#chargeProviders) {
try {
await p.onPaid(identity, q.chargeLines, { eventId: paymentEvent.id, tender });
} catch (err) {
this.#logger.error(`charge provider onPaid failed for ${identity}: ${(err as Error).message}`);
}
}
}
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`); this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
return { amountMinor, currency: q.currency }; return { amountMinor, currency: q.currency };
} }
@@ -320,6 +395,7 @@ export class PayStation {
withinGrace: false, graceExpiresAt: null, withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null, overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
grossMinor: null, discountMinor: null, validationLines: [], grossMinor: null, discountMinor: null, validationLines: [],
chargeLines: [], chargesMinor: null,
}; };
} }
// Subscription occurrence? The entry payload carries permit:true + permitId. // Subscription occurrence? The entry payload carries permit:true + permitId.
@@ -360,6 +436,8 @@ export class PayStation {
let grossMinor: number | null = null; let grossMinor: number | null = null;
let discountMinor: number | null = null; let discountMinor: number | null = null;
let validationLines: ValidationLine[] = []; let validationLines: ValidationLine[] = [];
let chargeLines: ChargeLine[] = [];
let chargesMinor: number | null = null;
if (open && !isSubscription) { if (open && !isSubscription) {
try { try {
const q = this.quote(id); const q = this.quote(id);
@@ -368,6 +446,8 @@ export class PayStation {
grossMinor = q.grossMinor; grossMinor = q.grossMinor;
discountMinor = q.discountMinor; discountMinor = q.discountMinor;
validationLines = q.validationLines; validationLines = q.validationLines;
chargeLines = q.chargeLines;
chargesMinor = q.chargesMinor;
} catch { } catch {
/* no active tariff — leave null; modal shows session without a price */ /* no active tariff — leave null; modal shows session without a price */
} }
@@ -389,6 +469,7 @@ export class PayStation {
subscriptionHolder: this.#holderOf(subscriptionId), subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null, plate: plateForIdentity(this.#db, id)?.plate ?? null,
grossMinor, discountMinor, validationLines, grossMinor, discountMinor, validationLines,
chargeLines, chargesMinor,
}; };
} }
+33
View File
@@ -1,4 +1,5 @@
import { and, desc, deviceEvents, eq, type Db } from "@parking/db"; import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
import { isNormBox, isVehicleClass, type VehicleRead } from "@parking/shared";
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it // READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the // lives in the unsigned, prunable `device_events` (kind="read") stream written by the
@@ -23,6 +24,38 @@ interface ReadDetail {
plate?: string; plate?: string;
confidence?: number; confidence?: number;
direction?: string; direction?: string;
bodyType?: string;
bodyConfidence?: number;
snapshotId?: string;
vehicleBox?: unknown;
plateBox?: unknown;
}
/** The advisory VEHICLE read (body type) for a session — the same stream and the same
* preference as the plate (entry over exit, newest first). Null when vision never
* classified the vehicle. See venue-modules.md §Vehicle category from vision. */
export function vehicleForIdentity(db: Db, identity: string): VehicleRead | null {
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
let fallback: VehicleRead | null = null;
for (const r of rows) {
const d = (r.detail ?? {}) as ReadDetail;
if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue;
const v: VehicleRead = {
bodyType: d.bodyType,
confidence: d.bodyConfidence,
snapshotId: d.snapshotId ?? null,
box: isNormBox(d.vehicleBox) ? d.vehicleBox : null,
plateBox: isNormBox(d.plateBox) ? d.plateBox : null,
};
if (d.direction === "entry") return v;
if (!fallback) fallback = v;
}
return fallback;
} }
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */ /** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
+25 -3
View File
@@ -1,6 +1,7 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, roles, users, type Db } from "@parking/db"; import { eq, roles, users, type Db } from "@parking/db";
import { effectiveModulesFor } from "../modules.js";
import { import {
clearAuthCookies, clearAuthCookies,
newCsrfToken, newCsrfToken,
@@ -65,7 +66,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi
/** The session shape the SPA bootstraps from: identity + role + its permission /** The session shape the SPA bootstraps from: identity + role + its permission
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the * list (so the UI can gate nav/routes) + language. Role NAME is for display; the
* permissions are the source of truth. */ * permissions are the source of truth.
*
* `csrf`, when passed, echoes the SAME value already sent as the readable
* parking_csrf cookie — not a new secret, just a second channel to learn it.
* The desktop shell needs this: tauri-plugin-http's fetch() runs through
* Rust's reqwest, which keeps its own cookie jar separate from the webview,
* so document.cookie on the tauri://localhost page never sees a cookie set
* on a plugin-routed response (open upstream bug, tauri-apps/tauri#13045).
* The cookie itself IS still sent back to the server by reqwest on
* subsequent requests — only the *client-side read* is broken — so
* api.ts's desktop path stashes this body value in memory instead of
* reading document.cookie, and echoes it in X-CSRF-Token exactly as the
* browser path echoes the cookie. See lib/api.ts and assertCsrf() in
* ../auth.ts (unchanged — this never touches verification, only how the
* desktop client learns what to send). */
function sessionView( function sessionView(
db: Db, db: Db,
user: { user: {
@@ -78,6 +93,7 @@ function sessionView(
fullName?: string | null; fullName?: string | null;
email?: string | null; email?: string | null;
}, },
csrf?: string,
) { ) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get(); const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
const permissions = [...permissionsFor(user.roleId)]; const permissions = [...permissionsFor(user.roleId)];
@@ -92,6 +108,10 @@ function sessionView(
fontScale: user.fontScale, fontScale: user.fontScale,
fullName: user.fullName ?? null, fullName: user.fullName ?? null,
email: user.email ?? null, email: user.email ?? null,
// Effective venue modules (entitled ∩ activated) so the SPA can hide nav/routes
// on first paint. The server still enforces via requireModule — this is display.
modules: effectiveModulesFor(db),
...(csrf ? { csrfToken: csrf } : {}),
}; };
} }
@@ -126,7 +146,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
setAuthCookies(reply, token, csrf); setAuthCookies(reply, token, csrf);
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference // `language` is NOT in the JWT (identity/role only) — it's a mutable preference
// read from the DB, so changing it needs no token refresh. // read from the DB, so changing it needs no token refresh.
return sessionView(db, user); return sessionView(db, user, csrf);
}); });
app.post("/api/auth/logout", async (_req, reply) => { app.post("/api/auth/logout", async (_req, reply) => {
@@ -146,7 +166,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
clearAuthCookies(reply); clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" }); return reply.code(401).send({ error: "session no longer valid" });
} }
return sessionView(db, row); // req.user.csrf is the value bound into the JWT at login (see assertCsrf in
// ../auth.ts) — same value as the cookie, re-surfaced for the desktop path.
return sessionView(db, row, req.user.csrf);
}, },
); );
+46 -21
View File
@@ -1,19 +1,26 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { requirePermission, roleHasPermissions } from "../auth.js"; import type { Db } from "@parking/db";
import type { TillId } from "@parking/shared";
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js"; import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a // Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after // receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md. // the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create) // - POST /api/drawer/movement : operator records a cash_in/cash_out on a till.
// - GET /api/drawer/movements: list with review status. Operators see (shift:read) // Guard = the till's `cash` (booth drawer:create,
// only their own; reviewers see all + can filter status. // wash carwash:cash).
// - GET /api/drawer/movements: list with review status, over the tills the role may
// read (own movements); reviewers (drawer:review) see all
// tills + all operators and can filter status.
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review) // - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read) // - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's read).
// payments + vouchers over the whole chain — the
// amount that carries across shifts).
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a // The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
// judgment about the operator settled outside the app, never a cash reversal. // judgment about the operator settled outside the app, never a cash reversal.
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each
// desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions
// matrix").
interface MovementBody { interface MovementBody {
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN), /** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
@@ -23,6 +30,8 @@ interface MovementBody {
amountMinor: number; amountMinor: number;
reason?: string; reason?: string;
currency?: string; currency?: string;
/** Which drawer (default: the booth). */
till?: string;
} }
interface ReviewBody { interface ReviewBody {
@@ -36,15 +45,15 @@ interface ReviewBody {
interface MovementsQuery { interface MovementsQuery {
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */ /** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
status?: MovementStatus; status?: MovementStatus;
/** Filter to one till; absent = every till the role may read (reviewers: every till). */
till?: string;
} }
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> { export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
const createGuard = requirePermission("drawer:create");
const reviewGuard = requirePermission("drawer:review"); const reviewGuard = requirePermission("drawer:review");
const readGuard = requirePermission("shift:read");
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once. // Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => { app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: requireTill(db, "cash", "body") }, async (req, reply) => {
const b = req.body ?? ({} as MovementBody); const b = req.body ?? ({} as MovementBody);
if (b.type !== "cash_in" && b.type !== "cash_out") { if (b.type !== "cash_in" && b.type !== "cash_out") {
return reply.code(400).send({ error: "type must be cash_in or cash_out" }); return reply.code(400).send({ error: "type must be cash_in or cash_out" });
@@ -56,6 +65,7 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
amountMinor: b.amountMinor, amountMinor: b.amountMinor,
reason: b.reason ?? "", reason: b.reason ?? "",
currency: b.currency, currency: b.currency,
till: req.till!,
}); });
} catch (err) { } catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message }); if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
@@ -63,22 +73,37 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
} }
}); });
// List movements + review status. Operators are hard-scoped to their OWN movements; a // List movements + review status. Operators are hard-scoped to their OWN movements on
// reviewer sees ALL and may filter by status (the pending review queue). // the tills they may read; a reviewer sees ALL and may filter by status (the pending
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => { // review queue).
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => {
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]); const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
const readable = tillsReadableBy(db, req.user.roleId);
if (!canReview && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
const q = req.query ?? {}; const q = req.query ?? {};
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined; const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
const movements = shift.movementsWithStatus({ let tills: TillId[] | undefined = canReview ? undefined : readable;
operator: canReview ? undefined : req.user.username, if (q.till?.trim()) {
status, const parsed = parseTill(db, q.till.trim());
}); if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
if (!canReview && !readable.includes(parsed)) {
return reply.code(403).send({ error: `your role cannot see the ${parsed} till`, code: "till_forbidden", till: parsed });
}
tills = [parsed];
}
const operator = canReview ? undefined : req.user.username;
const movements = tills
? tills.flatMap((till) => shift.movementsWithStatus({ operator, status, till })).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
: shift.movementsWithStatus({ operator, status });
return { movements, scope: canReview ? "all" : "self" }; return { movements, scope: canReview ? "all" : "self" };
}); });
// The physical drawer balance now. Same visibility as the open shift's X-report // A till's physical drawer balance now. Same visibility as the open shift's X-report
// (shift:read) — the drawer is a single site-wide till, not per-operator data. // (the till's read guard) — a drawer is a shared till, not per-operator data.
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance()); app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
till: req.till!,
...shift.drawerBalance(req.till!),
}));
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal. // Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => { app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
+44 -9
View File
@@ -1,7 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db"; import { and, desc, gte, inArray, lte, sql, ledgerEvents, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared"; import { BOOTH_TILL, MODULES, feedPermissionFor, isTillId, type LedgerEvent, type LedgerEventType } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
import { effectiveModulesFor } from "../modules.js";
import { enrichEvents } from "../event-enrich.js"; import { enrichEvents } from "../event-enrich.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
@@ -15,25 +16,59 @@ export async function eventRoutes(
db: Db, db: Db,
eventLog: EventLog, eventLog: EventLog,
): Promise<void> { ): Promise<void> {
// Reading the log (the audit trail). // Reading the log (the audit trail). `event:read` reads everything; a role WITHOUT it
const guard = requirePermission("event:read"); // may still hold a module's feed permission (a wash operator's `carwash:read`) and
// then reads ONLY that module's event types — the same rule the live socket applies
// (feedPermissionFor; venue-modules.md §Permissions matrix, move 3).
/** The event types a role may read, or null for "everything" (event:read). Empty =
* the role reads nothing → 403 at the route. */
function readableTypes(roleId: string): LedgerEventType[] | null {
if (roleHasPermissions(roleId, ["event:read"])) return null;
const effective = effectiveModulesFor(db);
const out: LedgerEventType[] = [];
for (const m of MODULES) {
if (!m.feedPermission || !effective.includes(m.id)) continue;
if (roleHasPermissions(roleId, [m.feedPermission])) out.push(...m.ledgerEventTypes);
}
return out;
}
/** SQL form of the shared `tillOfEvent` rule: the payload's `till`, else the till of
* the module owning the event type, else the booth. Computed in the query so the
* page limit applies AFTER the till filter (a shift's window can hold thousands of
* device events). */
const tillExpr = (() => {
const cases = MODULES.filter((m) => m.till && m.till !== BOOTH_TILL && m.ledgerEventTypes.length > 0).map(
(m) => sql`when ${ledgerEvents.type} in (${sql.join(m.ledgerEventTypes.map((t) => sql`${t}`), sql`, `)}) then ${m.till}`,
);
return sql`coalesce(json_extract(${ledgerEvents.payload}, '$.till'), case ${sql.join(cases, sql` `)} else ${BOOTH_TILL} end)`;
})();
// Recent events, newest first. `limit` caps the page (default 100, max 1000). // Recent events, newest first. `limit` caps the page (default 100, max 1000).
// Optional `since` (ISO) scopes to events at/after that instant — the booth passes // Optional `since` (ISO) scopes to events at/after that instant — the booth passes
// the current shift's start so the live feed shows ONLY this shift's activity. An // the current shift's start so the live feed shows ONLY this shift's activity. An
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a // optional `until` (ISO) closes the upper bound — the shift-history screen passes a
// selected shift's [start, end] to show just that shift's signed activity log. // selected shift's [start, end] to show just that shift's signed activity log.
// (logs are per-shift, not all history). See wiki/concepts/shift.md. // (logs are per-shift, not all history). An optional `till` keeps only that till's
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>( // activity (tillOfEvent) — a booth shift's log no longer shows the wash desk's, and
// vice versa. See wiki/concepts/shift.md §Tills.
app.get<{ Querystring: { limit?: string; since?: string; until?: string; till?: string } }>(
"/api/events", "/api/events",
{ preHandler: guard }, { preHandler: requireAuth },
async (req) => { async (req, reply) => {
const types = readableTypes(req.user?.roleId ?? "");
if (types && types.length === 0) return reply.code(403).send({ error: "forbidden" });
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const since = (req.query.since ?? "").trim(); const since = (req.query.since ?? "").trim();
const until = (req.query.until ?? "").trim(); const until = (req.query.until ?? "").trim();
const till = (req.query.till ?? "").trim();
if (till && !isTillId(till)) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
const bounds = [ const bounds = [
since ? gte(ledgerEvents.occurredAt, since) : undefined, since ? gte(ledgerEvents.occurredAt, since) : undefined,
until ? lte(ledgerEvents.occurredAt, until) : undefined, until ? lte(ledgerEvents.occurredAt, until) : undefined,
till ? sql`${tillExpr} = ${till}` : undefined,
types ? inArray(ledgerEvents.type, types) : undefined,
].filter(Boolean); ].filter(Boolean);
const rows = db const rows = db
.select() .select()
+102
View File
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { jobsBehind } from "@parking/shared";
import { buildServer } from "../server.js";
import { login, seedUser } from "../test-helpers.js";
// Roles are data composed from the permission grid (venue-modules.md §Permissions
// matrix): every edit is SIGNED as a config_change, and a role remembers the manifest
// JOBS it was built from so a grown job can be surfaced and re-applied.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
type Auth = { cookie: string; csrf: string };
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
async function admin(): Promise<Auth> {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
return login(app, username, password);
}
async function roleChanges(a: Auth) {
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).filter(
(e) => e.type === "config_change" && String(e.payload.setting).startsWith("role."),
);
}
describe("role edits are signed and jobs are remembered", () => {
it("create / update / delete each sign one config_change with prev + value + operator; a no-op resave signs nothing", async () => {
const a = await admin();
const created = await app.inject({
method: "POST", url: "/api/roles", headers: hdrs(a),
payload: { name: "Lavazh", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
});
expect(created.statusCode).toBe(201);
const role = created.json();
expect(role.jobs).toEqual(["wash-operator"]);
let evs = await roleChanges(a);
expect(evs).toHaveLength(1);
expect(evs[0]!.payload).toMatchObject({
setting: `role.${role.id}`, prev: null, operator: "boss",
value: { name: "Lavazh", jobs: ["wash-operator"] },
});
expect((evs[0]!.payload.value as { permissions: string[] }).permissions).toEqual(["carwash:cash", "carwash:create", "carwash:read", "carwash:update"]);
// Same content again → nothing new on the chain.
const same = await app.inject({
method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a),
payload: { permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
});
expect(same.statusCode).toBe(200);
expect(await roleChanges(a)).toHaveLength(1);
// A real change: prev is the old shape, value the new.
const renamed = await app.inject({ method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a), payload: { name: "Lavazh NEW" } });
expect(renamed.statusCode).toBe(200);
evs = await roleChanges(a);
expect(evs).toHaveLength(2);
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh" }, value: { name: "Lavazh NEW" } });
const gone = await app.inject({ method: "DELETE", url: `/api/roles/${role.id}`, headers: hdrs(a) });
expect(gone.statusCode).toBe(200);
evs = await roleChanges(a);
expect(evs).toHaveLength(3);
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh NEW" }, value: null });
});
it("unknown jobs are refused; a role built from a job that later grew reports what it is missing", async () => {
const a = await admin();
const bad = await app.inject({ method: "POST", url: "/api/roles", headers: hdrs(a), payload: { name: "X", permissions: [], jobs: ["bar-tender"] } });
expect(bad.statusCode).toBe(400);
// Compose "behind": the role follows wash-operator but holds only part of today's bundle
// — exactly what an older release's chip would have left once the job grew.
const r = (await app.inject({
method: "POST", url: "/api/roles", headers: hdrs(a),
payload: { name: "Old wash", permissions: ["carwash:read", "carwash:create"], jobs: ["wash-operator"] },
})).json();
const view = (await app.inject({ method: "GET", url: "/api/roles", headers: { cookie: a.cookie } })).json().roles.find((x: { id: string }) => x.id === r.id);
const has = new Set<string>(view.permissions);
expect(jobsBehind(view.jobs, (p) => has.has(p))).toEqual([{ job: "wash-operator", missing: ["carwash:update", "carwash:cash"] }]);
// Re-apply = the union; then nothing is behind.
const fixed = (await app.inject({
method: "PUT", url: `/api/roles/${r.id}`, headers: hdrs(a),
payload: { permissions: [...has, "carwash:update", "carwash:cash"] },
})).json();
const has2 = new Set<string>(fixed.permissions);
expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]);
});
});
+74 -3
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db"; import { and, eq, isNull, roleJobs, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared"; import { ADMIN_ROLE_ID, PERMISSIONS, jobById, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js"; import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { softDelete } from "../recycle-bin.js"; import { softDelete } from "../recycle-bin.js";
// Role management (admin). Roles are DATA: an admin composes a role from the // Role management (admin). Roles are DATA: an admin composes a role from the
@@ -18,14 +19,30 @@ import { softDelete } from "../recycle-bin.js";
// that grants admin-equivalent powers, and escalate. So a non-admin caller may // that grants admin-equivalent powers, and escalate. So a non-admin caller may
// only put permissions they ALREADY hold onto a role. An admin (full set) is // only put permissions they ALREADY hold onto a role. An admin (full set) is
// unrestricted, which is the intended behaviour. // unrestricted, which is the intended behaviour.
//
// EVERY role edit is SIGNED on the ledger as a `config_change` (setting `role.<id>`,
// value/prev = the role's name + permissions + jobs, operator = who) — a role edit is a
// privilege change, and under this threat model the only setting an admin could alter
// without a trace. A role also REMEMBERS the manifest JOBS it was composed from
// (role_jobs) so a later release that grows a job's bundle can be surfaced and
// re-applied — the grid is never expanded silently (venue-modules.md §Permissions matrix).
interface RoleBody { interface RoleBody {
name: string; name: string;
permissions: string[]; permissions: string[];
jobs?: string[];
} }
interface UpdateBody { interface UpdateBody {
name?: string; name?: string;
permissions?: string[]; permissions?: string[];
jobs?: string[];
}
/** What a signed role change records (before/after). */
interface RoleShape {
name: string;
permissions: Permission[];
jobs: string[];
} }
const VALID = new Set<string>(PERMISSIONS); const VALID = new Set<string>(PERMISSIONS);
@@ -41,7 +58,19 @@ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | {
return { ok: true, perms: [...out] }; return { ok: true, perms: [...out] };
} }
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> { /** Validate + dedupe a requested job list against the registry's job presets. */
function cleanJobs(input: unknown): { ok: true; jobs: string[] } | { ok: false; bad: string } {
if (input == null) return { ok: true, jobs: [] };
if (!Array.isArray(input)) return { ok: false, bad: "jobs must be an array" };
const out = new Set<string>();
for (const j of input) {
if (typeof j !== "string" || !jobById(j)) return { ok: false, bad: `unknown job: ${String(j)}` };
out.add(j);
}
return { ok: true, jobs: [...out] };
}
export async function roleRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog): Promise<void> {
const readGuard = requirePermission("role:read"); const readGuard = requirePermission("role:read");
const createGuard = requirePermission("role:create"); const createGuard = requirePermission("role:create");
const updateGuard = requirePermission("role:update"); const updateGuard = requirePermission("role:update");
@@ -64,10 +93,39 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
name: role.name, name: role.name,
builtin: role.builtin === 1, builtin: role.builtin === 1,
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms, permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
jobs: jobsOf(roleId),
userCount, userCount,
}; };
} }
function jobsOf(roleId: string): string[] {
return db.select({ jobId: roleJobs.jobId }).from(roleJobs).where(eq(roleJobs.roleId, roleId)).all().map((r) => r.jobId).sort();
}
/** The role as the ledger records it (sorted so two identical shapes compare equal). */
function shapeOf(roleId: string): RoleShape | null {
const v = roleView(roleId);
if (!v) return null;
return { name: v.name, permissions: [...v.permissions].sort() as Permission[], jobs: v.jobs };
}
/** Replace a role's remembered jobs. */
function setJobs(roleId: string, jobs: string[]): void {
db.delete(roleJobs).where(eq(roleJobs.roleId, roleId)).run();
for (const jobId of jobs) db.insert(roleJobs).values({ roleId, jobId }).run();
}
/** Sign a role change. `prev` null = created; `value` null = deleted. Skipped when
* nothing changed (a no-op resave leaves no trace, like the site-config flips). */
async function signRoleChange(req: { user?: { username?: string } }, roleId: string, prev: RoleShape | null, value: RoleShape | null): Promise<void> {
if (JSON.stringify(prev) === JSON.stringify(value)) return;
await eventLog?.append({
type: "config_change",
source: "manual",
payload: { setting: `role.${roleId}`, value, prev, operator: req.user?.username ?? "unknown" },
});
}
/** Replace a role's permission rows with `perms` (in a single pass). */ /** Replace a role's permission rows with `perms` (in a single pass). */
function setPermissions(roleId: string, perms: Permission[]): void { function setPermissions(roleId: string, perms: Permission[]): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run(); db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
@@ -104,13 +162,17 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
const cleaned = cleanPermissions(req.body?.permissions ?? []); const cleaned = cleanPermissions(req.body?.permissions ?? []);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad }); if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
const jobs = cleanJobs(req.body?.jobs);
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
const over = escalates(req.user.roleId, cleaned.perms); const over = escalates(req.user.roleId, cleaned.perms);
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` }); if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
const id = randomUUID(); const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run(); db.insert(roles).values({ id, name, builtin: 0 }).run();
setPermissions(id, cleaned.perms); setPermissions(id, cleaned.perms);
setJobs(id, jobs.jobs);
bumpPermsCache(); bumpPermsCache();
await signRoleChange(req, id, null, shapeOf(id));
return reply.code(201).send(roleView(id)); return reply.code(201).send(roleView(id));
}); });
@@ -125,6 +187,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (role.builtin === 1) { if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be edited" }); return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
} }
const prev = shapeOf(id);
if (req.body?.name != null) { if (req.body?.name != null) {
const name = req.body.name.trim(); const name = req.body.name.trim();
@@ -140,7 +203,13 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` }); if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
setPermissions(id, cleaned.perms); setPermissions(id, cleaned.perms);
} }
if (req.body?.jobs != null) {
const jobs = cleanJobs(req.body.jobs);
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
setJobs(id, jobs.jobs);
}
bumpPermsCache(); bumpPermsCache();
await signRoleChange(req, id, prev, shapeOf(id));
return roleView(id); return roleView(id);
}, },
); );
@@ -163,8 +232,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (holders > 0) { if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` }); return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
} }
const prev = shapeOf(id);
softDelete(db, "role", id, req.user.sub); softDelete(db, "role", id, req.user.sub);
bumpPermsCache(); bumpPermsCache();
await signRoleChange(req, id, prev, null);
return { ok: true }; return { ok: true };
}, },
); );
+2 -2
View File
@@ -30,7 +30,7 @@ describe("health + login", () => {
it("GET /health is open", async () => { it("GET /health is open", async () => {
const res = await app.inject({ method: "GET", url: "/health" }); const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok" }); expect(res.json()).toEqual({ status: "ok", app: "parking-system" });
}); });
it("login with bad credentials is rejected", async () => { it("login with bad credentials is rejected", async () => {
@@ -142,7 +142,7 @@ describe("drawer balance (the till NOW)", () => {
const { cookie } = await login(app, viewer.username, viewer.password); const { cookie } = await login(app, viewer.username, viewer.password);
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } }); const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
expect(ok.statusCode).toBe(200); expect(ok.statusCode).toBe(200);
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null }); expect(ok.json()).toEqual({ till: "booth", balanceMinor: 0, currency: null });
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] }); const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
const other = await login(app, outsider.username, outsider.password); const other = await login(app, outsider.username, outsider.password);
+73 -31
View File
@@ -1,6 +1,9 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance, FastifyReply } from "fastify";
import { requirePermission, roleHasPermissions } from "../auth.js"; import type { Db } from "@parking/db";
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js"; import { tillGuards, type TillId } from "@parking/shared";
import { requireAuth, roleHasPermissions } from "../auth.js";
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService, type ShiftSummary } from "../shift-service.js";
interface ShiftsQuery { interface ShiftsQuery {
/** Filter to one operator (admin-only; non-admins are forced to themselves). */ /** Filter to one operator (admin-only; non-admins are forced to themselves). */
@@ -8,85 +11,124 @@ interface ShiftsQuery {
/** ISO window over shift START time. */ /** ISO window over shift START time. */
from?: string; from?: string;
to?: string; to?: string;
/** Filter to one till; absent = every till the role may read. */
till?: string;
} }
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is // Shift endpoints (manned mode). The operator is the logged-in user; a shift is
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and // opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it. // local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
//
// TILLS + PERMISSIONS: every endpoint addresses a `till` (query on GET, body on POST;
// default booth) and its guard is resolved FROM THE TILL (requireTill): the booth's shift
// is `shift:read` / `shift:create`, the wash's is `carwash:read` / `carwash:cash` — each
// desk's money is guarded by that desk's own permissions, so a wash role holds no
// `shift:*` at all and cannot touch the booth. See venue-modules.md §"Permissions matrix".
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> { export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
// Reading the shift state vs. opening/closing one's own shift. const statusOf = (till: TillId, me: string, roleId: string) => {
const readGuard = requirePermission("shift:read"); const open = shift.currentOpenShift(till);
const guard = requirePermission("shift:create");
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
// someone else's shift → disabled. Also returns the live drawer balance.
// - open: the open shift { startedAt, operator } or null (site-wide)
// - isMine: true iff the open shift belongs to the requesting operator
// - operator: the requesting user (for the UI's own identity)
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
const me = req.user.username;
const open = shift.currentOpenShift();
const heldBy = open?.identity ?? null; const heldBy = open?.identity ?? null;
const drawer = shift.drawerBalance(); const drawer = shift.drawerBalance(till);
return { return {
operator: me, till,
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null, open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me, isMine: open != null && heldBy === me,
/** May this role open/close this till's shift? (The UI offers the button only then.) */
canWork: roleHasPermissions(roleId, [tillGuards(till).shift]),
drawerMinor: drawer.balanceMinor, drawerMinor: drawer.balanceMinor,
currency: drawer.currency, currency: drawer.currency,
}; };
};
// The shift state of ONE till (at most one shift open per till). The UI uses this
// to render a till's control: no shift → "Open"; my shift → "Close" (enabled);
// someone else's shift → disabled. Also returns the live drawer balance.
// - till: which till this describes
// - open: the open shift { startedAt, operator } or null
// - isMine: true iff the open shift belongs to the requesting operator
// - canWork: may this role open/close it
// - operator: the requesting user (for the UI's own identity)
// - tills: every till THIS ROLE may read — what the UI offers controls for
app.get("/api/shift/current", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
operator: req.user.username,
tills: tillsReadableBy(db, req.user.roleId),
...statusOf(req.till!, req.user.username, req.user.roleId),
}));
// The state of every till this role may read, in one read — the shift hub lists
// each open shift and offers "start" for the idle ones it may work.
app.get("/api/shift/tills", { preHandler: requireAuth }, async (req) => {
const me = req.user.username;
return { operator: me, tills: tillsReadableBy(db, req.user.roleId).map((t) => statusOf(t, me, req.user.roleId)) };
}); });
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings + // Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed // drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
// as of now. Appends nothing — it's not an accountability mark, just a projection // as of now. Appends nothing — it's not an accountability mark, just a projection
// (the Z-report at close is the signed record). 204 when no shift is open. // (the Z-report at close is the signed record). 204 when no shift is open.
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => { app.get("/api/shift/report", { preHandler: requireTill(db, "read", "query") }, async (req, reply) => {
const report = shift.currentReport(); const report = shift.currentReport(req.till!);
if (!report) return reply.code(204).send(); if (!report) return reply.code(204).send();
return report; return report;
}); });
// Completed shift history. SCOPED by permission: // Completed shift history. SCOPED by permission:
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored. // - a till's `read` guard (operators) → own shifts only, on the tills they may read;
// operator/from/to params ignored.
// - `shift:cash` (admin-grade) → all operators, optionally filtered by // - `shift:cash` (admin-grade) → all operators, optionally filtered by
// `operator` and a `from`/`to` time window over each shift's START. // `operator` and a `from`/`to` time window over each shift's START.
// This keeps one operator from reading another's takings while letting admins // This keeps one operator from reading another's takings while letting admins
// reconcile across the site. The data is the signed shift_z_report chain. // reconcile across the site. The data is the signed shift_z_report chain. Both
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => { // scopes may filter by `till` (must be one the role may read).
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: requireAuth }, async (req, reply) => {
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]); const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
const readable = tillsReadableBy(db, req.user.roleId);
if (!canSeeAll && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
const q = req.query ?? {}; const q = req.query ?? {};
// Non-admins are hard-scoped to themselves regardless of any operator param. // Non-admins are hard-scoped to themselves regardless of any operator param.
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username; const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
const from = canSeeAll ? q.from?.trim() || undefined : undefined; const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined; const to = canSeeAll ? q.to?.trim() || undefined : undefined;
const shifts = shift.listShifts({ operator, from, to }); let tills: TillId[] = canSeeAll ? [] : readable; // [] = no till filter (admin)
if (q.till?.trim()) {
const parsed = parseTill(db, q.till.trim());
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
if (!canSeeAll && !readable.includes(parsed)) return badTill(reply, parsed);
tills = [parsed];
}
const shifts: ShiftSummary[] =
tills.length === 0
? shift.listShifts({ operator, from, to })
: tills.flatMap((till) => shift.listShifts({ operator, from, to, till })).sort((a, b) => b.index - a.index);
// Admins also get the distinct operator list (unfiltered) for the filter // Admins also get the distinct operator list (unfiltered) for the filter
// dropdown — operators don't see other names, so it's scope-gated. // dropdown — operators don't see other names, so it's scope-gated.
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() }; if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: tillsReadableBy(db, req.user.roleId) };
return { shifts, scope: "self" }; return { shifts, scope: "self", tills: readable };
}); });
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the // NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
// feature is no longer part of the shift route. See wiki/concepts/shift.md. // feature is no longer part of the shift route. See wiki/concepts/shift.md.
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => { app.post("/api/shift/open", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
try { try {
return await shift.open(req.user.username); return await shift.open(req.user.username, req.till!);
} catch (err) { } catch (err) {
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message }); if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message }); return reply.code(500).send({ error: (err as Error).message });
} }
}); });
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => { app.post("/api/shift/close", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
try { try {
return await shift.close(req.user.username); return await shift.close(req.user.username, req.till!);
} catch (err) { } catch (err) {
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message }); if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message }); return reply.code(500).send({ error: (err as Error).message });
} }
}); });
} }
function badTill(reply: FastifyReply, till: TillId): FastifyReply {
return reply.code(403).send({ error: `your role cannot see the ${till} till`, code: "till_forbidden", till });
}
+58
View File
@@ -1,7 +1,9 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db"; import { eq, siteConfig, type Db } from "@parking/db";
import { MODULES, effectiveModules, isModuleId, resolveModuleActivation, type ModuleId } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
import { activatedModulesOf, entitledModules } from "../modules.js";
import { getOccupancy } from "../occupancy.js"; import { getOccupancy } from "../occupancy.js";
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed // Site config (capacity) + live occupancy. Occupancy is a fold over the signed
@@ -36,6 +38,10 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's /** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's
* plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */ * plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
anprEntryEnabled?: boolean; anprEntryEnabled?: boolean;
/** Venue modules to ACTIVATE (full desired set). Validated against the entitlement
* and the registry's dependency rules; required modules are always included. Each
* module that actually flips signs a config_change. See wiki/decisions/venue-modules.md. */
modules?: unknown;
} }
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default /** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
@@ -48,6 +54,13 @@ type SiteConfig = {
anprEntryEnabled: boolean; anprEntryEnabled: boolean;
bypassPresenceRadar: boolean; bypassPresenceRadar: boolean;
bypassPresenceCamera: boolean; bypassPresenceCamera: boolean;
/** Effective venue modules = entitled ∩ activated (what the server enforces). */
modules: ModuleId[];
/** What this deployment is entitled to (MODULES_ENTITLED env) — the Setup → Site
* panel offers exactly these to toggle. */
modulesEntitled: ModuleId[];
/** What the site admin has activated (null in storage = everything entitled). */
modulesActivated: ModuleId[];
} & Record<TextField, string | null>; } & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
@@ -59,11 +72,22 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
anprEntryEnabled: row?.anprEntryEnabled ?? true, anprEntryEnabled: row?.anprEntryEnabled ?? true,
bypassPresenceRadar: row?.bypassPresenceRadar ?? false, bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
bypassPresenceCamera: row?.bypassPresenceCamera ?? false, bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
...moduleView(row),
} as SiteConfig; } as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out; return out;
} }
function moduleView(row: typeof siteConfig.$inferSelect | undefined) {
const entitled = entitledModules();
const activated = activatedModulesOf(row) ?? entitled;
return {
modules: effectiveModules(entitled, activated),
modulesEntitled: entitled,
modulesActivated: activated,
};
}
/** Trim a text field; empty string becomes null so blank input clears it. */ /** Trim a text field; empty string becomes null so blank input clears it. */
function normText(v: unknown): string | null { function normText(v: unknown): string | null {
if (v == null) return null; if (v == null) return null;
@@ -136,6 +160,40 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL
} }
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
// Venue-module activation. The body carries the full DESIRED set; the shared rules
// (required always on, must be entitled, dependencies effective) decide, and every
// module whose effective state actually flips is signed as a config_change — the
// same attribution pattern as the presence-bypass endpoint below. Disabling never
// deletes anything: tables/history/grants stay, routes 403, UI hides.
if ("modules" in body) {
const requested = body.modules;
if (!Array.isArray(requested) || !requested.every(isModuleId)) {
return reply.code(400).send({
error: `modules must be an array of module ids (${MODULES.map((m) => m.id).join(", ")})`,
});
}
const entitled = entitledModules();
const result = resolveModuleActivation(entitled, requested);
if (!result.ok) return reply.code(400).send({ error: result.error });
const prevEffective = new Set(effectiveModules(entitled, activatedModulesOf(existing) ?? entitled));
const nextEffective = new Set(effectiveModules(entitled, result.modules));
const operator = req.user?.username ?? "unknown";
for (const m of MODULES) {
const was = prevEffective.has(m.id);
const now = nextEffective.has(m.id);
if (was !== now) {
await eventLog?.append({
type: "config_change",
source: "manual",
identity: `module:${m.id}`,
payload: { setting: `modules.${m.id}`, value: now, prev: was, operator },
});
}
}
patch.modulesJson = JSON.stringify(result.modules);
}
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
if (existing) { if (existing) {
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run(); db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
+2 -1
View File
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db"; import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices"; import { NoPrinterAvailableError } from "@parking/devices";
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared"; import { BOOTH_TILL, type SubscriptionPlan, type SubscriptionQuote, type Tender } from "@parking/shared";
import { requirePermission, roleHasPermissions } from "../auth.js"; import { requirePermission, roleHasPermissions } from "../auth.js";
import { softDelete } from "../recycle-bin.js"; import { softDelete } from "../recycle-bin.js";
import { invalidateHolder } from "../event-enrich.js"; import { invalidateHolder } from "../event-enrich.js";
@@ -381,6 +381,7 @@ export async function subscriptionRoutes(
amountMinor, amountMinor,
currency, currency,
tender, tender,
till: BOOTH_TILL,
operator, operator,
// Flags this `payment` as a subscription SALE (not a parking payment) so the // Flags this `payment` as a subscription SALE (not a parking payment) so the
// live feed / activity log can label it distinctly. plan + periods for audit // live feed / activity log can label it distinctly. plan + periods for audit
+4 -1
View File
@@ -3,7 +3,7 @@ import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, eq, isNull, roles, users, type Db } from "@parking/db"; import { and, eq, isNull, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID } from "@parking/shared"; import { ADMIN_ROLE_ID } from "@parking/shared";
import { permissionsFor, requirePermission } from "../auth.js"; import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js"; import { softDelete } from "../recycle-bin.js";
// User management (admin). Users are created/edited at runtime here — the // User management (admin). Users are created/edited at runtime here — the
@@ -202,6 +202,8 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
return reply.code(400).send({ error: "nothing to update" }); return reply.code(400).send({ error: "nothing to update" });
} }
db.update(users).set(next).where(eq(users.id, id)).run(); db.update(users).set(next).where(eq(users.id, id)).run();
// A role reassignment takes effect on the user's NEXT request (auth.ts refreshRole).
if (next.roleId) bumpPermsCache();
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!); return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
}, },
); );
@@ -251,6 +253,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
return reply.code(409).send({ error: "cannot delete the last admin" }); return reply.code(409).send({ error: "cannot delete the last admin" });
} }
softDelete(db, "user", id, req.user.sub); softDelete(db, "user", id, req.user.sub);
bumpPermsCache(); // their live session ends on its next request
return { ok: true }; return { ok: true };
}, },
); );
+28 -82
View File
@@ -10,10 +10,11 @@ import {
validationPrograms, validationPrograms,
type Db, type Db,
} from "@parking/db"; } from "@parking/db";
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared"; import { MERCHANT_VALIDATION_MODES, VALIDATION_MODES, type ValidationMode } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
import { requireModule } from "../modules.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
import { liveValidations, sessionValidations } from "../validations.js"; import { applyValidation, liveValidations, sessionValidations } from "../validations.js";
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the // Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
// customer's ticket on their own device and apply their program — all money and paper // customer's ticket on their own device and apply their program — all money and paper
@@ -63,21 +64,33 @@ function validateProgram(b: ProgramBody): string | null {
if (!b.name || !String(b.name).trim()) return "name is required"; if (!b.name || !String(b.name).trim()) return "name is required";
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent"; if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0); const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
if (!intOrNull(b.minutes)) return "minutes must be a positive integer"; // doneTolerance's minutes is a TOLERANCE — zero is a legitimate "free until done, not a
// minute more"; every other minutes use is a positive credit.
const minutesOk = b.mode === "doneTolerance"
? b.minutes == null || (Number.isInteger(b.minutes) && (b.minutes as number) >= 0)
: intOrNull(b.minutes);
if (!minutesOk) return b.mode === "doneTolerance" ? "minutes must be a non-negative integer" : "minutes must be a positive integer";
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer"; if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer"; if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100)) if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
return "percent must be 1..100"; return "percent must be 1..100";
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes"; if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
if (b.mode === "doneTolerance" && b.minutes == null) return "doneTolerance needs minutes (the tolerance; 0 allowed)";
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent"; if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor"; if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
return null; return null;
} }
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> { export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
// The PROGRAM routes (compose / read discount programs) are CORE: the discount engine
// serves every module that grants a parking discount (Car Wash's "carwash" program
// rides it), so they are never behind the validation module gate — plain site:read /
// site:update. The MERCHANT routes (mine / lookup / apply / void — the scan screen)
// are the validation module itself: module gate FIRST (403 module_disabled when the
// site has validation off — see ../modules.ts), then the permission.
const siteRead = requirePermission("site:read"); const siteRead = requirePermission("site:read");
const siteWrite = requirePermission("site:update"); const siteWrite = requirePermission("site:update");
const applyGuard = requirePermission("validation:create"); const applyGuard = [requireModule(db, "validation"), requirePermission("validation:create")];
const liveProgram = (id: string) => const liveProgram = (id: string) =>
db db
@@ -243,88 +256,21 @@ export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: E
if (!boundUserIds(programId).includes(req.user.sub)) { if (!boundUserIds(programId).includes(req.user.sub)) {
return reply.code(403).send({ error: "you are not bound to this program" }); return reply.code(403).send({ error: "you are not bound to this program" });
} }
if (!MERCHANT_VALIDATION_MODES.includes(program.mode)) {
// Session state — an open transient (subscriptions are prepaid; nothing to discount). return reply.code(400).send({ error: "this program's discount is resolved by a car wash order, not at scan" });
const rows = db
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return reply.code(404).send({ error: "no session for ticket" });
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return reply.code(409).send({ error: "subscription sessions cannot be validated" });
}
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
return reply.code(409).send({ error: "session is closed" });
}
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
return reply.code(409).send({ error: "this program is already applied to the ticket" });
} }
// Per-day cap: unvoided applications of this program since LOCAL midnight (the // The decision chain + the signed append live in ../validations.ts (applyValidation)
// appliance runs in site time). // — shared with the Car Wash module, which applies its own sponsorship program with
if (program.maxPerDay != null) { // no user binding. Only the binding check above is merchant-specific.
const midnight = new Date(); const result = await applyValidation(db, eventLog, {
midnight.setHours(0, 0, 0, 0); programId,
const todays = db
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "validation"))
.all()
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
const voidedIds = new Set(
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
);
const count = todays.filter((r) => {
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
}).length;
if (count >= program.maxPerDay) {
return reply.code(409).send({ error: "daily cap reached for this program" });
}
}
// Resolve the values off the program row (frozen into the signed event).
let amountMinor: number | undefined;
if (program.mode === "fixed") {
const a = req.body?.amountMinor;
if (a == null || !Number.isInteger(a) || a <= 0) {
return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" });
}
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` });
}
amountMinor = a;
}
const ev = await eventLog.append({
type: "validation",
source: "manual",
identity, identity,
payload: { actor: req.user.username,
sessionRef: identity, amountMinor: req.body?.amountMinor,
programId,
programLabel: program.name,
mode: program.mode,
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
...(amountMinor != null ? { amountMinor } : {}),
operator: req.user.username,
},
});
return reply.code(201).send({
ok: true,
eventId: ev.id,
programId,
label: program.name,
mode: program.mode,
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
percent: program.mode === "percent" ? program.percent : undefined,
amountMinor,
}); });
if (!result.ok) return reply.code(result.status).send({ error: result.error });
return reply.code(201).send(result);
}); });
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only: // VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
+116 -25
View File
@@ -1,7 +1,9 @@
import { randomBytes } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db"; import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared"; import { feedPermissionFor, watchPermissions, type LedgerEvent, type Permission } from "@parking/shared";
import { roleHasPermissions } from "../auth.js"; import { currentRoleId, requireAuth, roleHasPermissions } from "../auth.js";
import { effectiveModulesFor } from "../modules.js";
import { import {
deviceEvents, deviceEvents,
type LaneStatusEvent, type LaneStatusEvent,
@@ -31,10 +33,61 @@ import { getOccupancy } from "../occupancy.js";
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly // an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too. // allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md. // See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
//
// Desktop shell (Tauri) exception — the WS TICKET. The desktop app's HTTP goes
// through tauri-plugin-http (reqwest, its own cookie jar) and its WebSocket
// through tauri-plugin-websocket (bare tungstenite, NO cookie jar at all), so
// the JWT cookie set at login can never ride on the WS handshake — jwtVerify()
// would 401 every connect (found 2026-09-04: the desktop live feed reconnected
// every 10s forever). The JWT is HttpOnly and must stay out of JS, so instead
// the desktop client POSTs /api/ws/ticket (normal cookie + CSRF auth) to get a
// single-use, 30-second random ticket bound to its user, and presents it in an
// `x-ws-ticket` header on the handshake. A browser page cannot set custom
// headers on a WebSocket, so this path is unreachable from a browser and adds
// no CSWSH surface; the Origin allowlist still applies to both paths.
/** Permission required to watch the live feed (a read-only stream of ledger + // WHO may watch, and WHAT they see (venue-modules.md §"Permissions matrix", move 3):
* device status). Any role granted `report:read` may watch. */ // a role connects if it holds ANY watch permission — the core feed/occupancy/device
const WATCH_PERMISSION = "report:read" as const; // ones or an effective module's own (carwash:read) — and every pushed message is then
// FILTERED per role: a ledger event needs feedPermissionFor(type) (the owning module's,
// else event:read); occupancy + the plate backfill need session:read; device / printer /
// lane / radar need device:read. `report:read` is the REPORTS screen, not the socket: the
// wash desk gets a live queue without the booth's ledger, the booth a feed without reports.
type Viewer = { has: (p: Permission) => boolean };
/** Handshake header carrying a desktop WS ticket (see file header). */
const WS_TICKET_HEADER = "x-ws-ticket";
/** A ticket is only good for the connect that immediately follows its issue. */
const WS_TICKET_TTL_MS = 30_000;
interface WsTicket {
sub: string;
roleId: string;
expiresAt: number;
}
/** Outstanding tickets. Tiny (one per desktop connect attempt), in-memory only —
* a server restart invalidates them, which is fine: the client just asks for
* another on its next reconnect. */
const tickets = new Map<string, WsTicket>();
function issueWsTicket(sub: string, roleId: string): string {
const now = Date.now();
for (const [key, t] of tickets) {
if (t.expiresAt <= now) tickets.delete(key);
}
const ticket = randomBytes(32).toString("hex");
tickets.set(ticket, { sub, roleId, expiresAt: now + WS_TICKET_TTL_MS });
return ticket;
}
/** Single-use: the ticket is removed whether or not it turns out to be valid. */
function consumeWsTicket(ticket: string): WsTicket | null {
const t = tickets.get(ticket);
if (!t) return null;
tickets.delete(ticket);
return t.expiresAt > Date.now() ? t : null;
}
/** /**
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is * Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
@@ -61,18 +114,25 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
type OutMsg = type OutMsg =
| { | {
kind: "hello"; kind: "hello";
occupancy: ReturnType<typeof getOccupancy>; occupancy: ReturnType<typeof getOccupancy> | null;
devices: unknown; devices: unknown;
lanes: LaneStatusEvent; lanes: LaneStatusEvent | null;
radar: LanePresenceEvent; radar: LanePresenceEvent | null;
} }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> } | { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> | null }
| { kind: "printer-status"; event: unknown } | { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown } | { kind: "device-status"; event: unknown }
| { kind: "lane-status"; lanes: LaneStatusEvent } | { kind: "lane-status"; lanes: LaneStatusEvent }
| { kind: "lane-presence"; radar: LanePresenceEvent } | { kind: "lane-presence"; radar: LanePresenceEvent }
| { kind: "plate-recognized"; plate: PlateRecognizedEvent }; | { kind: "plate-recognized"; plate: PlateRecognizedEvent };
declare module "fastify" {
interface FastifyRequest {
/** The role the WS preHandler authenticated (ticket or cookie path) — for the handler's filter. */
wsRoleId?: string;
}
}
export async function wsRoutes( export async function wsRoutes(
app: FastifyInstance, app: FastifyInstance,
db: Db, db: Db,
@@ -80,24 +140,52 @@ export async function wsRoutes(
laneStatus: LaneStatus, laneStatus: LaneStatus,
lanePresence: LanePresence, lanePresence: LanePresence,
): Promise<void> { ): Promise<void> {
// Desktop-only: mint a WS ticket for the signed-in session (see file header).
// Ordinary cookie + CSRF auth — the desktop client CAN do that over HTTP (via
// tauri-plugin-http), it just can't carry the cookie onto the WebSocket.
app.post("/api/ws/ticket", { preHandler: requireAuth }, async (req) => ({
ticket: issueWsTicket(req.user.sub, req.user.roleId),
expiresInMs: WS_TICKET_TTL_MS,
}));
app.get( app.get(
"/api/ws", "/api/ws",
{ {
websocket: true, websocket: true,
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT + // Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN
// role. Reject a cross/absent origin before touching the token, so a hijack // session (JWT cookie, or a desktop WS ticket) THEN role. Reject a
// attempt never reaches an authenticated socket. jwtVerify reads the cookie. // cross/absent origin before touching either credential, so a hijack
// attempt never reaches an authenticated socket.
preHandler: async (req) => { preHandler: async (req) => {
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) { if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 }); throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
} }
await req.jwtVerify(); const rawTicket = req.headers[WS_TICKET_HEADER];
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) { const ticket = Array.isArray(rawTicket) ? rawTicket[0] : rawTicket;
let roleId: string;
if (ticket !== undefined) {
const t = consumeWsTicket(ticket);
if (!t) {
throw Object.assign(new Error("invalid or expired ws ticket"), { statusCode: 401 });
}
roleId = t.roleId;
} else {
await req.jwtVerify(); // reads the HttpOnly cookie (browser path)
if (!req.user) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 }); throw Object.assign(new Error("forbidden"), { statusCode: 403 });
} }
roleId = currentRoleId(req.user.sub) ?? "";
}
const may = watchPermissions(effectiveModulesFor(db)).some((p) => roleHasPermissions(roleId, [p]));
if (!may) throw Object.assign(new Error("forbidden"), { statusCode: 403 });
req.wsRoleId = roleId;
}, },
}, },
(socket) => { (socket, req) => {
const roleId = req.wsRoleId ?? req.user?.roleId ?? "";
const viewer: Viewer = { has: (p) => roleHasPermissions(roleId, [p]) };
const seesOccupancy = viewer.has("session:read");
const seesDevices = viewer.has("device:read");
const send = (msg: OutMsg) => { const send = (msg: OutMsg) => {
// readyState 1 = OPEN; never throw out of an event-bus callback. // readyState 1 = OPEN; never throw out of an event-bus callback.
if (socket.readyState === 1) { if (socket.readyState === 1) {
@@ -111,40 +199,43 @@ export async function wsRoutes(
// Initial snapshot so the client renders immediately, before any event: // Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer). // occupancy AND the current device-status set (for the footer).
// Each part of the snapshot only for a role that may see it (null otherwise).
send({ send({
kind: "hello", kind: "hello",
occupancy: getOccupancy(db), occupancy: seesOccupancy ? getOccupancy(db) : null,
devices: deviceMonitor.snapshot(), devices: seesDevices ? deviceMonitor.snapshot() : null,
lanes: laneStatus.snapshot(), lanes: seesDevices ? laneStatus.snapshot() : null,
radar: lanePresence.snapshot(), radar: seesDevices ? lanePresence.snapshot() : null,
}); });
// Subscribe to the live buses. Each handler recomputes occupancy from the // Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative. // ledger (cheap fold) so the pushed count is always authoritative.
const offLedger = deviceEvents.onLedger((event) => { const offLedger = deviceEvents.onLedger((event) => {
// Per-role filter: the event type's feed permission (module's own, else event:read).
if (!viewer.has(feedPermissionFor((event as { type: LedgerEvent["type"] }).type))) return;
// Enrich with read-time display fields (subscriber name) before fan-out. // Enrich with read-time display fields (subscriber name) before fan-out.
const enriched = enrichEvent(db, event as unknown as LedgerEvent); const enriched = enrichEvent(db, event as unknown as LedgerEvent);
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) }); send({ kind: "ledger", event: enriched, occupancy: seesOccupancy ? getOccupancy(db) : null });
}); });
const offPrinter = deviceEvents.onPrinterStatus((event) => { const offPrinter = deviceEvents.onPrinterStatus((event) => {
send({ kind: "printer-status", event }); if (seesDevices) send({ kind: "printer-status", event });
}); });
// Unified device status (all categories) for the booth footer — pushed on // Unified device status (all categories) for the booth footer — pushed on
// change; the initial set rode the hello above. // change; the initial set rode the hello above.
const offDevice = deviceEvents.onDeviceStatus((event) => { const offDevice = deviceEvents.onDeviceStatus((event) => {
send({ kind: "device-status", event }); if (seesDevices) send({ kind: "device-status", event });
}); });
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory. // Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
const offLane = deviceEvents.onLaneStatus((lanes) => { const offLane = deviceEvents.onLaneStatus((lanes) => {
send({ kind: "lane-status", lanes }); if (seesDevices) send({ kind: "lane-status", lanes });
}); });
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory. // Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
const offPresence = deviceEvents.onLanePresence((radar) => { const offPresence = deviceEvents.onLanePresence((radar) => {
send({ kind: "lane-presence", radar }); if (seesDevices) send({ kind: "lane-presence", radar });
}); });
// A late async plate recognition → backfill the badge on the matching feed row. Advisory. // A late async plate recognition → backfill the badge on the matching feed row. Advisory.
const offPlate = deviceEvents.onPlateRecognized((plate) => { const offPlate = deviceEvents.onPlateRecognized((plate) => {
send({ kind: "plate-recognized", plate }); if (seesOccupancy) send({ kind: "plate-recognized", plate });
}); });
socket.on("close", () => { socket.on("close", () => {
+14 -9
View File
@@ -45,7 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
import { drawerRoutes } from "./routes/drawer.js"; import { drawerRoutes } from "./routes/drawer.js";
import { entryRoutes } from "./routes/entry.js"; import { entryRoutes } from "./routes/entry.js";
import { siteRoutes } from "./routes/site.js"; import { siteRoutes } from "./routes/site.js";
import { validationRoutes } from "./routes/validations.js"; import { registerModules } from "./modules/index.js";
import { snapshotRoutes } from "./routes/snapshots.js"; import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js"; import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
@@ -106,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
cookie: { cookieName: TOKEN_COOKIE, signed: false }, cookie: { cookieName: TOKEN_COOKIE, signed: false },
}); });
app.get("/health", async () => ({ status: "ok" })); // Unauthenticated liveness probe. `app` lets a client (the desktop ConnectScreen
// test — apps/web/src/lib/backend-config.ts) tell THIS server apart from any
// other service that happens to answer on the address the operator typed.
app.get("/health", async () => ({ status: "ok", app: "parking-system" }));
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie. // Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db); await authRoutes(app, db);
@@ -114,7 +117,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// RBAC administration: compose roles (role:*) + manage users (user:*). The // RBAC administration: compose roles (role:*) + manage users (user:*). The
// built-in admin role is protected; the last admin can't be removed. See auth.ts. // built-in admin role is protected; the last admin can't be removed. See auth.ts.
await userRoutes(app, db); await userRoutes(app, db);
await roleRoutes(app, db);
// Vision (ANPR) client — built early so the device monitor can include the vision // Vision (ANPR) client — built early so the device monitor can include the vision
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a // service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
@@ -141,6 +143,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// no lane — a parking lot is one pool with a flexible set of entry/exit points. // no lane — a parking lot is one pool with a flexible set of entry/exit points.
// See wiki/concepts/first-run-setup.md, entry-exit-points.md. // See wiki/concepts/first-run-setup.md, entry-exit-points.md.
await setupRoutes(app, db, visionClient, eventLog); await setupRoutes(app, db, visionClient, eventLog);
await roleRoutes(app, db, eventLog);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events), // Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from // guarded by source-IP allowlist + a shared-secret path token, both read from
@@ -283,9 +286,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
await subscriptionPlanRoutes(app, db); await subscriptionPlanRoutes(app, db);
// Shift open/close (shiftService constructed above). // Shift open/close (shiftService constructed above).
await shiftRoutes(app, shiftService); await shiftRoutes(app, db, shiftService);
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts). // Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
await drawerRoutes(app, shiftService); await drawerRoutes(app, db, shiftService);
// Operator-issued entry (broken physical button) — flagged mint, presence-gated. // Operator-issued entry (broken physical button) — flagged mint, presence-gated.
await entryRoutes(app, entryFlow, laneStatus, shiftService); await entryRoutes(app, entryFlow, laneStatus, shiftService);
@@ -293,10 +296,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md. // at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
await siteRoutes(app, db, eventLog); await siteRoutes(app, db, eventLog);
// Merchant validations (bar / lavazh): setup panel config + the merchant user's // Venue modules (wiki/decisions/venue-modules.md): folder-based modules register
// scan-and-apply. The booth settlement folds the applied validations into its // here by iterating the shared registry — today that is `validation` (merchant
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md. // validations for the Bar; the booth settlement folds applied validations into its
await validationRoutes(app, db, eventLog); // quote, pay-station.ts). `parking` is in the registry too but its routes are still
// the flat list above; they move behind the seam subsystem by subsystem.
await registerModules(app, { db, eventLog, payStation, shiftService });
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) + // Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md. // read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
+90
View File
@@ -242,3 +242,93 @@ describe("close signs a Z-report; listShifts reads it back", () => {
expect(shift.listOperators()).toEqual(["alice", "bob"]); expect(shift.listOperators()).toEqual(["alice", "bob"]);
}); });
}); });
describe("tills: one shift per till, one drawer per till", () => {
/** A bay payment as the Car Wash module signs it (till = carwash). */
async function bayPayment(amountMinor: number, tender: "cash" | "card" = "cash") {
await log.append({
type: "carwash_payment", source: "manual", identity: "T",
payload: { sessionRef: "T", orderId: "o1", amountMinor, currency: "ALL", tender, till: "carwash" },
});
}
it("the booth and the carwash till can both be open at once, by different operators", async () => {
await shift.open("alice");
await expect(shift.open("wanda", "carwash")).resolves.toMatchObject({ till: "carwash" });
expect(shift.currentOpenShift()?.identity).toBe("alice");
expect(shift.currentOpenShift("carwash")?.identity).toBe("wanda");
// Each till keeps its own single-open rule.
await expect(shift.open("bob", "carwash")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
});
it("requireOpenShift is per till: a booth shift does not cover the bay", async () => {
await shift.open("alice");
expect(() => shift.requireOpenShift("carwash")).toThrow(NoShiftOpenError);
await shift.open("wanda", "carwash");
expect(shift.requireOpenShift("carwash").identity).toBe("wanda");
});
it("money folds into ITS till only: bay cash is the wash operator's, not the booth's", async () => {
await shift.open("alice");
await shift.open("wanda", "carwash");
await payment(10000); // booth (payment events carry till=booth or nothing)
await bayPayment(70000);
await bayPayment(20000, "card");
const booth = shift.currentReport()!;
expect(booth.till).toBe("booth");
expect(booth.cashTotalMinor).toBe(10000);
expect(booth.paymentCount).toBe(1);
expect(booth.expectedDrawerMinor).toBe(10000);
const wash = shift.currentReport("carwash")!;
expect(wash.till).toBe("carwash");
expect(wash.cashTotalMinor).toBe(70000);
expect(wash.cardTotalMinor).toBe(20000);
expect(wash.paymentCount).toBe(2);
expect(wash.expectedDrawerMinor).toBe(70000);
expect(shift.drawerBalance().balanceMinor).toBe(10000);
expect(shift.drawerBalance("carwash").balanceMinor).toBe(70000);
});
it("vouchers name their till; each till's expected drawer carries forward on its own", async () => {
await shift.open("alice");
await shift.open("wanda", "carwash");
await shift.recordVoucher({ type: "cash_in", operator: "wanda", amountMinor: 5000, reason: "float", till: "carwash" });
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float" });
await bayPayment(70000);
expect(shift.movementsWithStatus({ till: "carwash" }).map((m) => m.amountMinor)).toEqual([5000]);
const washZ = await shift.close("wanda", "carwash");
expect(washZ).toMatchObject({ till: "carwash", cashAddedMinor: 5000, cashTotalMinor: 70000, expectedDrawerMinor: 75000 });
const boothZ = await shift.close("alice");
expect(boothZ).toMatchObject({ till: "booth", cashAddedMinor: 100000, cashTotalMinor: 0, expectedDrawerMinor: 100000 });
// Next shift on each till inherits that till's drawer only.
expect((await shift.open("wanda", "carwash")).openingFloatMinor).toBe(75000);
expect((await shift.open("bob")).openingFloatMinor).toBe(100000);
});
it("close is per till: closing the booth never closes the wash desk", async () => {
await shift.open("alice");
await shift.open("alice", "carwash");
await shift.close("alice");
expect(shift.currentOpenShift()).toBeNull();
expect(shift.currentOpenShift("carwash")?.identity).toBe("alice");
await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError);
});
it("history lists both tills, filterable; pre-till reports read as booth", async () => {
await shift.open("alice");
await shift.open("wanda", "carwash");
await shift.close("wanda", "carwash");
await shift.close("alice");
const all = shift.listShifts();
expect(all.map((s) => s.till).sort()).toEqual(["booth", "carwash"]);
expect(shift.listShifts({ till: "carwash" }).map((s) => s.operator)).toEqual(["wanda"]);
expect(shift.listShifts({ till: "booth" }).map((s) => s.operator)).toEqual(["alice"]);
expect(shift.listOperators("carwash")).toEqual(["wanda"]);
});
});
+236 -118
View File
@@ -1,6 +1,6 @@
import { eq, devices, ledgerEvents, type Db } from "@parking/db"; import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices"; import { orderForRole, printerRoleOf, registry, formatStampSq as zStamp, type PrinterDevice, type PrinterInstance, type PrinterRole } from "@parking/devices";
import type { LedgerPayload } from "@parking/shared"; import { BOOTH_TILL, tillOf, type ChargeLine, type LedgerPayload, type ModuleId, type TillId } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -8,33 +8,46 @@ import type { EventLog } from "./event-log.js";
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger // delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the // events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
// `payment` events taken during the shift by tender and print a Z-report. // `payment` events taken during the shift by tender and print a Z-report.
//
// TILLS (2026-09-05): a shift is opened ON A TILL — the booth, or a money-taking
// module's own desk (Car Wash → "carwash"). One shift may be open PER TILL, each with
// its own operator, opening float, expected drawer and Z-report. Every money event
// names its till (`payload.till`; absent = booth, which is what every pre-till event
// is), and every fold in this file filters by it. Every public method takes the till,
// defaulting to the booth so the parking paths read as they always did.
// See wiki/concepts/shift.md. // See wiki/concepts/shift.md.
export class ShiftAlreadyOpenError extends Error { export class ShiftAlreadyOpenError extends Error {
/** The operator who currently holds the open shift (may be someone else). */ /** The operator who currently holds the open shift (may be someone else). */
readonly heldBy: string; readonly heldBy: string;
constructor(operator: string, heldBy: string) { constructor(operator: string, heldBy: string, till: TillId = BOOTH_TILL) {
super( super(
heldBy === operator heldBy === operator
? `operator ${operator} already has an open shift` ? `operator ${operator} already has an open ${till} shift`
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`, : `another operator (${heldBy}) has an open ${till} shift; only one shift may be open per till`,
); );
this.name = "ShiftAlreadyOpenError"; this.name = "ShiftAlreadyOpenError";
this.heldBy = heldBy; this.heldBy = heldBy;
} }
} }
export class NoOpenShiftError extends Error { export class NoOpenShiftError extends Error {
constructor(operator: string) { constructor(operator: string, till: TillId = BOOTH_TILL) {
super(`operator ${operator} has no open shift`); super(`operator ${operator} has no open ${till} shift`);
this.name = "NoOpenShiftError"; this.name = "NoOpenShiftError";
} }
} }
/** Thrown by the booth money path when NO shift is open site-wide — an operator /** Thrown by a money path when NO shift is open on its till — an operator must open
* must open a shift before any payment/exit can be attributed to a shift. */ * a shift there before any payment/exit can be attributed to one. */
export class NoShiftOpenError extends Error { export class NoShiftOpenError extends Error {
constructor() { readonly till: TillId;
super("no shift is open — open a shift before processing tickets"); constructor(till: TillId = BOOTH_TILL) {
super(
till === BOOTH_TILL
? "no shift is open — open a shift before processing tickets"
: `no ${till} shift is open — open one before taking money there`,
);
this.name = "NoShiftOpenError"; this.name = "NoShiftOpenError";
this.till = till;
} }
} }
@@ -44,6 +57,8 @@ export class NoShiftOpenError extends Error {
export interface ShiftSummary { export interface ShiftSummary {
readonly id: string; readonly id: string;
readonly index: number; readonly index: number;
/** The till this shift reconciled (booth for every pre-till report). */
readonly till: TillId;
readonly operator: string; readonly operator: string;
readonly startedAt: string; readonly startedAt: string;
readonly endedAt: string; readonly endedAt: string;
@@ -56,13 +71,21 @@ export interface ShiftSummary {
readonly subscriptionSalesMinor: number; readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number; readonly subscriptionWindowMinor: number;
readonly discountTotalMinor: number; readonly discountTotalMinor: number;
readonly chargesByModuleMinor: ChargesByModule;
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
readonly cashAddedMinor: number; readonly cashAddedMinor: number;
readonly cashRemovedMinor: number; readonly cashRemovedMinor: number;
readonly expectedDrawerMinor: number; readonly expectedDrawerMinor: number;
} }
/** Module money folded into this till's payments as `chargeLines`, by owning module —
* a wash paid on the parking ticket lands here as `{ carwash: <minor> }`. Only modules
* that actually charged in the window appear. Cash+card already contain it; it is
* broken OUT of the ticket bucket so "Bileta" is parking money only. */
export type ChargesByModule = Partial<Record<ModuleId, number>>;
export interface ShiftReport { export interface ShiftReport {
readonly till: TillId;
readonly operator: string; readonly operator: string;
readonly startedAt: string; readonly startedAt: string;
readonly endedAt: string; readonly endedAt: string;
@@ -82,6 +105,8 @@ export interface ShiftReport {
/** Merchant-validation DISCOUNT total given away in the window (leakage — the /** Merchant-validation DISCOUNT total given away in the window (leakage — the
* cash/card figures above are already NET of it). See validation-discounts.md. */ * cash/card figures above are already NET of it). See validation-discounts.md. */
readonly discountTotalMinor: number; readonly discountTotalMinor: number;
/** Module charges settled on this till's payments (a booth-paid wash), by module. */
readonly chargesByModuleMinor: ChargesByModule;
// --- Drawer (physical cash till; carries across shifts) --- // --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */ /** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
@@ -102,6 +127,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
export interface DrawerMovement { export interface DrawerMovement {
readonly id: string; readonly id: string;
readonly type: "cash_in" | "cash_out"; readonly type: "cash_in" | "cash_out";
/** Which drawer the cash moved in/out of. */
readonly till: TillId;
/** Positive magnitude; direction is the `type`. */ /** Positive magnitude; direction is the `type`. */
readonly amountMinor: number; readonly amountMinor: number;
readonly currency: string | null; readonly currency: string | null;
@@ -122,6 +149,18 @@ export class InvalidCashMovementError extends Error {
} }
} }
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
/** The takings line a till's OWN money prints under (the booth sells tickets; the wash
* desk sells washes) and the label a module's charge gets when it rides another
* till's ticket ("Lavazh (në biletë)"). Printed slips are Albanian (i18n.md). */
const TILL_TAKINGS_LABEL: Record<TillId, string> = { booth: "Bileta", carwash: "Lavazh" };
const MODULE_PRINT_LABEL: Partial<Record<ModuleId, string>> = { carwash: "Lavazh", validation: "Validime" };
/** Which printer a till's slips (Z-report, vouchers) want. The wash desk falls back to
* the booth printer when it has none of its own (orderForRole); the booth never falls
* back to the desk. See wiki/concepts/printer-roles-failover.md. */
const TILL_PRINTER_ROLE: Record<TillId, PrinterRole> = { booth: "booth-receipt", carwash: "wash-desk" };
export class ShiftService { export class ShiftService {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
@@ -133,41 +172,42 @@ export class ShiftService {
this.#logger = logger; this.#logger = logger;
} }
/** Current physical drawer balance (cash payments + cash_movements, by time). For /** Current physical drawer balance of a till (cash payments + cash_movements, by
* the UI to show "inherited / in the drawer now". */ * time). For the UI to show "inherited / in the drawer now". */
drawerBalance(): { balanceMinor: number; currency: string | null } { drawerBalance(till: TillId = BOOTH_TILL): { balanceMinor: number; currency: string | null } {
return this.#drawerBalanceAt(new Date().toISOString()); return this.#drawerBalanceAt(new Date().toISOString(), till);
} }
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */ /** The shift-boundary events (shift_open / shift_z_report) of ONE till, chain order. */
openShiftFor(operator: string) { #shiftEvents(till: TillId) {
// Scan shift events for this operator; the shift is open if the most recent return this.#db
// shift event for them is a `shift_open` (not yet closed by a z_report).
const rows = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
.where(eq(ledgerEvents.identity, operator)) .where(inArray(ledgerEvents.type, ["shift_open", "shift_z_report"]))
.orderBy(ledgerEvents.index) .orderBy(ledgerEvents.index)
.all() .all()
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report"); .filter((r) => tillOf(r.payload as LedgerPayload | null) === till);
}
/** Is there an open shift for this operator on this till? Returns the open
* `shift_open` row or null. */
openShiftFor(operator: string, till: TillId = BOOTH_TILL) {
// The shift is open if the operator's most recent shift event on the till is a
// `shift_open` (not yet closed by a z_report).
const rows = this.#shiftEvents(till).filter((r) => r.identity === operator);
const last = rows[rows.length - 1]; const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null; return last && last.type === "shift_open" ? last : null;
} }
/** /**
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability * The SINGLE open shift of a till, or null. A shift is the till's accountability
* period: at most ONE may be open at a time (so booth takings are unambiguously * period: at most ONE may be open per till at a time (so its takings are
* attributed to one operator). It's open iff the most recent shift event on the * unambiguously attributed to one operator). It's open iff the till's most recent
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been * shift event is a `shift_open` (the matching `shift_z_report` hasn't been appended
* appended yet). Returns that row so callers can read its operator/startedAt. * yet). Returns that row so callers can read its operator/startedAt.
*/ */
currentOpenShift() { currentOpenShift(till: TillId = BOOTH_TILL) {
const rows = this.#db const rows = this.#shiftEvents(till);
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
const last = rows[rows.length - 1]; const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null; return last && last.type === "shift_open" ? last : null;
} }
@@ -186,24 +226,25 @@ export class ShiftService {
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask * distinct + sorted — feeds the admin filter dropdown so it can only ever ask
* for an operator that exists (the filter is an exact username match). * for an operator that exists (the filter is an exact username match).
*/ */
listOperators(): string[] { listOperators(till?: TillId): string[] {
const rows = this.#db const rows = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
.where(eq(ledgerEvents.type, "shift_z_report")) .where(inArray(ledgerEvents.type, ["shift_z_report", "shift_open"]))
.all(); .all()
.filter((r) => till == null || tillOf(r.payload as LedgerPayload | null) === till);
// Every operator with a closed report, plus the holder of each open shift (an
// open shift is the last shift_open on its till — but any shift_open's operator
// has or had a shift, which is all the dropdown needs).
const names = new Set<string>(); const names = new Set<string>();
for (const r of rows) { for (const r of rows) {
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity; const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
if (op) names.add(op); if (op) names.add(op);
} }
const open = this.currentOpenShift();
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
if (openOp) names.add(openOp);
return [...names].sort((a, b) => a.localeCompare(b)); return [...names].sort((a, b) => a.localeCompare(b));
} }
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] { listShifts(opts: { operator?: string; from?: string; to?: string; till?: TillId } = {}): ShiftSummary[] {
const rows = this.#db const rows = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
@@ -225,6 +266,7 @@ export class ShiftService {
subscriptionSalesMinor?: number; subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number; subscriptionWindowMinor?: number;
discountTotalMinor?: number; discountTotalMinor?: number;
chargesByModuleMinor?: ChargesByModule;
openingFloatMinor?: number; openingFloatMinor?: number;
cashAddedMinor?: number; cashAddedMinor?: number;
cashRemovedMinor?: number; cashRemovedMinor?: number;
@@ -232,12 +274,15 @@ export class ShiftService {
}; };
const operator = pl.operator ?? r.identity ?? "?"; const operator = pl.operator ?? r.identity ?? "?";
const startedAt = pl.startedAt ?? r.occurredAt; const startedAt = pl.startedAt ?? r.occurredAt;
const till = tillOf(pl);
if (opts.till && till !== opts.till) continue;
if (opts.operator && operator !== opts.operator) continue; if (opts.operator && operator !== opts.operator) continue;
if (opts.from && startedAt < opts.from) continue; if (opts.from && startedAt < opts.from) continue;
if (opts.to && startedAt > opts.to) continue; if (opts.to && startedAt > opts.to) continue;
out.push({ out.push({
id: r.id, id: r.id,
index: r.index, index: r.index,
till,
operator, operator,
startedAt, startedAt,
endedAt: pl.endedAt ?? r.occurredAt, endedAt: pl.endedAt ?? r.occurredAt,
@@ -257,6 +302,8 @@ export class ShiftService {
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0), (pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0. // Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
discountTotalMinor: pl.discountTotalMinor ?? 0, discountTotalMinor: pl.discountTotalMinor ?? 0,
// Module charges on the ticket (added 2026-09-06). Old reports lack it → none.
chargesByModuleMinor: pl.chargesByModuleMinor ?? {},
openingFloatMinor: pl.openingFloatMinor ?? 0, openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0, cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0, cashRemovedMinor: pl.cashRemovedMinor ?? 0,
@@ -267,10 +314,10 @@ export class ShiftService {
return out.reverse(); return out.reverse();
} }
/** Require an open shift for the booth money path; returns it or throws. */ /** Require an open shift on a till for its money path; returns it or throws. */
requireOpenShift() { requireOpenShift(till: TillId = BOOTH_TILL) {
const open = this.currentOpenShift(); const open = this.currentOpenShift(till);
if (!open) throw new NoShiftOpenError(); if (!open) throw new NoShiftOpenError(till);
return open; return open;
} }
@@ -283,9 +330,10 @@ export class ShiftService {
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude) * - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / − * - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
* removal) — historical chain events that still fold in unchanged. * removal) — historical chain events that still fold in unchanged.
* This is what carries across shifts. * This is what carries across shifts. ONE till: every money event is filtered by
* `tillOf(payload)` (absent = booth).
*/ */
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } { #drawerBalanceAt(at: string, till: TillId): { balanceMinor: number; currency: string | null } {
const rows = this.#db const rows = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
@@ -295,16 +343,20 @@ export class ShiftService {
(r) => (r) =>
r.occurredAt <= at && r.occurredAt <= at &&
(r.type === "payment" || (r.type === "payment" ||
// Car Wash module: money taken at the bay (cash adds to the drawer, card
// never does — same tender rule as a parking payment).
r.type === "carwash_payment" ||
r.type === "cash_in" || r.type === "cash_in" ||
r.type === "cash_out" || r.type === "cash_out" ||
r.type === "cash_movement"), r.type === "cash_movement") &&
tillOf(r.payload as LedgerPayload | null) === till,
); );
let balanceMinor = 0; let balanceMinor = 0;
let currency: string | null = null; let currency: string | null = null;
for (const r of rows) { for (const r of rows) {
const pl = (r.payload ?? {}) as LedgerPayload; const pl = (r.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (r.type === "payment") { if (r.type === "payment" || r.type === "carwash_payment") {
// Only CASH enters the till; card settles to the bank. // Only CASH enters the till; card settles to the bank.
if (pl.tender !== "card") balanceMinor += amt; if (pl.tender !== "card") balanceMinor += amt;
} else if (r.type === "cash_in") { } else if (r.type === "cash_in") {
@@ -347,8 +399,11 @@ export class ShiftService {
amountMinor: number; amountMinor: number;
reason: string; reason: string;
currency?: string; currency?: string;
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> { /** Which drawer the cash moved in/out of (default: the booth). */
till?: TillId;
}): Promise<{ type: "cash_in" | "cash_out"; till: TillId; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
const { type, operator, reason } = args; const { type, operator, reason } = args;
const till = args.till ?? BOOTH_TILL;
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) { if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)"); throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
} }
@@ -365,15 +420,16 @@ export class ShiftService {
...(args.currency ? { currency: args.currency } : {}), ...(args.currency ? { currency: args.currency } : {}),
operator, operator,
voucherNo, voucherNo,
till,
}, },
occurredAt: now, occurredAt: now,
}); });
const { balanceMinor, currency } = this.#drawerBalanceAt(now); const { balanceMinor, currency } = this.#drawerBalanceAt(now, till);
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now }); const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now, till });
this.#logger.info( this.#logger.info(
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`, `${type} ${voucherNo} ${amountMinor} by ${operator} on ${till} (${reason || "no reason"}) → drawer ${balanceMinor}`,
); );
return { type, amountMinor, voucherNo, balanceMinor, printed }; return { type, till, amountMinor, voucherNo, balanceMinor, printed };
} }
/** /**
@@ -431,7 +487,7 @@ export class ShiftService {
* review queue. `operator` (optional) scopes to one operator's movements (an operator * review queue. `operator` (optional) scopes to one operator's movements (an operator
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md. * sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
*/ */
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] { movementsWithStatus(filter?: { operator?: string; status?: MovementStatus; till?: TillId }): DrawerMovement[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Latest review decision per movement id. // Latest review decision per movement id.
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>(); const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
@@ -452,12 +508,15 @@ export class ShiftService {
const pl = (r.payload ?? {}) as LedgerPayload; const pl = (r.payload ?? {}) as LedgerPayload;
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? ""; const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
if (filter?.operator && operator !== filter.operator) continue; if (filter?.operator && operator !== filter.operator) continue;
const till = tillOf(pl);
if (filter?.till && till !== filter.till) continue;
const review = reviewByRef.get(r.id); const review = reviewByRef.get(r.id);
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending"; const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
if (filter?.status && status !== filter.status) continue; if (filter?.status && status !== filter.status) continue;
out.push({ out.push({
id: r.id, id: r.id,
type: r.type, type: r.type,
till,
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0, amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
currency: pl.currency ?? null, currency: pl.currency ?? null,
reason: pl.reason ?? null, reason: pl.reason ?? null,
@@ -474,27 +533,28 @@ export class ShiftService {
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)); return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
} }
/** Open a shift for the operator (explicit start). The opening float is auto- /** Open a shift for the operator on a till (explicit start). The opening float is
* inherited from the chain = the drawer balance at the start instant. */ * auto-inherited from the chain = that till's drawer balance at the start instant. */
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> { async open(operator: string, till: TillId = BOOTH_TILL): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
// Site-wide single-open invariant: refuse if ANY shift is open — whether this // Single-open-per-till invariant: refuse if a shift is open ON THIS TILL — whether
// operator's own (double-open) or another operator's (handover not done). Only // this operator's own (double-open) or another operator's (handover not done).
// one accountability period at a time. // One accountability period per drawer at a time. (Another till's shift is
const current = this.currentOpenShift(); // independent: the booth and the wash desk run side by side.)
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator); const current = this.currentOpenShift(till);
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator, till);
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt); const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt, till);
await this.#log.append({ await this.#log.append({
type: "shift_open", type: "shift_open",
source: "manual", source: "manual",
identity: operator, // the shift's operator; `identity` keys the shift to them identity: operator, // the shift's operator; `identity` keys the shift to them
// Record the inherited opening float on the shift_open so it's reproducible // Record the inherited opening float on the shift_open so it's reproducible
// and the next operator's handover figure is fixed in the chain. // and the next operator's handover figure is fixed in the chain.
payload: { operator, openingFloatMinor }, payload: { operator, openingFloatMinor, till },
occurredAt: startedAt, occurredAt: startedAt,
}); });
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`); this.#logger.info(`${till} shift opened for ${operator} (opening float ${openingFloatMinor})`);
return { startedAt, openingFloatMinor }; return { startedAt, till, openingFloatMinor };
} }
/** /**
@@ -510,15 +570,22 @@ export class ShiftService {
): Omit<ShiftReport, "printed"> { ): Omit<ShiftReport, "printed"> {
const operator = open.identity ?? "?"; const operator = open.identity ?? "?";
const startedAt = open.occurredAt; const startedAt = open.occurredAt;
const till = tillOf(open.payload as LedgerPayload | null);
// All payments taken in [startedAt, asOf], summed by tender. Payment time = // All payments taken ON THIS TILL in [startedAt, asOf], summed by tender. Payment
// the operator who handled the money (decision: sum by payment time). // time = the operator who handled the money (decision: sum by payment time).
const payments = this.#db const payments = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
.where(eq(ledgerEvents.type, "payment")) // Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
// parking payment's amount already, as chargeLines). Both fold into the cash/card
// tender totals so the expected drawer is right; the booth-paid wash is then
// broken OUT of the ticket bucket into chargesByModuleMinor (see below).
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
.all() .all()
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf); .filter(
(r) => r.occurredAt >= startedAt && r.occurredAt <= asOf && tillOf(r.payload as LedgerPayload | null) === till,
);
let cashTotalMinor = 0; let cashTotalMinor = 0;
let cardTotalMinor = 0; let cardTotalMinor = 0;
@@ -532,11 +599,16 @@ export class ShiftService {
// Merchant-validation leakage: Σ discountMinor across the window's payments. The // Merchant-validation leakage: Σ discountMinor across the window's payments. The
// tender totals are already NET; this is the "given away" figure beside them. // tender totals are already NET; this is the "given away" figure beside them.
let discountTotalMinor = 0; let discountTotalMinor = 0;
// Module charges folded into this till's payments (chargeLines on a booth payment),
// summed by owning module. Part of cash/card; NOT ticket money.
const chargesByModuleMinor: ChargesByModule = {};
let chargesTotalMinor = 0;
let currency: string | null = null; let currency: string | null = null;
for (const p of payments) { for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload & { const pl = (p.payload ?? {}) as LedgerPayload & {
subscriptionSale?: boolean; subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean; subscriptionWindowCharge?: boolean;
chargeLines?: ChargeLine[];
}; };
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (pl.tender === "card") cardTotalMinor += amt; if (pl.tender === "card") cardTotalMinor += amt;
@@ -545,10 +617,17 @@ export class ShiftService {
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt; else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription) // (else → transient ticket; derived below as total − subscription)
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor; if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
for (const l of pl.chargeLines ?? []) {
if (typeof l.amountMinor !== "number" || !l.module) continue;
chargesByModuleMinor[l.module] = (chargesByModuleMinor[l.module] ?? 0) + l.amountMinor;
chargesTotalMinor += l.amountMinor;
}
if (pl.currency) currency = pl.currency; if (pl.currency) currency = pl.currency;
} }
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor; const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor; // Ticket = what is left once subscriber money and module charges are taken out:
// ticket + subscriptions + Σcharges = cash + card, always.
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor - chargesTotalMinor;
// --- Drawer figures --- // --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start); // Opening float was fixed on shift_open (inherited from the chain at start);
@@ -557,7 +636,7 @@ export class ShiftService {
const openingFloatMinor = const openingFloatMinor =
typeof openPl.openingFloatMinor === "number" typeof openPl.openingFloatMinor === "number"
? openPl.openingFloatMinor ? openPl.openingFloatMinor
: this.#drawerBalanceAt(startedAt).balanceMinor; : this.#drawerBalanceAt(startedAt, till).balanceMinor;
// Drawer movements within the window, split into added (+) and removed (−). // Drawer movements within the window, split into added (+) and removed (−).
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-± // Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
@@ -570,7 +649,8 @@ export class ShiftService {
(r) => (r) =>
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") && (r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
r.occurredAt >= startedAt && r.occurredAt >= startedAt &&
r.occurredAt <= asOf, r.occurredAt <= asOf &&
tillOf(r.payload as LedgerPayload | null) === till,
); );
let cashAddedMinor = 0; let cashAddedMinor = 0;
let cashRemovedMinor = 0; let cashRemovedMinor = 0;
@@ -589,6 +669,7 @@ export class ShiftService {
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor; const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
return { return {
till,
operator, operator,
startedAt, startedAt,
endedAt: asOf, endedAt: asOf,
@@ -601,6 +682,7 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -615,17 +697,18 @@ export class ShiftService {
* projection the Z-report prints, so the operator sees exactly what their close * projection the Z-report prints, so the operator sees exactly what their close
* will show. See wiki/concepts/shift.md. * will show. See wiki/concepts/shift.md.
*/ */
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null { currentReport(till: TillId = BOOTH_TILL): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
const open = this.currentOpenShift(); const open = this.currentOpenShift(till);
if (!open) return null; if (!open) return null;
const asOf = new Date().toISOString(); const asOf = new Date().toISOString();
return { ...this.#summariseWindow(open, asOf), asOf }; return { ...this.#summariseWindow(open, asOf), asOf };
} }
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */ /** Close the operator's open shift on a till: sum its payments in the window, sign +
async close(operator: string): Promise<ShiftReport> { * print the Z-report. */
const open = this.openShiftFor(operator); async close(operator: string, till: TillId = BOOTH_TILL): Promise<ShiftReport> {
if (!open) throw new NoOpenShiftError(operator); const open = this.openShiftFor(operator, till);
if (!open) throw new NoOpenShiftError(operator, till);
const endedAt = new Date().toISOString(); const endedAt = new Date().toISOString();
const report = this.#summariseWindow(open, endedAt); const report = this.#summariseWindow(open, endedAt);
@@ -640,6 +723,7 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -652,6 +736,7 @@ export class ShiftService {
identity: operator, identity: operator,
payload: { payload: {
operator, operator,
till,
startedAt, startedAt,
endedAt, endedAt,
cashTotalMinor, cashTotalMinor,
@@ -663,6 +748,8 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
// Only when a module charged in the window (older slips/payloads stay identical).
...(Object.keys(chargesByModuleMinor).length ? { chargesByModuleMinor } : {}),
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -673,25 +760,23 @@ export class ShiftService {
const printed = await this.#printZReport(report); const printed = await this.#printZReport(report);
this.#logger.info( this.#logger.info(
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` + `${till} shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`, `drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
); );
return { ...report, printed }; return { ...report, printed };
} }
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event /** Print the Z-report on the till's printer (best-effort; the signed event is the
* is the record — a failed print doesn't undo the close). */ * record — a failed print doesn't undo the close). */
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> { async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
return false;
}
const cur = r.currency ?? ""; const cur = r.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2); const money = (m: number) => (m / 100).toFixed(2);
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not // Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25". // governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
const lines = [ const lines = [
// Which drawer this report reconciles — only printed off the booth, so booth
// slips stay byte-identical to before tills existed.
...(r.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[r.till]}`] : []),
`Operatori: ${r.operator}`, `Operatori: ${r.operator}`,
`Nga: ${zStamp(r.startedAt)}`, `Nga: ${zStamp(r.startedAt)}`,
`Deri: ${zStamp(r.endedAt)}`, `Deri: ${zStamp(r.endedAt)}`,
@@ -701,11 +786,23 @@ export class ShiftService {
`Kartë: ${money(r.cardTotalMinor)} ${cur}`, `Kartë: ${money(r.cardTotalMinor)} ${cur}`,
"", "",
"-- Arkëtime sipas burimit --", "-- Arkëtime sipas burimit --",
// The booth prints its three classic lines (byte-identical to before tills); a
// module's till prints its own takings under its own name — it sells no tickets
// and no subscriptions.
...(r.till === BOOTH_TILL
? [
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`, `Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out. // Abonime is the subscription TOTAL; only the out-of-window part is broken out.
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.) // (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`, `Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`, `Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
]
: [`${TILL_TAKINGS_LABEL[r.till]}: ${money(r.ticketTotalMinor)} ${cur}`]),
// Module money that rode this till's tickets (a booth-paid wash) — its own line,
// only when any was taken, so the operator sees parking and wash money apart.
...Object.entries(r.chargesByModuleMinor)
.filter(([, v]) => (v ?? 0) > 0)
.map(([m, v]) => `${MODULE_PRINT_LABEL[m as ModuleId] ?? m} (në biletë): ${money(v ?? 0)} ${cur}`),
// Merchant-validation leakage — printed only when the shift actually gave any // Merchant-validation leakage — printed only when the shift actually gave any
// (older slips stay byte-identical). The takings above are already NET of it. // (older slips stay byte-identical). The takings above are already NET of it.
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []), ...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
@@ -717,13 +814,7 @@ export class ShiftService {
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`, `Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`, `Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
]; ];
try { return this.#printOn(r.till, `Z-report for ${r.operator}`, (p) => p.printReport({ title: "RAPORT TURNI", lines }));
await printer.printReport({ title: "RAPORT TURNI", lines });
return true;
} catch (err) {
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
return false;
}
} }
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort — /** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
@@ -737,17 +828,14 @@ export class ShiftService {
operator: string; operator: string;
currency: string | null; currency: string | null;
at: string; at: string;
till: TillId;
}): Promise<boolean> { }): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
return false;
}
const cur = v.currency ?? ""; const cur = v.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2); const money = (m: number) => (m / 100).toFixed(2);
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE"; const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
const lines = [ const lines = [
`Mandat Nr.: ${v.voucherNo}`, `Mandat Nr.: ${v.voucherNo}`,
...(v.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[v.till]}`] : []),
`Data: ${zStamp(v.at)}`, `Data: ${zStamp(v.at)}`,
"", "",
`Shuma: ${money(v.amountMinor)} ${cur}`, `Shuma: ${money(v.amountMinor)} ${cur}`,
@@ -755,27 +843,57 @@ export class ShiftService {
"", "",
`Regjistroi: ${v.operator}`, `Regjistroi: ${v.operator}`,
]; ];
try { return this.#printOn(v.till, `${v.type} ${v.voucherNo}`, (p) => p.printReport({ title, lines }));
await printer.printReport({ title, lines });
return true;
} catch (err) {
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
return false;
}
} }
/** First enabled booth-receipt printer, or any enabled printer. */ /** Print a till's slip on its printer with failover (wash desk → booth printer;
async #boothPrinter(): Promise<PrinterDevice | null> { * see TILL_PRINTER_ROLE / orderForRole). Best-effort: the signed event is the
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all(); * record — every failure is logged and reported as "not printed", never thrown.
const enabled = rows.filter((r) => r.enabled); * Legacy fallback: a site whose only printer carries no booth role (one unit,
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0]; * configured as the entry dispenser) still prints its slips on it, as before. */
if (!booth) return null; async #printOn(till: TillId, what: string, job: (p: PrinterDevice) => Promise<void>): Promise<boolean> {
const driver = registry.get(booth.driverId); const printers = this.#loadPrinters();
if (!driver) return null; const want = TILL_PRINTER_ROLE[till];
let ordered = orderForRole(printers, want);
if (ordered.length === 0 && till === BOOTH_TILL) ordered = printers.slice(0, 1);
if (ordered.length === 0) {
this.#logger.warn(`no ${want} printer — ${what} not printed (event is recorded)`);
return false;
}
const attempts: string[] = [];
for (const p of ordered) {
try { try {
return driver.create(booth.config as never) as PrinterDevice; await job(p.device);
if (p.role !== want) this.#logger.info(`${what} printed on ${p.id} (${p.role}; no ${want} printer reachable)`);
return true;
} catch (err) {
attempts.push(`${p.id} (${(err as Error).message})`);
}
}
this.#logger.warn(`${what} print failed on every candidate: ${attempts.join(", ")} (event recorded)`);
return false;
}
/** Every enabled printer as a live instance (role + rank from its saved config). */
#loadPrinters(): PrinterInstance[] {
const rows = this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
const out: PrinterInstance[] = [];
for (const row of rows) {
if (!row.enabled) continue;
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
try {
out.push({
id: row.id,
role: printerRoleOf(cfg),
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch { } catch {
return null; // skip a printer whose config won't build
} }
} }
return out;
}
} }
+53 -8
View File
@@ -167,22 +167,42 @@ async function recognizePlate(
): Promise<void> { ): Promise<void> {
try { try {
const result = await vision.analyze(shot.bytes, shot.contentType); const result = await vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate || result.lowConfidence) return; // nothing trustworthy to record if (!result) return;
const plate = result.plate.text.trim().toUpperCase(); // Boxes are kept as FRACTIONS of the analysed frame (the stored snapshot is a
if (!plate) return; // downscaled copy — see reencodeForStorage), so the wash's review crop can cut the
// vehicle out of whatever copy survives and blur the plate inside it.
const frame = await frameSize(shot.bytes);
const norm = (b: { x1: number; y1: number; x2: number; y2: number } | null | undefined) =>
b && frame
? {
x1: clamp01(b.x1 / frame.w), y1: clamp01(b.y1 / frame.h),
x2: clamp01(b.x2 / frame.w), y2: clamp01(b.y2 / frame.h),
}
: null;
// The vehicle's body type (advisory; the wash desk's category suggestion — see
// venue-modules.md §Vehicle category). Rides the plate's read row when there is one,
// else a row of its own: a car with an unreadable plate is still a car of some class.
const vehicleBox = norm(result.vehicle?.bbox);
const vehicle = result.vehicle
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence, ...(vehicleBox ? { vehicleBox } : {}) }
: {};
const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase();
const plateBox = plate ? norm(result.plate?.bbox) : null;
if (!plate && !result.vehicle) return; // nothing trustworthy to record
db.insert(deviceEventsTable) db.insert(deviceEventsTable)
.values({ .values({
id: randomUUID(), id: randomUUID(),
deviceId, deviceId,
category: "camera", category: "camera",
kind: "read", kind: "read",
// `identity` ties the plate to the session; `snapshotId` to the evidence image. // `identity` ties the read to the session; `snapshotId` to the evidence image.
detail: { detail: {
identity, identity,
direction, direction,
plate, ...(plate
confidence: result.plate.confidence, ? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null, ...(plateBox ? { plateBox } : {}) }
region: result.plate.region ?? null, : {}),
...vehicle,
modelVersion: result.modelVersion, modelVersion: result.modelVersion,
snapshotId, snapshotId,
source: "entry-exit-snapshot", source: "entry-exit-snapshot",
@@ -190,7 +210,18 @@ async function recognizePlate(
occurredAt: new Date().toISOString(), occurredAt: new Date().toISOString(),
}) })
.run(); .run();
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`); if (result.vehicle) {
logger.info(`vision vehicle '${result.vehicle.bodyType}' (${result.vehicle.confidence.toFixed(3)}) for ${identity}`);
if (vehicleBox) {
deviceEvents.emitVehicleRead({
identity,
direction,
read: { bodyType: result.vehicle.bodyType, confidence: result.vehicle.confidence, snapshotId, box: vehicleBox, plateBox },
});
}
}
if (!plate) return;
logger.info(`anpr plate '${plate}' (${result.plate!.confidence.toFixed(3)}) for ${identity}`);
// The session's entry/exit event already shipped without this (async) plate — tell the // The session's entry/exit event already shipped without this (async) plate — tell the
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched. // booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
deviceEvents.emitPlateRecognized({ identity, plate, direction }); deviceEvents.emitPlateRecognized({ identity, plate, direction });
@@ -206,6 +237,20 @@ async function recognizePlate(
} }
} }
function clamp01(v: number): number {
return Math.max(0, Math.min(1, v));
}
/** Pixel size of the analysed frame (JPEG header only — cheap). Null when unreadable. */
async function frameSize(bytes: Buffer): Promise<{ w: number; h: number } | null> {
try {
const m = await sharp(bytes, { failOn: "none" }).metadata();
return m.width && m.height ? { w: m.width, h: m.height } : null;
} catch {
return null;
}
}
/** How far back a recognized entry plate is compared against other OPEN sessions' /** How far back a recognized entry plate is compared against other OPEN sessions'
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car * entry plates. Short on purpose: the duplicate-ticket scenario is the same car
* re-pressing within minutes; a long window would flag legit re-visits. */ * re-pressing within minutes; a long window would flag legit re-visits. */
+169 -1
View File
@@ -1,4 +1,5 @@
import { eq, ledgerEvents, type Db } from "@parking/db"; import { eq, ledgerEvents, type Db, and, isNull, validationPrograms } from "@parking/db";
import type { EventLog } from "./event-log.js";
import type { SessionValidation, ValidationMode } from "@parking/shared"; import type { SessionValidation, ValidationMode } from "@parking/shared";
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the // Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
@@ -86,3 +87,170 @@ export function sessionValidations(db: Db, identity: string): AppliedValidation[
export function liveValidations(db: Db, identity: string): AppliedValidation[] { export function liveValidations(db: Db, identity: string): AppliedValidation[] {
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null); return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
} }
// --- Apply (shared by the merchant route and the Car Wash module) ----------------
export interface ApplyValidationInput {
programId: string;
identity: string;
/** Username recorded as the applying operator. */
actor: string;
/** fixed mode only: the amount the operator grants (minor units, ≤ maxAmountMinor). */
amountMinor?: number;
/** Car Wash context — required by the wash-only modes (doneTolerance / washPrice), which
* are RESOLVED here into a plain timeCredit / fixed event the pricing fold already
* understands: `washMinutes` = the wash window (order intake → done), NOT the whole
* stay; `priceMinor` = the wash price. */
wash?: { washMinutes: number; priceMinor: number };
}
export type ApplyValidationResult =
| {
ok: true;
eventId: string;
programId: string;
label: string;
mode: string;
minutes?: number | null;
percent?: number | null;
amountMinor?: number;
}
| { ok: false; status: 400 | 404 | 409; error: string };
/**
* Apply a validation program to an open transient session and append the signed
* `validation` event with the RESOLVED values. The decision chain, in order: program
* live + active → open TRANSIENT session → not already carrying a live application of
* this program → per-day cap → fixed-amount bounds. The merchant route adds its own
* program↔user BINDING check before calling this; a module applying its own program
* (Car Wash sponsorship) has no binding — the actor is attributed on the event instead.
* Returns a result object rather than throwing so each caller maps to its own HTTP
* shape. See wiki/concepts/validation-discounts.md.
*/
export async function applyValidation(
db: Db,
eventLog: EventLog,
input: ApplyValidationInput,
): Promise<ApplyValidationResult> {
const { programId, identity, actor } = input;
const program = db
.select()
.from(validationPrograms)
.where(and(eq(validationPrograms.id, programId), isNull(validationPrograms.deletedAt)))
.get();
if (!program || !program.active) return { ok: false, status: 404, error: "program not found or inactive" };
const rows = db
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return { ok: false, status: 404, error: "no session for ticket" };
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return { ok: false, status: 409, error: "subscription sessions cannot be validated" };
}
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
return { ok: false, status: 409, error: "session is closed" };
}
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
return { ok: false, status: 409, error: "this program is already applied to the ticket" };
}
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
// appliance runs in site time).
if (program.maxPerDay != null) {
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const todays = db
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "validation"))
.all()
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
const voidedIds = new Set(
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
);
const count = todays.filter((r) => {
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
}).length;
if (count >= program.maxPerDay) return { ok: false, status: 409, error: "daily cap reached for this program" };
}
// Resolve the program into the event's (mode, minutes/percent/amount). The wash-only
// modes become the plain modes the pricing fold knows; `programMode` keeps the original
// on the signed event for audit.
let mode: "comp" | "timeCredit" | "fixed" | "percent";
let minutes: number | undefined;
let percent: number | undefined;
let amountMinor: number | undefined;
switch (program.mode) {
case "comp":
mode = "comp";
break;
case "timeCredit":
mode = "timeCredit";
minutes = program.minutes ?? undefined;
break;
case "percent":
mode = "percent";
percent = program.percent ?? undefined;
break;
case "fixed": {
const a = input.amountMinor;
if (a == null || !Number.isInteger(a) || a <= 0) {
return { ok: false, status: 400, error: "amountMinor (positive integer) required for this program" };
}
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
return { ok: false, status: 400, error: `amount exceeds the program cap (${program.maxAmountMinor})` };
}
mode = "fixed";
amountMinor = a;
break;
}
case "doneTolerance": {
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (done time)" };
mode = "timeCredit";
minutes = Math.max(0, input.wash.washMinutes) + Math.max(0, program.minutes ?? 0);
break;
}
case "washPrice": {
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (price)" };
mode = "fixed";
amountMinor = Math.max(0, input.wash.priceMinor);
break;
}
default:
return { ok: false, status: 400, error: `unknown program mode ${String(program.mode)}` };
}
const ev = await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
programId,
programLabel: program.name,
mode,
...(program.mode !== mode ? { programMode: program.mode } : {}),
...(minutes != null ? { minutes } : {}),
...(percent != null ? { percent } : {}),
...(amountMinor != null ? { amountMinor } : {}),
operator: actor,
},
});
return {
ok: true,
eventId: ev.id,
programId,
label: program.name,
mode,
minutes,
percent,
amountMinor,
};
}
+22 -3
View File
@@ -23,6 +23,8 @@ import type { FastifyBaseLogger } from "fastify";
// transport + contract adapter only. // transport + contract adapter only.
/** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */ /** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */
import { isVehicleClass, type VehicleClass } from "@parking/shared";
export interface PlateBBox { export interface PlateBBox {
readonly x1: number; readonly x1: number;
readonly y1: number; readonly y1: number;
@@ -40,12 +42,21 @@ export interface VisionPlate {
readonly region?: string | null; readonly region?: string | null;
} }
/** The raw /analyze response shape (the Python contract). `vehicle` is reserved for /** The vehicle attributes stage of /analyze (advisory). `body_type` is one of the shared
* Job 2 (vehicle verification) — not yet produced. */ * VEHICLE_CLASSES vocabulary (the service's raw label is normalised there); a stub or a
* plate-only recognizer sends null. */
export interface VisionVehicle {
readonly bodyType: VehicleClass;
readonly confidence: number;
/** The vehicle's box in frame pixels, when the stage found one. */
readonly bbox?: PlateBBox | null;
}
/** The raw /analyze response shape (the Python contract). */
interface AnalyzeResponse { interface AnalyzeResponse {
readonly plate: VisionPlate | null; readonly plate: VisionPlate | null;
readonly plates: VisionPlate[]; readonly plates: VisionPlate[];
readonly vehicle: unknown | null; readonly vehicle: { body_type?: string | null; confidence?: number | null; bbox?: PlateBBox | null } | null;
readonly low_confidence: boolean; readonly low_confidence: boolean;
readonly model_version: string; readonly model_version: string;
readonly took_ms: number; readonly took_ms: number;
@@ -61,6 +72,8 @@ export interface VisionResult {
/** True when the best plate is below the confidence floor — treat as advisory only /** True when the best plate is below the confidence floor — treat as advisory only
* and fall back to the ticket/manual path. */ * and fall back to the ticket/manual path. */
readonly lowConfidence: boolean; readonly lowConfidence: boolean;
/** The vehicle's body type, when the service ran that stage and named a known class. */
readonly vehicle: VisionVehicle | null;
readonly modelVersion: string; readonly modelVersion: string;
readonly tookMs: number; readonly tookMs: number;
} }
@@ -124,10 +137,16 @@ export class VisionClient {
const best = res.plate ?? null; const best = res.plate ?? null;
const lowConfidence = const lowConfidence =
res.low_confidence || (best != null && best.confidence < this.#minConfidence); res.low_confidence || (best != null && best.confidence < this.#minConfidence);
const v = res.vehicle;
const vehicle: VisionVehicle | null =
v && isVehicleClass(v.body_type) && typeof v.confidence === "number"
? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)), bbox: v.bbox ?? null }
: null;
return { return {
plate: best, plate: best,
plates: Array.isArray(res.plates) ? res.plates : [], plates: Array.isArray(res.plates) ? res.plates : [],
lowConfidence, lowConfidence,
vehicle,
modelVersion: res.model_version ?? "unknown", modelVersion: res.model_version ?? "unknown",
tookMs: typeof res.took_ms === "number" ? res.took_ms : 0, tookMs: typeof res.took_ms === "number" ? res.took_ms : 0,
}; };
+7
View File
@@ -0,0 +1,7 @@
.venv/
**/__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
out/
.env
+12
View File
@@ -0,0 +1,12 @@
# Python
__pycache__/
*.py[cod]
.venv/
.mypy_cache/
.pytest_cache/
.ruff_cache/
# Model weights (fetched at deploy / first run, never committed — can be large + license-scoped)
out/
*.onnx
+1
View File
@@ -0,0 +1 @@
3.12
+49
View File
@@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1.7
# Parking TRAINER image: the phase-B body-type classifier. Build CONTEXT is apps/trainer
# (self-contained Python package). Runs on the reviewer's host (art-docker-station) beside
# the collector, never on a booth: by default it SERVES the job API the collector's Training
# section drives (`serve`); the same image runs the CLI one-off (`train`, `inspect`, …). It
# reads the wash collector's volume (collector.sqlite + crops/) and writes versioned model
# folders. CPU-only PyTorch — the host has no usable GPU and a few thousand crops train in
# minutes/an hour on four Xeon cores.
# See wiki/decisions/bodytype-classifier-training.md.
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base
WORKDIR /app
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock .python-version ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev --extra train
COPY trainer/ ./trainer/
COPY README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --extra train
# Pre-warm the ImageNet backbone weights INTO the image so a run needs no network (the
# host has one, but a job that fetches at run time is a job that fails at 2 am). Best-effort:
# without network at build time torchvision fetches lazily on the first run.
ENV TORCH_HOME=/app/torch-home
RUN uv run python -c "import torchvision.models as m; m.resnet18(weights=m.ResNet18_Weights.IMAGENET1K_V1); m.mobilenet_v3_small(weights=m.MobileNet_V3_Small_Weights.IMAGENET1K_V1)" \
|| echo "[build] backbone weights not pre-warmed (no network) — fetched on first run"
RUN useradd --system --create-home --uid 999 trainer \
&& mkdir -p /data /out && chown -R trainer:trainer /app /out
USER trainer
ENV TRAINER_DATA_DIR=/data \
TRAINER_OUT_DIR=/out \
TRAINER_PORT=8091
VOLUME ["/out"]
EXPOSE 8091
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8091/health').status==200 else 1)" || exit 1
ENTRYPOINT ["uv", "run", "--no-sync", "parking-trainer"]
CMD ["serve"]
+36
View File
@@ -0,0 +1,36 @@
# parking-trainer
The phase-B **body-type classifier** job. Reads the wash collector's volume
(`collector.sqlite` + `crops/`), trains a classifier on the reviewer's labels, and writes a
versioned model folder the vision image bakes in — or refuses when validation is below the
floor. Design and decisions: `wiki/decisions/bodytype-classifier-training.md`.
```
parking-trainer inspect --data /data # what a run would train on
parking-trainer train --data /data --out /out # features mode (minutes)
parking-trainer train --mode finetune --epochs 12 ... # full fine-tune (about an hour on 4 cores)
parking-trainer evaluate --model /out/<version>/bodytype.onnx --data /data
parking-trainer publish /out/<version> --url https://git.infra.msai.al/api/packages/mca/generic/parking-bodytype
```
Exit codes: `0` model written · `2` not enough labels · `3` below the floor (report written,
no model) · `1` other.
A passing run writes `<out>/<version>/`:
| file | what |
| --- | --- |
| `bodytype.onnx` | the classifier; input `image` = RGB float32 0–255 `[N,3,S,S]`, output `logits` `[N,K]`; normalisation is inside the graph |
| `bodytype.json` | sidecar: version, class list (in vocabulary order), input size, crop margin, backbone, mode, label counts, validation metrics |
| `report.md` | the human report: accuracy, per-class recall/precision, confusion matrix, dropped classes, loss weights |
| `metrics.json` | the same numbers, machine-readable |
On the reviewer's host the image runs `serve` as the `trainer` service of the
`wash-collector` stack: a job API (`/health`, `/readiness`, `/versions`, `/jobs`) on the compose
network that the collector's **Training section** (`/review`) drives — readiness, Train /
Evaluate / Publish, reports and logs. Jobs run as subprocesses of the CLI, one at a time; state
and logs persist under `/out/jobs/`. The CLI stays for debugging:
`docker compose -f docker-compose.collector.yml exec trainer parking-trainer inspect`.
Local dev: `uv sync --extra train` (CPU torch, ~200 MB), `uv run pytest -q`. The test suite
runs without the extra (torch tests skip), matching CI.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@parking/trainer",
"version": "0.0.0",
"private": true,
"//": "Thin shim so this Python job is a node in the Turbo task graph (NOT a JS package — deps are managed by uv/pyproject.toml). It is a one-off job image, never a booth service: see wiki/decisions/bodytype-classifier-training.md.",
"scripts": {
"lint": "uv run ruff check .",
"format": "uv run ruff format .",
"typecheck": "uv run mypy trainer",
"test": "uv run pytest -q",
"build": "echo 'no build step (Python job; see Dockerfile)'"
}
}
+73
View File
@@ -0,0 +1,73 @@
[project]
name = "parking-trainer"
version = "0.0.0"
description = "Phase-B body-type classifier trainer: reviewer labels + crops off the wash collector's volume → an ONNX classifier the vision image bakes in."
requires-python = ">=3.10,<4.0"
# Core deps are LIGHT on purpose (same rule as the vision service): `inspect`, `evaluate`
# and the data/report code run with only these, so `uv sync` and the test suite work
# in CI without the PyTorch stack. Training itself needs the `train` extra.
# See wiki/decisions/bodytype-classifier-training.md.
dependencies = [
"numpy>=1.26",
# OpenCV does the decode + resize on BOTH sides (trainer and vision service): same
# library, same interpolation, same pixels — the preprocessing contract (preprocess.py).
"opencv-python-headless>=4.10",
"onnxruntime>=1.19",
]
[project.scripts]
parking-trainer = "trainer.cli:main"
[project.optional-dependencies]
# The training stack. CPU-only PyTorch (the reviewer's host has no usable GPU — the
# decision is recorded in the wiki page above): resolved from PyTorch's CPU wheel index,
# ~200 MB instead of the ~5 GB CUDA build. Install with: uv sync --extra train
# torch / torchvision are BSD-3; the ImageNet backbone weights ship under the same
# licence (the licence rule applies to weights as much as code).
train = [
"torch>=2.4",
"torchvision>=0.19",
"onnx>=1.16",
"onnxscript>=0.3", # the torch.export-based ONNX exporter (MIT)
]
[dependency-groups]
dev = [
"ruff>=0.8",
"pytest>=8.3",
"mypy>=1.13",
]
[tool.uv]
# Pick the CPU wheels for torch/torchvision from PyTorch's own index; everything else
# from PyPI. `explicit = true` keeps the index from shadowing PyPI for other packages.
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
torch = [{ index = "pytorch-cpu" }]
torchvision = [{ index = "pytorch-cpu" }]
[tool.ruff]
line-length = 110
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["trainer"]
+102
View File
@@ -0,0 +1,102 @@
"""A synthetic collector volume: the collector's `items` table (same DDL as apps/collector
src/db.ts) + JPEG crops. Classes are told apart by COLOUR so even a random-init backbone's
features separate them — the tests check the plumbing (split, floor, export, sidecar),
not accuracy on real cars."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
import numpy as np
import pytest
DDL = """
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY, booth TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'wash',
order_ref TEXT NOT NULL, at TEXT NOT NULL, operator_ref TEXT NOT NULL DEFAULT '',
operator_category_id TEXT NOT NULL DEFAULT '', operator_category_name TEXT NOT NULL DEFAULT '',
operator_classes TEXT NOT NULL DEFAULT '[]', service TEXT NOT NULL, vision_class TEXT NOT NULL,
vision_confidence REAL NOT NULL, vision_category_id TEXT, downgraded INTEGER NOT NULL DEFAULT 0,
image_width INTEGER NOT NULL, image_height INTEGER NOT NULL, plate_blurred INTEGER NOT NULL,
image_path TEXT NOT NULL, received_at TEXT NOT NULL, review_label TEXT, reviewed_at TEXT, reviewer TEXT
);
"""
COLOURS = {"sedan": (200, 40, 40), "suv": (40, 200, 40), "van": (40, 40, 200), "truck": (200, 200, 40)}
def write_jpeg(path: Path, colour: tuple[int, int, int], rng: np.random.Generator) -> None:
import cv2
path.parent.mkdir(parents=True, exist_ok=True)
h, w = int(rng.integers(120, 200)), int(rng.integers(160, 260))
img = np.empty((h, w, 3), np.uint8)
img[:] = colour[::-1] # BGR
noise = rng.integers(-20, 20, size=img.shape, dtype=np.int16)
img = np.clip(img.astype(np.int16) + noise, 0, 255).astype(np.uint8)
cv2.imwrite(str(path), img, [cv2.IMWRITE_JPEG_QUALITY, 85])
@pytest.fixture
def collector_dir(tmp_path: Path) -> Path:
"""40 labelled crops per class for sedan/suv/van, 5 for truck (below the minimum), a few
unusable, a few pending, one labelled row whose file is missing."""
rng = np.random.default_rng(1)
con = sqlite3.connect(tmp_path / "collector.sqlite")
con.executescript(DDL)
t0 = datetime(2026, 9, 1, tzinfo=timezone.utc)
n = 0
def add(label: str | None, reviewed: bool, kind: str = "wash", missing: bool = False) -> None:
nonlocal n
n += 1
item = f"item-{n:04d}"
rel = f"crops/booth-2/{item}.jpg"
colour = COLOURS.get(label or "sedan", (128, 128, 128))
if not missing:
write_jpeg(tmp_path / rel, colour, rng)
at = (t0 + timedelta(minutes=10 * n)).isoformat().replace("+00:00", "Z")
reviewed_at = (
(t0 + timedelta(days=1, minutes=n)).isoformat().replace("+00:00", "Z") if reviewed else None
)
con.execute(
"INSERT INTO items (id, booth, kind, order_ref, at, service, vision_class, vision_confidence, "
"image_width, image_height, plate_blurred, image_path, received_at, review_label, "
"reviewed_at, reviewer) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
item,
"booth-2",
kind,
"o",
at,
"wash",
"car" if label != "truck" else "truck",
0.9,
200,
150,
1,
rel,
at,
label if reviewed else None,
reviewed_at,
"reviewer" if reviewed else None,
),
)
# Interleaved in time so every class exists on both sides of the time split.
for i in range(40):
for label in ("sedan", "suv", "van"):
add(label, True)
if i % 8 == 0:
add("truck", True)
add("unusable", True)
add("unusable", True)
add("sedan", True, missing=True)
for _ in range(6):
add(None, False, kind="entry")
con.commit()
con.close()
return tmp_path
+98
View File
@@ -0,0 +1,98 @@
"""Data rules, torch-free: labels, the time split, thin classes, weights, the report."""
from __future__ import annotations
import json
from pathlib import Path
from trainer.cli import main
from trainer.data import (
class_weights,
load_labelled,
load_reviewed_since,
load_unlabelled,
make_split,
summarise,
)
from trainer.preprocess import CROP_MARGIN, Sidecar, load_input
from trainer.report import compute_metrics, render_report
def test_loads_only_reviewed_usable_rows_with_a_crop_on_disk(collector_dir: Path) -> None:
samples, missing = load_labelled(collector_dir)
assert missing == 1 # the labelled row whose file is gone
assert len(samples) == 125 # 3×40 + 5 trucks; unusable and pending excluded
assert all(s.path.is_file() for s in samples)
assert {s.label for s in samples} == {"sedan", "suv", "van", "truck"}
assert summarise(samples)["byClass"] == {"sedan": 40, "suv": 40, "van": 40, "truck": 5}
assert len(load_unlabelled(collector_dir)) == 6
assert len(load_reviewed_since(collector_dir, "2026-09-02T00:00:00Z")) == 125
assert load_reviewed_since(collector_dir, "2030-01-01T00:00:00Z") == []
def test_split_is_by_time_and_drops_thin_classes(collector_dir: Path) -> None:
samples, _ = load_labelled(collector_dir)
split = make_split(samples, val_fraction=0.2, min_per_class=20)
assert split.classes == ("sedan", "suv", "van") # canonical order, truck dropped
assert split.dropped == {"truck": 5}
assert len(split.train) + len(split.val) == 120
assert len(split.val) == 24
assert max(s.at for s in split.train) < min(s.at for s in split.val) # newest = validation
assert all(v > 0 for v in split.counts("val").values())
def test_class_weights_lean_against_imbalance_but_gently(collector_dir: Path) -> None:
samples, _ = load_labelled(collector_dir)
vans = [s for s in samples if s.label == "van"]
keep = set(vans[::10]) # 4 of 40 vans survive
split = make_split([s for s in samples if s.label != "van" or s in keep], 0.2, 3)
w = dict(zip(split.classes, class_weights(split), strict=True))
assert w["van"] > w["sedan"] > 0 # the rare class weighs more
assert w["van"] / w["sedan"] < 4 # but not the full inverse ratio (damped)
assert abs(sum(w.values()) / len(w) - 1.0) < 1e-9
def test_metrics_and_report() -> None:
classes = ("sedan", "suv")
m = compute_metrics(classes, [0, 0, 1, 1], [0, 1, 1, 1], camera=["car"] * 4)
assert m.accuracy == 0.75
assert m.per_class["sedan"].recall == 0.5 and m.per_class["suv"].precision == 2 / 3
assert m.confusion == [[1, 1], [0, 2]]
assert m.camera_agreement == 0.0
text = render_report(
version="v1",
trained_at="t",
mode="features",
backbone="resnet18",
epochs=3,
classes=classes,
train_counts={"sedan": 10, "suv": 8},
val_counts={"sedan": 2, "suv": 2},
dropped={"truck": 2},
missing_files=1,
weights=[0.9, 1.1],
metrics=m,
min_accuracy=0.85,
written=False,
)
assert "MODEL NOT WRITTEN" in text and "| **sedan** | 1 | 1 |" in text and "truck (2)" in text
def test_preprocess_contract(tmp_path: Path, collector_dir: Path) -> None:
samples, _ = load_labelled(collector_dir)
x = load_input(samples[0].path, 32)
assert x.shape == (3, 32, 32) and x.dtype.name == "float32" and 0 <= x.min() and x.max() <= 255
assert x[0].mean() > x[2].mean() # a sedan crop is red: RGB order, not BGR
assert load_input(tmp_path / "nope.jpg", 32) is None
side = Sidecar(version="v1", classes=["sedan", "suv"])
side.write(tmp_path / "s.json")
back = Sidecar.read(tmp_path / "s.json")
assert back == side and back.crop_margin == CROP_MARGIN == 0.08 and back.normalization == "in-graph"
def test_inspect_prints_the_run_shape(collector_dir: Path, capsys) -> None: # type: ignore[no-untyped-def]
assert main(["inspect", "--data", str(collector_dir)]) == 0
out = json.loads(capsys.readouterr().out)
assert out["ready"] is True and out["run"]["classes"] == ["sedan", "suv", "van"]
assert out["run"]["dropped"] == {"truck": 5} and out["missingCrops"] == 1
assert main(["inspect", "--data", str(collector_dir), "--min-per-class", "100"]) == 2
+111
View File
@@ -0,0 +1,111 @@
"""The job API: readiness, one job at a time, subprocess jobs with persisted logs, versions."""
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
import pytest
from trainer.server import Handler, Jobs, readiness, versions, wait_idle
@pytest.fixture
def api(collector_dir: Path, tmp_path: Path): # type: ignore[no-untyped-def]
out = tmp_path / "out"
Handler.jobs = Jobs(collector_dir, out, "https://example.invalid/pkg", "tok")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
base = f"http://127.0.0.1:{httpd.server_address[1]}"
def call(method: str, path: str, body: dict | None = None): # type: ignore[no-untyped-def]
req = urllib.request.Request(base + path, method=method)
data = None
if body is not None:
data = json.dumps(body).encode()
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, data=data, timeout=10) as r:
raw = r.read()
return r.status, (
json.loads(raw) if r.headers.get_content_type() == "application/json" else raw.decode()
)
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b"{}")
yield call, out
httpd.shutdown()
httpd.server_close()
def test_readiness_and_empty_versions(api) -> None: # type: ignore[no-untyped-def]
call, _ = api
code, r = call("GET", "/readiness")
assert code == 200 and r["ready"] is True and r["run"]["classes"] == ["sedan", "suv", "van"]
assert r["defaults"]["minAccuracy"] == 0.85 and "finetune" in r["modes"]
assert call("GET", "/versions") == (200, {"versions": []})
assert call("GET", "/health")[1]["busy"] is False
assert readiness(Path("/nonexistent"))["ready"] is False
def test_evaluate_job_runs_as_a_subprocess_and_is_recorded(api) -> None: # type: ignore[no-untyped-def]
call, out = api
code, job = call("POST", "/jobs", {"kind": "evaluate", "version": "nope"})
assert code == 202 and job["status"] == "running" and job["kind"] == "evaluate"
wait_idle(Handler.jobs)
code, j = call("GET", f"/jobs/{job['id']}")
assert code == 200 and j["status"] == "failed" and j["exitCode"] == 1
assert "evaluate --data" in j["log"] and "nope" in j["log"]
assert (out / "jobs" / f"{job['id']}.json").is_file() and (out / "jobs" / f"{job['id']}.log").is_file()
code, lst = call("GET", "/jobs")
assert code == 200 and lst["jobs"][0]["id"] == job["id"] and lst["current"] is None
def test_bad_requests(api) -> None: # type: ignore[no-untyped-def]
call, _ = api
assert call("POST", "/jobs", {"kind": "nuke"})[0] == 400
assert call("POST", "/jobs", {"kind": "train", "mode": "magic"})[0] == 400
assert call("POST", "/jobs", {"kind": "evaluate", "version": "../etc"})[0] == 400
assert call("POST", "/jobs", {"kind": "publish", "version": "v1", "url": "ftp://x"})[0] == 400
assert call("GET", "/versions/../x/report")[0] == 400
assert call("GET", "/versions/v9/report")[0] == 404
assert call("GET", "/jobs/nope")[0] == 404
assert call("GET", "/nothing")[0] == 404
def test_train_job_then_versions_and_report(api) -> None: # type: ignore[no-untyped-def]
pytest.importorskip("torch")
call, out = api
body = {"kind": "train", "mode": "features", "minAccuracy": 0.0, "epochs": 100, "version": "vapi"}
# The test-only flags are not offered by the API; inject them via the CLI args the runner builds.
orig = Jobs._argv
def patched(self, kind, a): # type: ignore[no-untyped-def]
argv = orig(self, kind, a)
return argv + ["--no-pretrained", "--input-size", "64", "--no-cache"] if kind == "train" else argv
Jobs._argv = patched # type: ignore[method-assign]
try:
code, job = call("POST", "/jobs", body)
assert code == 202
assert call("POST", "/jobs", {"kind": "evaluate", "version": "vapi"})[0] == 409 # one at a time
wait_idle(Handler.jobs, 120)
finally:
Jobs._argv = orig # type: ignore[method-assign]
code, j = call("GET", f"/jobs/{job['id']}")
assert j["status"] == "done" and "MODEL WRITTEN" in j["log"]
code, v = call("GET", "/versions")
assert code == 200 and v["versions"][0]["version"] == "vapi" and v["versions"][0]["written"] is True
assert v["versions"][0]["classes"] == ["sedan", "suv", "van"] and v["versions"][0]["accuracy"] >= 0.9
code, report = call("GET", "/versions/vapi/report")
assert code == 200 and report.startswith("# Body-type classifier vapi")
assert versions(out)[0]["floor"] == 0.0
# evaluate on the written model now succeeds
code, job2 = call("POST", "/jobs", {"kind": "evaluate", "version": "vapi"})
wait_idle(Handler.jobs)
assert call("GET", f"/jobs/{job2['id']}")[1]["status"] == "done"
+155
View File
@@ -0,0 +1,155 @@
"""The training job end to end on the synthetic volume — needs the `train` extra (torch);
skipped where it is not installed (CI syncs without it, like the vision service)."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
torch = pytest.importorskip("torch")
from trainer.cli import main # noqa: E402
from trainer.infer import OnnxClassifier # noqa: E402
from trainer.preprocess import Sidecar # noqa: E402
COMMON = ["--no-pretrained", "--input-size", "64", "--no-cache", "--seed", "3"]
def test_features_run_writes_model_sidecar_report_and_evaluates(
collector_dir: Path, tmp_path: Path, capsys
) -> None: # type: ignore[no-untyped-def]
out = tmp_path / "out"
rc = main(
[
"train",
"--data",
str(collector_dir),
"--out",
str(out),
"--version",
"vtest",
"--mode",
"features",
"--epochs",
"150",
"--min-accuracy",
"0.0",
*COMMON,
]
)
assert rc == 0
d = out / "vtest"
assert {p.name for p in d.iterdir()} == {"bodytype.onnx", "bodytype.json", "report.md", "metrics.json"}
side = Sidecar.read(d / "bodytype.json")
assert side.classes == ["sedan", "suv", "van"] and side.input_size == 64 and side.mode == "features"
assert side.labels == {"train": 96, "val": 24} and side.metrics["floor"] == 0.0
metrics = json.loads((d / "metrics.json").read_text())
assert metrics["n"] == 24 and metrics["onnx_agreement"] == 1.0
# Colour-coded classes: even a random backbone's pooled features separate them.
assert metrics["accuracy"] >= 0.9
report = (d / "report.md").read_text()
assert (
"MODEL WRITTEN" in report
and "truck (5)" in report
and "crop is missing on disk (skipped): 1" in report
)
# The exported graph takes raw 0–255 RGB and answers by itself.
clf = OnnxClassifier(d / "bodytype.onnx")
probs, kept = clf.predict_files(
[s for s in sorted((collector_dir / "crops" / "booth-2").glob("*.jpg"))][:6]
)
assert probs.shape == (6, 3) and kept == [0, 1, 2, 3, 4, 5]
assert np.allclose(probs.sum(axis=1), 1.0, atol=1e-4)
# evaluate: labels reviewed after training (none — the fixture's reviews predate it) and the
# unlabelled pile (6 entry samples).
capsys.readouterr()
assert main(["evaluate", "--data", str(collector_dir), "--model", str(d / "bodytype.onnx")]) == 0
res = json.loads(capsys.readouterr().out)
assert res["model"] == "vtest" and res["reviewedSince"] is None
assert res["unlabelled"]["n"] == 6 and sum(res["unlabelled"]["predicted"].values()) == 6
assert (
main(
[
"evaluate",
"--data",
str(collector_dir),
"--model",
str(d / "bodytype.onnx"),
"--since",
"2026-09-01T00:00:00Z",
]
)
== 0
)
res2 = json.loads(capsys.readouterr().out)
assert res2["reviewedSince"]["n"] == 125 - 5 # trucks are not a class the model knows
def test_below_the_floor_writes_the_report_but_no_model(collector_dir: Path, tmp_path: Path) -> None:
out = tmp_path / "out"
rc = main(
[
"train",
"--data",
str(collector_dir),
"--out",
str(out),
"--version",
"vlow",
"--mode",
"features",
"--epochs",
"5",
"--min-accuracy",
"1.01",
*COMMON,
]
)
assert rc == 3
d = out / "vlow"
assert {p.name for p in d.iterdir()} == {"report.md", "metrics.json"}
assert "MODEL NOT WRITTEN" in (d / "report.md").read_text()
def test_not_enough_labels_is_exit_2(collector_dir: Path, tmp_path: Path) -> None:
out = tmp_path / "out"
rc = main(["train", "--data", str(collector_dir), "--out", str(out), "--min-per-class", "100", *COMMON])
assert rc == 2
assert not out.exists()
def test_finetune_runs_and_uses_the_feature_cache(collector_dir: Path, tmp_path: Path) -> None:
out = tmp_path / "out"
args = [
"train",
"--data",
str(collector_dir),
"--out",
str(out),
"--mode",
"finetune",
"--backbone",
"mobilenet_v3_small",
"--epochs",
"1",
"--batch",
"16",
"--min-accuracy",
"0.0",
"--no-pretrained",
"--input-size",
"64",
"--seed",
"3",
]
assert main([*args, "--version", "vft"]) == 0
cache = out / "cache" / "features-mobilenet_v3_small-64.npz"
assert cache.exists()
z = np.load(cache)
assert len(z["ids"]) == 96 and z["feats"].shape == (96, 576)
assert Sidecar.read(out / "vft" / "bodytype.json").mode == "finetune"
+7
View File
@@ -0,0 +1,7 @@
"""parking-trainer — the phase-B body-type classifier job.
Reads the wash collector's SQLite + crops straight off its volume, splits by TIME, trains a
small classifier on a pretrained backbone, and writes the ONNX model + sidecar + report —
or refuses to write the model when validation is below the owner's floor.
See wiki/decisions/bodytype-classifier-training.md.
"""
+414
View File
@@ -0,0 +1,414 @@
"""parking-trainer — inspect / train / evaluate / publish.
parking-trainer inspect --data /data
parking-trainer train --data /data --out /out [--mode features|finetune] [--min-accuracy 0.85]
parking-trainer evaluate --model /out/<version>/bodytype.onnx --data /data
parking-trainer publish /out/<version> --url https://<gitea>/api/packages/<owner>/generic/parking-bodytype
Exit codes: 0 ok · 2 not enough labels · 3 trained but below the floor (report written, model
NOT written) · 1 anything else.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from .data import (
VEHICLE_CLASSES,
class_weights,
load_labelled,
load_reviewed_since,
load_unlabelled,
make_split,
suggested_epochs,
summarise,
)
from .preprocess import CROP_MARGIN, Sidecar
from .report import compute_metrics, render_report
MODEL_FILE = "bodytype.onnx"
SIDECAR_FILE = "bodytype.json"
REPORT_FILE = "report.md"
METRICS_FILE = "metrics.json"
def _log(msg: str) -> None:
print(f"[trainer] {msg}", file=sys.stderr, flush=True)
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
# ----------------------------------------------------------------------------------
# inspect
# ----------------------------------------------------------------------------------
def cmd_inspect(a: argparse.Namespace) -> int:
samples, missing = load_labelled(a.data)
split = make_split(samples, a.val_fraction, a.min_per_class)
out = {
"labelled": summarise(samples),
"missingCrops": missing,
"run": {
"classes": list(split.classes),
"train": split.counts("train"),
"val": split.counts("val"),
"dropped": split.dropped,
"minPerClass": a.min_per_class,
"valFraction": a.val_fraction,
},
"ready": len(split.classes) >= 2,
}
print(json.dumps(out, indent=2))
return 0 if out["ready"] else 2
# ----------------------------------------------------------------------------------
# train
# ----------------------------------------------------------------------------------
def cmd_train(a: argparse.Namespace) -> int:
try:
from . import model as M
except ImportError as exc: # torch missing
_log(f"the training stack is not installed ({exc}); install with: uv sync --extra train")
return 1
import numpy as np
samples, missing = load_labelled(a.data)
split = make_split(samples, a.val_fraction, a.min_per_class)
if len(split.classes) < 2:
_log(
f"not enough labels: {len(samples)} usable, classes with >= {a.min_per_class}: "
f"{list(split.classes)} (dropped {split.dropped}); nothing to train"
)
return 2
version = a.version or datetime.now(timezone.utc).strftime("v%Y%m%d-%H%M")
out_dir = a.out / version
epochs = a.epochs or suggested_epochs(len(split.train), a.mode)
weights = class_weights(split)
idx = split.class_index
_log(
f"{version}: {a.mode} on {a.backbone}, classes {list(split.classes)}, "
f"{len(split.train)} train / {len(split.val)} val, {epochs} epochs"
)
t0 = time.perf_counter()
x_train, train = M.load_images(split.train, a.input_size)
x_val, val = M.load_images(split.val, a.input_size)
y_train = [idx[s.label] for s in train]
y_val = [idx[s.label] for s in val]
_log(f"decoded {len(train)} + {len(val)} crops in {time.perf_counter() - t0:.1f}s")
if len(val) == 0 or len(set(y_train)) < 2:
_log("not enough decodable crops on both sides of the split")
return 2
backbone = M.build_backbone(a.backbone, pretrained=not a.no_pretrained)
cache = (
None if a.no_cache else M.FeatureCache(a.out / "cache" / f"features-{a.backbone}-{a.input_size}.npz")
)
t0 = time.perf_counter()
f_train = M.features_for(backbone, train, x_train, cache)
_log(
f"features for {len(train)} train crops in {time.perf_counter() - t0:.1f}s "
f"(cache: {cache.path if cache else 'off'})"
)
head_epochs = epochs if a.mode == "features" else max(30, epochs * 5)
head = M.train_head(f_train, y_train, len(split.classes), weights, head_epochs, seed=a.seed)
net = M.Classifier.make(backbone, head)
if a.mode == "finetune":
t0 = time.perf_counter()
net = M.finetune(
net, x_train, y_train, weights, epochs, batch=a.batch, lr=a.lr, seed=a.seed, log=_log
)
_log(f"fine-tuned in {(time.perf_counter() - t0) / 60:.1f} min")
logits = M.predict_logits(net, x_val)
y_pred = logits.argmax(axis=1).tolist()
metrics = compute_metrics(split.classes, y_val, y_pred, camera=[s.vision_class for s in val])
# Export and check the graph gives the same answers as the torch model.
out_dir.mkdir(parents=True, exist_ok=True)
tmp_model = out_dir / (MODEL_FILE + ".tmp")
M.export_onnx(net, a.input_size, tmp_model)
from .infer import OnnxClassifier
sidecar = Sidecar(
version=version,
classes=list(split.classes),
input_size=a.input_size,
backbone=a.backbone,
mode=a.mode,
trained_at=_now(),
labels={"train": len(train), "val": len(val)},
)
tmp_side = out_dir / (SIDECAR_FILE + ".tmp")
sidecar.write(tmp_side)
onnx_pred = OnnxClassifier(tmp_model, tmp_side).predict_inputs(x_val.astype(np.float32)).argmax(axis=1)
metrics.onnx_agreement = float((onnx_pred == np.array(y_pred)).mean()) if len(y_pred) else None
sidecar.metrics = {
"accuracy": metrics.accuracy,
"macroRecall": metrics.macro_recall,
"perClass": {c: m.__dict__ for c, m in metrics.per_class.items()},
"floor": a.min_accuracy,
}
written = metrics.accuracy >= a.min_accuracy and (metrics.onnx_agreement or 0.0) >= 0.99
notes = []
if metrics.onnx_agreement is not None and metrics.onnx_agreement < 0.99:
notes.append(
f"ONNX export disagrees with the torch model ({metrics.onnx_agreement:.3f}); model withheld"
)
report = render_report(
version=version,
trained_at=sidecar.trained_at,
mode=a.mode,
backbone=a.backbone,
epochs=epochs,
classes=split.classes,
train_counts=split.counts("train"),
val_counts=split.counts("val"),
dropped=split.dropped,
missing_files=missing,
weights=weights,
metrics=metrics,
min_accuracy=a.min_accuracy,
written=written,
notes=notes,
)
(out_dir / REPORT_FILE).write_text(report)
(out_dir / METRICS_FILE).write_text(json.dumps(metrics.to_dict(), indent=2) + "\n")
if written:
sidecar.write(out_dir / SIDECAR_FILE)
tmp_model.replace(out_dir / MODEL_FILE)
tmp_side.unlink(missing_ok=True)
_log(
f"MODEL WRITTEN: {out_dir / MODEL_FILE} "
f"(accuracy {metrics.accuracy:.3f} >= floor {a.min_accuracy})"
)
else:
tmp_model.unlink(missing_ok=True)
tmp_side.unlink(missing_ok=True)
_log(
f"MODEL NOT WRITTEN: accuracy {metrics.accuracy:.3f} < floor {a.min_accuracy}; "
f"see {out_dir / REPORT_FILE}"
)
print(report)
return 0 if written else 3
# ----------------------------------------------------------------------------------
# evaluate — an existing model against labels that arrived AFTER it was trained, and its
# view of the unlabelled pile (the ongoing accuracy check without labelling everything)
# ----------------------------------------------------------------------------------
def cmd_evaluate(a: argparse.Namespace) -> int:
from .infer import OnnxClassifier
clf = OnnxClassifier(a.model)
since = a.since or clf.sidecar.trained_at
classes = clf.classes
result: dict[str, object] = {"model": clf.sidecar.version, "classes": classes, "since": since}
reviewed = [s for s in load_reviewed_since(a.data, since) if s.label in classes]
if reviewed:
probs, kept = clf.predict_files([s.path for s in reviewed])
rows = [reviewed[i] for i in kept]
y_true = [classes.index(s.label) for s in rows]
y_pred = probs.argmax(axis=1).tolist()
m = compute_metrics(classes, y_true, y_pred, camera=[s.vision_class for s in rows])
result["reviewedSince"] = m.to_dict()
else:
result["reviewedSince"] = None
pending = load_unlabelled(a.data, a.limit)
if pending:
probs, kept = clf.predict_files([s.path for s in pending])
rows = [pending[i] for i in kept]
pred = probs.argmax(axis=1)
conf = probs.max(axis=1)
hist = {c: int((pred == i).sum()) for i, c in enumerate(classes)}
result["unlabelled"] = {
"n": len(rows),
"predicted": hist,
"meanConfidence": float(conf.mean()) if len(rows) else None,
"belowHalf": int((conf < 0.5).sum()),
"agreesWithDetector": float(
sum(1 for p, s in zip(pred, rows, strict=True) if classes[int(p)] == s.vision_class)
/ len(rows)
)
if rows
else None,
}
else:
result["unlabelled"] = None
print(json.dumps(result, indent=2))
return 0
# ----------------------------------------------------------------------------------
# publish — the versioned files to a Gitea generic package (weights are not code, they
# do not live in git; the vision image fetches them by URL at build)
# ----------------------------------------------------------------------------------
def cmd_publish(a: argparse.Namespace) -> int:
d: Path = a.dir
files = [d / MODEL_FILE, d / SIDECAR_FILE, d / REPORT_FILE, d / METRICS_FILE]
for f in files[:2]:
if not f.exists():
_log(f"{f} missing — nothing to publish (a run below the floor writes no model)")
return 1
version = Sidecar.read(d / SIDECAR_FILE).version
if not a.url:
_log("no publish url: pass --url or set TRAINER_PUBLISH_URL")
return 1
token = a.token or os.environ.get("TRAINER_PUBLISH_TOKEN", "")
if not token:
_log("no token: pass --token or set TRAINER_PUBLISH_TOKEN")
return 1
base = a.url.rstrip("/") + "/" + version
for f in files:
if not f.exists():
continue
req = urllib.request.Request(f"{base}/{f.name}", data=f.read_bytes(), method="PUT")
req.add_header("Authorization", f"token {token}")
req.add_header("Content-Type", "application/octet-stream")
with urllib.request.urlopen(req, timeout=120) as r:
_log(f"PUT {base}/{f.name} → {r.status}")
_log(f"published {version}; pin it in apps/vision/models/bodytype.version and rebuild the vision image")
return 0
def cmd_serve(a: argparse.Namespace) -> int:
from .server import serve
serve(
a.data,
a.out,
a.host,
a.port,
os.environ.get("TRAINER_PUBLISH_URL", ""),
os.environ.get("TRAINER_PUBLISH_TOKEN", ""),
)
return 0
# ----------------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="parking-trainer", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
sub = p.add_subparsers(dest="cmd", required=True)
def data_args(sp: argparse.ArgumentParser) -> None:
sp.add_argument(
"--data",
type=Path,
default=Path(os.environ.get("TRAINER_DATA_DIR", "/data")),
help="collector volume (collector.sqlite + crops/)",
)
def split_args(sp: argparse.ArgumentParser) -> None:
sp.add_argument(
"--min-per-class",
type=int,
default=20,
help="classes with fewer reviewed crops are dropped from the run",
)
sp.add_argument(
"--val-fraction", type=float, default=0.2, help="newest fraction held out for validation"
)
i = sub.add_parser("inspect", help="what a run would train on")
data_args(i)
split_args(i)
i.set_defaults(fn=cmd_inspect)
t = sub.add_parser("train", help="train, evaluate, export (or refuse)")
data_args(t)
split_args(t)
t.add_argument(
"--out",
type=Path,
default=Path(os.environ.get("TRAINER_OUT_DIR", "/out")),
help="output root; a <version>/ folder is created under it",
)
t.add_argument("--mode", choices=["features", "finetune"], default="features")
t.add_argument(
"--backbone", choices=["resnet18", "mobilenet_v3_small", "efficientnet_b0"], default="resnet18"
)
t.add_argument("--epochs", type=int, default=0, help="0 = pick from the data size")
t.add_argument("--batch", type=int, default=32)
t.add_argument("--lr", type=float, default=1e-4, help="fine-tune learning rate")
t.add_argument("--input-size", type=int, default=224)
t.add_argument(
"--min-accuracy", type=float, default=0.85, help="validation floor below which NO model is written"
)
t.add_argument("--version", default="", help="model version (default v<date>-<time>)")
t.add_argument("--seed", type=int, default=7)
t.add_argument(
"--no-pretrained", action="store_true", help="random init (tests only — never for a real run)"
)
t.add_argument("--no-cache", action="store_true", help="do not read/write the feature cache")
t.set_defaults(fn=cmd_train)
e = sub.add_parser(
"evaluate", help="an existing model vs labels reviewed after it was trained + the unlabelled pile"
)
data_args(e)
e.add_argument(
"--model", type=Path, required=True, help="path to bodytype.onnx (sidecar .json beside it)"
)
e.add_argument("--since", default="", help="ISO time; default = the model's trained_at")
e.add_argument(
"--limit", type=int, default=2000, help="how many unlabelled crops to score (newest first)"
)
e.set_defaults(fn=cmd_evaluate)
u = sub.add_parser("publish", help="PUT a version folder to a Gitea generic package")
u.add_argument("dir", type=Path, help="the <version>/ folder a passing run wrote")
u.add_argument(
"--url",
default=os.environ.get("TRAINER_PUBLISH_URL", ""),
help="https://<gitea>/api/packages/<owner>/generic/parking-bodytype (or TRAINER_PUBLISH_URL)",
)
u.add_argument("--token", default="", help="Gitea token with package:write (or TRAINER_PUBLISH_TOKEN)")
u.set_defaults(fn=cmd_publish)
s = sub.add_parser("serve", help="the job API the collector's Training section talks to")
data_args(s)
s.add_argument("--out", type=Path, default=Path(os.environ.get("TRAINER_OUT_DIR", "/out")))
s.add_argument("--host", default=os.environ.get("TRAINER_HOST", "0.0.0.0"))
s.add_argument("--port", type=int, default=int(os.environ.get("TRAINER_PORT", "8091")))
s.set_defaults(fn=cmd_serve)
return p
def main(argv: list[str] | None = None) -> int:
a = build_parser().parse_args(argv)
try:
return int(a.fn(a))
except FileNotFoundError as exc:
_log(str(exc))
return 1
__all__ = ["main", "build_parser", "VEHICLE_CLASSES", "CROP_MARGIN"]
if __name__ == "__main__":
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
"""The training set: reviewed, usable rows off the collector's SQLite, and how they are
split and weighed. Pure Python + sqlite3 — no torch, so `inspect` and the tests run light.
Rules (wiki/decisions/bodytype-classifier-training.md):
- Only rows a REVIEWER labelled count; the operator's pick and the camera's class are
never labels. `unusable` rows are dropped.
- Split by TIME (`at` = when the vehicle was seen): validation = the newest slice, so
the number reflects tomorrow's traffic rather than a random shuffle of the same days.
- Classes with too few labels are dropped from the run (and reported), never trained
on a handful of examples that would make the softmax confidently wrong.
- Class imbalance is weighed in the loss (inverse frequency, damped) and reported.
"""
from __future__ import annotations
import math
import sqlite3
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
# The shared vocabulary (packages/shared VEHICLE_CLASSES) — the only labels a reviewer can
# give, and the only classes a model may emit. Order is the canonical one; the model's own
# class list (sidecar) is the subset it trained on, in this order.
VEHICLE_CLASSES: tuple[str, ...] = (
"car",
"sedan",
"hatchback",
"suv",
"minivan",
"pickup",
"van",
"truck",
"bus",
"motorcycle",
)
DB_FILE = "collector.sqlite"
@dataclass(frozen=True)
class Sample:
item: str
booth: str
kind: str # wash | entry
at: str # ISO-8601, when the vehicle was seen (the split key)
label: str # the reviewer's class
vision_class: str # what the detector said (for the report, never a label)
path: Path # the crop on disk
@dataclass(frozen=True)
class Split:
classes: tuple[str, ...]
train: list[Sample]
val: list[Sample]
dropped: dict[str, int] # class → count, below the per-class minimum
missing_files: int # labelled rows whose crop is not on disk
@property
def class_index(self) -> dict[str, int]:
return {c: i for i, c in enumerate(self.classes)}
def counts(self, part: str) -> dict[str, int]:
rows = self.train if part == "train" else self.val
c = Counter(s.label for s in rows)
return {k: c.get(k, 0) for k in self.classes}
_SELECT = "SELECT id, booth, kind, at, review_label, vision_class, image_path FROM items "
def _rows(
data_dir: Path, where: str, params: tuple[object, ...], db_file: Path | None = None
) -> list[Sample]:
db = db_file or data_dir / DB_FILE
if not db.exists():
raise FileNotFoundError(f"collector database not found: {db}")
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
try:
rows = con.execute(_SELECT + where, params).fetchall()
finally:
con.close()
out: list[Sample] = []
for item, booth, kind, at, label, vision_class, image_path in rows:
out.append(Sample(item, booth, kind, at, label or "", vision_class, data_dir / image_path))
return out
def load_labelled(data_dir: Path, db_file: Path | None = None) -> tuple[list[Sample], int]:
"""Every reviewed, usable row with its crop path resolved. Returns (samples, missing)
where `missing` counts rows whose crop file is gone (pruned/moved) — skipped."""
rows = _rows(
data_dir,
"WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY at, id",
(),
db_file,
)
out: list[Sample] = []
missing = 0
for s in rows:
if s.label not in VEHICLE_CLASSES:
continue # a label outside the vocabulary can only be a future/foreign row
if not s.path.is_file():
missing += 1
continue
out.append(s)
return out, missing
def load_reviewed_since(data_dir: Path, since: str, db_file: Path | None = None) -> list[Sample]:
"""Usable labels whose REVIEW happened after `since` (ISO) — a clean held-out check for a
model trained before then. Rows whose crop is gone are skipped."""
rows = _rows(
data_dir,
"WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' AND reviewed_at > ? "
"ORDER BY reviewed_at, id",
(since,),
db_file,
)
return [s for s in rows if s.label in VEHICLE_CLASSES and s.path.is_file()]
def load_unlabelled(data_dir: Path, limit: int = 2000, db_file: Path | None = None) -> list[Sample]:
"""The pending pile, newest first — what the model would say about traffic nobody has
labelled (its class histogram and detector agreement are the cheap drift check)."""
rows = _rows(data_dir, "WHERE reviewed_at IS NULL ORDER BY received_at DESC LIMIT ?", (limit,), db_file)
return [s for s in rows if s.path.is_file()]
def make_split(samples: list[Sample], val_fraction: float = 0.2, min_per_class: int = 20) -> Split:
"""Drop thin classes, then cut by time: the newest `val_fraction` is validation."""
if not 0.0 < val_fraction < 1.0:
raise ValueError("val_fraction must be in (0, 1)")
counts = Counter(s.label for s in samples)
kept = tuple(c for c in VEHICLE_CLASSES if counts.get(c, 0) >= min_per_class)
dropped = {c: n for c, n in counts.items() if c not in kept}
rows = sorted((s for s in samples if s.label in kept), key=lambda s: (s.at, s.item))
n_val = int(round(len(rows) * val_fraction))
if rows and n_val == 0:
n_val = 1
cut = len(rows) - n_val
return Split(classes=kept, train=rows[:cut], val=rows[cut:], dropped=dropped, missing_files=0)
def class_weights(split: Split, damping: float = 0.5) -> list[float]:
"""Inverse-frequency weights for the loss, damped by `damping` (0.5 = square root, so a
1:9 imbalance becomes 1:3 rather than 1:9 — full inverse weights over-correct on small
sets). Normalised to mean 1 so the learning rate keeps its meaning."""
counts = split.counts("train")
total = sum(counts.values())
k = len(split.classes)
raw = [(total / (k * max(1, counts[c]))) ** damping for c in split.classes]
mean = sum(raw) / max(1, len(raw))
return [w / mean for w in raw]
def summarise(samples: list[Sample]) -> dict[str, object]:
"""What `inspect` prints: per-class counts, per-booth counts, time range."""
by_class = Counter(s.label for s in samples)
by_booth = Counter(s.booth for s in samples)
by_kind = Counter(s.kind for s in samples)
ats = sorted(s.at for s in samples)
return {
"total": len(samples),
"byClass": {c: by_class.get(c, 0) for c in VEHICLE_CLASSES if by_class.get(c, 0)},
"byBooth": dict(sorted(by_booth.items())),
"byKind": dict(sorted(by_kind.items())),
"from": ats[0] if ats else None,
"to": ats[-1] if ats else None,
}
def suggested_epochs(n_train: int, mode: str) -> int:
"""A sane default when the owner gives none: enough passes for a small set, fewer as
it grows. Feature-extraction heads converge fast; fine-tunes need more but cost more."""
if mode == "features":
return int(min(200, max(30, 4000 / max(1, n_train) * 10)))
return int(min(30, max(8, math.ceil(3000 / max(1, n_train)) * 4)))
+51
View File
@@ -0,0 +1,51 @@
"""Run an exported classifier (ONNX + sidecar) — torch-free. Used by `evaluate` and by
the tests; the vision service carries its own, equivalent, reader (vehicle.py)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .preprocess import Sidecar, load_input, softmax
class OnnxClassifier:
def __init__(self, model_path: Path, sidecar_path: Path | None = None) -> None:
import onnxruntime as ort
self.model_path = Path(model_path)
self.sidecar = Sidecar.read(sidecar_path or self.model_path.with_suffix(".json"))
opts = ort.SessionOptions()
opts.intra_op_num_threads = 2
self._session = ort.InferenceSession(
str(self.model_path), sess_options=opts, providers=["CPUExecutionProvider"]
)
self._input = self._session.get_inputs()[0].name
@property
def classes(self) -> list[str]:
return list(self.sidecar.classes)
def predict_inputs(self, x: Any, batch: int = 64) -> Any:
"""[N,3,S,S] float32 → probabilities [N,K]."""
import numpy as np
outs = []
for i in range(0, len(x), batch):
logits = self._session.run(None, {self._input: x[i : i + batch]})[0]
outs.append(softmax(logits))
return np.concatenate(outs, axis=0) if outs else np.zeros((0, len(self.classes)), np.float32)
def predict_files(self, paths: list[Path], batch: int = 64) -> tuple[Any, list[int]]:
"""Decode + classify crop files. Returns (probs, indices of paths that decoded)."""
import numpy as np
xs, kept = [], []
for i, p in enumerate(paths):
x = load_input(p, self.sidecar.input_size)
if x is not None:
xs.append(x)
kept.append(i)
if not xs:
return np.zeros((0, len(self.classes)), np.float32), []
return self.predict_inputs(np.stack(xs), batch), kept
+305
View File
@@ -0,0 +1,305 @@
"""The network and the two ways of training it. Imports torch — only `train` reaches here;
everything else in the package stays torch-free (the `train` extra is heavy).
Backbone: a small ImageNet-pretrained torchvision model (BSD-3, weights included), used as
a feature extractor; head: one linear layer over its pooled features.
- "features" mode: the backbone is FROZEN. Every crop goes through it once, the vectors
are cached on disk (keyed by item id), and only the head is trained — minutes for a few
thousand crops, seconds to retrain when new labels arrive. Expected to carry most of
the accuracy on frontal gate views.
- "finetune" mode: warm-starts the head the same way, then unfreezes everything and trains
end to end with light augmentation. Roughly an hour on four Xeon cores for a few
thousand crops with a mobile-sized backbone; the step when the cheap mode plateaus.
The exported ONNX graph takes raw RGB 0–255 float pixels and normalises INSIDE
(see preprocess.py), so the vision service cannot get the constants wrong.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .data import Sample
from .preprocess import IMAGENET_MEAN, IMAGENET_STD, load_input
BACKBONES: dict[str, int] = {"resnet18": 512, "mobilenet_v3_small": 576, "efficientnet_b0": 1280}
def _torch() -> Any:
import torch
torch.set_num_threads(max(1, os.cpu_count() or 1))
return torch
def build_backbone(name: str, pretrained: bool = True) -> Any:
"""torchvision model with its classifier removed → pooled feature vector."""
torch = _torch()
import torchvision.models as tvm
if name not in BACKBONES:
raise ValueError(f"unknown backbone {name!r} (choose from {', '.join(BACKBONES)})")
if name == "resnet18":
m = tvm.resnet18(weights=tvm.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None)
m.fc = torch.nn.Identity()
elif name == "mobilenet_v3_small":
m = tvm.mobilenet_v3_small(
weights=tvm.MobileNet_V3_Small_Weights.IMAGENET1K_V1 if pretrained else None
)
m.classifier = torch.nn.Identity()
else:
m = tvm.efficientnet_b0(weights=tvm.EfficientNet_B0_Weights.IMAGENET1K_V1 if pretrained else None)
m.classifier = torch.nn.Identity()
return m
class Classifier: # a factory, not a Module subclass at import time (torch is lazy)
@staticmethod
def make(backbone: Any, head: Any) -> Any:
torch = _torch()
class _Net(torch.nn.Module): # type: ignore[misc,name-defined]
def __init__(self) -> None:
super().__init__()
self.backbone = backbone
self.head = head
self.register_buffer("mean", torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1))
self.register_buffer("std", torch.tensor(IMAGENET_STD).view(1, 3, 1, 1))
def forward(self, x: Any) -> Any:
x = (x / 255.0 - self.mean) / self.std
return self.head(self.backbone(x))
return _Net()
# ----------------------------------------------------------------------------------
# Images in memory (uint8 — a few thousand 224² crops is a few hundred MB; float32 would
# be four times that)
# ----------------------------------------------------------------------------------
def load_images(samples: list[Sample], input_size: int) -> tuple[Any, list[Sample]]:
"""Decode + resize every crop once. Returns (uint8 [N,3,S,S], the samples that decoded)."""
import numpy as np
xs, kept = [], []
for s in samples:
x = load_input(s.path, input_size)
if x is None:
continue
xs.append(x.astype(np.uint8))
kept.append(s)
if not xs:
return np.zeros((0, 3, input_size, input_size), np.uint8), []
return np.stack(xs), kept
def _batches(n: int, batch: int) -> list[slice]:
return [slice(i, min(n, i + batch)) for i in range(0, n, batch)]
# ----------------------------------------------------------------------------------
# Feature extraction (+ on-disk cache) and the head
# ----------------------------------------------------------------------------------
@dataclass
class FeatureCache:
"""`<cache_dir>/features-<backbone>-<size>.npz`: item ids + vectors. Retraining the head
after new labels arrive only runs the backbone on the NEW crops."""
path: Path
def load(self) -> dict[str, Any]:
import numpy as np
if not self.path.exists():
return {}
z = np.load(self.path, allow_pickle=False)
return dict(zip(z["ids"].tolist(), z["feats"], strict=True))
def save(self, table: dict[str, Any]) -> None:
import numpy as np
self.path.parent.mkdir(parents=True, exist_ok=True)
ids = np.array(list(table), dtype=str)
feats = np.stack(list(table.values())) if table else np.zeros((0, 0), np.float32)
np.savez(self.path, ids=ids, feats=feats)
def extract_features(backbone: Any, x_u8: Any, batch: int = 64) -> Any:
"""Frozen forward pass → [N,D] float32 (normalisation applied here, as in the graph)."""
torch = _torch()
import numpy as np
net = Classifier.make(backbone, torch.nn.Identity()).eval()
out = []
with torch.no_grad():
for sl in _batches(len(x_u8), batch):
xb = torch.from_numpy(x_u8[sl]).float()
out.append(net(xb).numpy())
return np.concatenate(out, axis=0) if out else np.zeros((0, 0), np.float32)
def features_for(
backbone: Any, samples: list[Sample], x_u8: Any, cache: FeatureCache | None, batch: int = 64
) -> Any:
"""Feature vectors for `samples` (aligned with x_u8), from the cache where present."""
import numpy as np
table = cache.load() if cache else {}
todo = [i for i, s in enumerate(samples) if s.item not in table]
if todo:
fresh = extract_features(backbone, x_u8[todo], batch)
for i, f in zip(todo, fresh, strict=True):
table[samples[i].item] = f.astype(np.float32)
if cache:
cache.save(table)
return np.stack([table[s.item] for s in samples]) if samples else np.zeros((0, 0), np.float32)
def train_head(
feats: Any,
y: list[int],
n_classes: int,
weights: list[float],
epochs: int,
lr: float = 1e-3,
seed: int = 7,
) -> Any:
"""Multinomial logistic regression on cached features (full batch, Adam, weighted CE)."""
torch = _torch()
torch.manual_seed(seed)
f = torch.from_numpy(feats).float()
t = torch.tensor(y, dtype=torch.long)
head = torch.nn.Linear(f.shape[1], n_classes)
opt = torch.optim.Adam(head.parameters(), lr=lr, weight_decay=1e-4)
loss_fn = torch.nn.CrossEntropyLoss(weight=torch.tensor(weights, dtype=torch.float32))
head.train()
for _ in range(epochs):
opt.zero_grad()
loss = loss_fn(head(f), t)
loss.backward()
opt.step()
return head.eval()
# ----------------------------------------------------------------------------------
# Full fine-tune
# ----------------------------------------------------------------------------------
def _augment(xb: Any) -> Any:
"""Light, label-preserving augmentation on a float batch [B,3,S,S] (0–255): horizontal
flip (a gate view mirrored is still the same body type), a mild random zoom, and
brightness/contrast jitter (dusk, headlights, wet tarmac)."""
torch = _torch()
b, _, s, _ = xb.shape
flip = torch.rand(b) < 0.5
xb = torch.where(flip.view(b, 1, 1, 1), xb.flip(-1), xb)
# zoom: crop a random 85–100 % window and resize back
out = torch.empty_like(xb)
for i in range(b):
frac = float(torch.empty(1).uniform_(0.85, 1.0))
w = max(8, int(s * frac))
x0 = int(torch.randint(0, s - w + 1, (1,)))
y0 = int(torch.randint(0, s - w + 1, (1,)))
crop = xb[i : i + 1, :, y0 : y0 + w, x0 : x0 + w]
out[i : i + 1] = torch.nn.functional.interpolate(
crop, size=(s, s), mode="bilinear", align_corners=False
)
bright = torch.empty(b, 1, 1, 1).uniform_(-25, 25)
contrast = torch.empty(b, 1, 1, 1).uniform_(0.8, 1.2)
mean = out.mean(dim=(1, 2, 3), keepdim=True)
out = (out - mean) * contrast + mean + bright
return out.clamp_(0, 255)
def finetune(
model: Any,
x_u8: Any,
y: list[int],
weights: list[float],
epochs: int,
batch: int = 32,
lr: float = 1e-4,
seed: int = 7,
log: Any = None,
) -> Any:
torch = _torch()
import numpy as np
torch.manual_seed(seed)
rng = np.random.default_rng(seed)
t = torch.tensor(y, dtype=torch.long)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-2)
steps = epochs * max(1, (len(y) + batch - 1) // batch)
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=max(1, steps), pct_start=0.15)
loss_fn = torch.nn.CrossEntropyLoss(weight=torch.tensor(weights, dtype=torch.float32))
for epoch in range(epochs):
model.train()
order = rng.permutation(len(y))
total = 0.0
for sl in _batches(len(y), batch):
idx = order[sl]
xb = _augment(torch.from_numpy(x_u8[idx]).float())
opt.zero_grad()
loss = loss_fn(model(xb), t[idx])
loss.backward()
opt.step()
sched.step()
total += float(loss.detach()) * len(idx)
if log:
log(f"epoch {epoch + 1}/{epochs} loss {total / max(1, len(y)):.4f}")
return model.eval()
def predict_logits(model: Any, x_u8: Any, batch: int = 64) -> Any:
torch = _torch()
import numpy as np
model.eval()
out = []
with torch.no_grad():
for sl in _batches(len(x_u8), batch):
out.append(model(torch.from_numpy(x_u8[sl]).float()).numpy())
return np.concatenate(out, axis=0) if out else np.zeros((0, 0), np.float32)
def export_onnx(model: Any, input_size: int, path: Path) -> None:
"""Export with the current (torch.export-based) exporter; fall back to the legacy
TorchScript one where the new path is unavailable or trips over an op. Whichever wrote
the graph, the job then checks it against the torch model (onnx_agreement) before the
file is kept."""
torch = _torch()
model.eval()
dummy = torch.zeros(1, 3, input_size, input_size)
names: dict[str, Any] = dict(input_names=["image"], output_names=["logits"])
try:
torch.onnx.export(
model,
(dummy,),
str(path),
dynamo=True,
dynamic_shapes={"x": {0: "batch"}},
opset_version=18,
external_data=False, # ONE file: the vision image bakes bodytype.onnx + .json, nothing else
verbose=False,
**names,
)
except Exception: # noqa: BLE001 - any failure → the legacy exporter
torch.onnx.export(
model,
dummy,
str(path),
dynamo=False,
dynamic_axes={"image": {0: "batch"}, "logits": {0: "batch"}},
opset_version=17,
**names,
)
+88
View File
@@ -0,0 +1,88 @@
"""Crop → model input. THE CONTRACT between the trainer and the vision service's classifier
stage: what the network sees at training time must be exactly what it sees on the booth.
The trainer does not share code with the vision service (different packages, different
images), so the contract is DATA: every constant here is written into the model's sidecar
(`bodytype.json`) and the vision side reads and applies them from there — nothing is
assumed on either side. Both use OpenCV with the same interpolation so the pixels match.
- input: the collector's crop (the detector's vehicle box + margin, plate blurred), or on
the booth the same cut made live from the frame (vehicle.py mirrors `makeReviewCrop`).
- resize: squash to input_size × input_size with INTER_AREA (the crop IS the vehicle; no
centre-crop that would lose a bumper or a roofline — the shape is the signal).
- colour: RGB, float32, 0–255. Normalisation (/255, ImageNet mean/std) lives INSIDE the
ONNX graph, so a consumer feeds raw pixels and cannot get the constants wrong.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
SIDECAR_FORMAT = "parking-bodytype/1"
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)
CROP_MARGIN = 0.08 # must equal CROP_MARGIN in apps/server review-outbox.ts
@dataclass
class Sidecar:
"""`bodytype.json` beside `bodytype.onnx`."""
version: str
classes: list[str]
input_size: int = 224
color: str = "rgb"
resize: str = "area"
crop_margin: float = CROP_MARGIN
normalization: str = "in-graph" # the ONNX divides by 255 and applies mean/std itself
mean: list[float] = field(default_factory=lambda: list(IMAGENET_MEAN))
std: list[float] = field(default_factory=lambda: list(IMAGENET_STD))
backbone: str = ""
mode: str = ""
trained_at: str = ""
labels: dict[str, int] = field(default_factory=dict) # train / val counts
metrics: dict[str, Any] = field(default_factory=dict) # accuracy, macro, per-class
format: str = SIDECAR_FORMAT
def write(self, path: Path) -> None:
path.write_text(json.dumps(asdict(self), indent=2) + "\n")
@classmethod
def read(cls, path: Path) -> Sidecar:
d = json.loads(path.read_text())
if d.get("format") != SIDECAR_FORMAT:
raise ValueError(f"{path}: unknown sidecar format {d.get('format')!r}")
known = {f for f in cls.__dataclass_fields__}
return cls(**{k: v for k, v in d.items() if k in known})
def load_input(path: Path, input_size: int) -> Any:
"""Decode a crop and produce the network input: RGB float32 CHW, 0–255, squashed to
input_size. Returns None when the file cannot be decoded."""
import cv2
img = cv2.imread(str(path), cv2.IMREAD_COLOR)
if img is None:
return None
return array_to_input(img, input_size)
def array_to_input(bgr: Any, input_size: int) -> Any:
"""BGR uint8 HWC (OpenCV's native) → RGB float32 CHW 0–255 at input_size."""
import cv2
import numpy as np
resized = cv2.resize(bgr, (input_size, input_size), interpolation=cv2.INTER_AREA)
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
return np.ascontiguousarray(rgb.transpose(2, 0, 1).astype(np.float32))
def softmax(logits: Any) -> Any:
import numpy as np
z = logits - logits.max(axis=-1, keepdims=True)
e = np.exp(z)
return e / e.sum(axis=-1, keepdims=True)
+152
View File
@@ -0,0 +1,152 @@
"""Metrics + the human-readable report. The owner reads this BEFORE anything ships; the
floor decision is made on these numbers. Pure Python, no torch."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
@dataclass
class ClassMetrics:
support: int
recall: float # of the true members, how many the model caught
precision: float # of the model's picks, how many were right
@dataclass
class Metrics:
n: int
accuracy: float
macro_recall: float
per_class: dict[str, ClassMetrics]
confusion: list[list[int]] # rows = true class, cols = predicted, in `classes` order
camera_agreement: float | None = None # how often the model equals the detector's class
onnx_agreement: float | None = None # exported graph vs the torch model, argmax
extra: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def compute_metrics(
classes: tuple[str, ...] | list[str],
y_true: list[int],
y_pred: list[int],
camera: list[str] | None = None,
) -> Metrics:
k = len(classes)
conf = [[0] * k for _ in range(k)]
for t, p in zip(y_true, y_pred, strict=True):
conf[t][p] += 1
per: dict[str, ClassMetrics] = {}
recalls: list[float] = []
for i, c in enumerate(classes):
support = sum(conf[i])
tp = conf[i][i]
picked = sum(conf[r][i] for r in range(k))
recall = tp / support if support else 0.0
precision = tp / picked if picked else 0.0
per[c] = ClassMetrics(support=support, recall=recall, precision=precision)
if support:
recalls.append(recall)
n = len(y_true)
acc = sum(1 for t, p in zip(y_true, y_pred, strict=True) if t == p) / n if n else 0.0
agree = None
if camera is not None and n:
agree = sum(1 for p, cam in zip(y_pred, camera, strict=True) if classes[p] == cam) / n
return Metrics(
n=n,
accuracy=acc,
macro_recall=sum(recalls) / len(recalls) if recalls else 0.0,
per_class=per,
confusion=conf,
camera_agreement=agree,
)
def _pct(x: float | None) -> str:
return "—" if x is None else f"{x * 100:.1f} %"
def render_report(
*,
version: str,
trained_at: str,
mode: str,
backbone: str,
epochs: int,
classes: tuple[str, ...] | list[str],
train_counts: dict[str, int],
val_counts: dict[str, int],
dropped: dict[str, int],
missing_files: int,
weights: list[float],
metrics: Metrics,
min_accuracy: float,
written: bool,
notes: list[str] | None = None,
) -> str:
lines: list[str] = []
verdict = "MODEL WRITTEN" if written else "MODEL NOT WRITTEN — below the floor"
lines.append(f"# Body-type classifier {version}")
lines.append("")
lines.append(
f"**{verdict}** · validation accuracy {_pct(metrics.accuracy)} vs floor {_pct(min_accuracy)}"
)
lines.append("")
lines.append(f"- trained: {trained_at}")
lines.append(f"- mode: {mode} · backbone: {backbone} · epochs: {epochs}")
lines.append(f"- classes ({len(classes)}): {', '.join(classes)}")
lines.append(
f"- labels: {sum(train_counts.values())} train · {sum(val_counts.values())} validation "
"(validation = the NEWEST slice, by time seen)"
)
if dropped:
lines.append(
"- dropped (too few labels this run): "
+ ", ".join(f"{c} ({n})" for c, n in sorted(dropped.items()))
)
if missing_files:
lines.append(f"- labelled rows whose crop is missing on disk (skipped): {missing_files}")
lines.append("")
lines.append("## Validation")
lines.append("")
lines.append(f"- accuracy: {_pct(metrics.accuracy)} on {metrics.n} crops")
lines.append(f"- macro recall (each class counted equally): {_pct(metrics.macro_recall)}")
if metrics.camera_agreement is not None:
lines.append(
f"- agrees with the detector's coarse class: {_pct(metrics.camera_agreement)} "
"(informational — the detector only knows car/truck/bus/motorcycle)"
)
if metrics.onnx_agreement is not None:
lines.append(
f"- exported ONNX matches the trained model on validation: {_pct(metrics.onnx_agreement)}"
)
lines.append("")
lines.append("| class | train | val | recall | precision | loss weight |")
lines.append("|---|---:|---:|---:|---:|---:|")
for c, w in zip(classes, weights, strict=True):
m = metrics.per_class[c]
lines.append(
f"| {c} | {train_counts.get(c, 0)} | {m.support} | {_pct(m.recall) if m.support else '—'} | "
f"{_pct(m.precision) if m.support else '—'} | {w:.2f} |"
)
lines.append("")
lines.append("## Confusion (rows = reviewer's label, columns = model)")
lines.append("")
lines.append("| | " + " | ".join(classes) + " |")
lines.append("|---|" + "---:|" * len(classes))
for c, row in zip(classes, metrics.confusion, strict=True):
lines.append(f"| **{c}** | " + " | ".join(str(n) for n in row) + " |")
lines.append("")
if notes:
lines.append("## Notes")
lines.append("")
lines.extend(f"- {n}" for n in notes)
lines.append("")
lines.append(
"The flag on the booth records, it never bills: even a model that passes the floor is "
"advisory (the site threshold gates the flag)."
)
return "\n".join(lines) + "\n"
+382
View File
@@ -0,0 +1,382 @@
"""`parking-trainer serve` — the job API behind the collector's Training section.
A tiny stdlib HTTP server (no framework, no extra deps) on the compose-internal network,
never published: the collector proxies to it behind the reviewer's login. One job at a
time; each job is the CLI run as a SUBPROCESS (`python -m trainer.cli …`) with its output
captured to a log file — torch's memory goes away with the process, and a crashing job
cannot take the service down. Job state + logs persist under `<out>/jobs/` so a restart
still shows history.
GET /health {ok, busy, version}
GET /readiness what `inspect` prints (+ the defaults the UI offers)
GET /versions every <out>/<version>/ folder: written?, metrics, sidecar
GET /versions/<v>/report report.md (text/markdown)
GET /jobs recent jobs, newest first
GET /jobs/<id> one job incl. the log tail
POST /jobs {kind: train|evaluate|publish, …args} → 202 {id} | 409 busy
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import threading
import time
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from .cli import METRICS_FILE, MODEL_FILE, REPORT_FILE, SIDECAR_FILE
from .data import load_labelled, make_split, summarise
from .preprocess import Sidecar
_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
BACKBONES = ("resnet18", "mobilenet_v3_small", "efficientnet_b0")
MODES = ("features", "finetune")
LOG_TAIL_BYTES = 16_000
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
class Jobs:
"""The single-slot job runner. `start` refuses while one runs."""
def __init__(self, data_dir: Path, out_dir: Path, publish_url: str, publish_token: str) -> None:
self.data_dir = data_dir
self.out_dir = out_dir
self.publish_url = publish_url
self.publish_token = publish_token
self.jobs_dir = out_dir / "jobs"
self.jobs_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._current: dict[str, Any] | None = None
self._proc: subprocess.Popen[bytes] | None = None
# ---- state -----------------------------------------------------------------------
def _write(self, job: dict[str, Any]) -> None:
(self.jobs_dir / f"{job['id']}.json").write_text(json.dumps(job, indent=2))
def _read(self, job_id: str) -> dict[str, Any] | None:
p = self.jobs_dir / f"{job_id}.json"
if not p.is_file():
return None
return json.loads(p.read_text()) # type: ignore[no-any-return]
def log_tail(self, job_id: str) -> str:
p = self.jobs_dir / f"{job_id}.log"
if not p.is_file():
return ""
size = p.stat().st_size
with p.open("rb") as f:
if size > LOG_TAIL_BYTES:
f.seek(size - LOG_TAIL_BYTES)
return f.read().decode("utf-8", "replace")
@property
def busy(self) -> bool:
return self._current is not None
def current(self) -> dict[str, Any] | None:
return dict(self._current) if self._current else None
def get(self, job_id: str) -> dict[str, Any] | None:
if self._current and self._current["id"] == job_id:
job = dict(self._current)
else:
job = self._read(job_id) or {}
if not job:
return None
job["log"] = self.log_tail(job_id)
return job
def recent(self, limit: int = 20) -> list[dict[str, Any]]:
files = sorted(self.jobs_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
out = []
for p in files[:limit]:
try:
out.append(json.loads(p.read_text()))
except ValueError:
continue
if self._current and all(j["id"] != self._current["id"] for j in out):
out.insert(0, dict(self._current))
out.sort(key=lambda j: j.get("startedAt", ""), reverse=True)
return out
# ---- args → CLI ------------------------------------------------------------------
def _argv(self, kind: str, a: dict[str, Any]) -> list[str]:
base = [sys.executable, "-m", "trainer.cli"]
if kind == "train":
mode = a.get("mode", "features")
backbone = a.get("backbone", "resnet18")
if mode not in MODES or backbone not in BACKBONES:
raise ValueError("bad mode/backbone")
floor = float(a.get("minAccuracy", 0.85))
min_per = int(a.get("minPerClass", 20))
epochs = int(a.get("epochs", 0))
if not 0.0 <= floor <= 1.0 or min_per < 1 or epochs < 0:
raise ValueError("bad numbers")
argv = base + [
"train",
"--data",
str(self.data_dir),
"--out",
str(self.out_dir),
"--mode",
mode,
"--backbone",
backbone,
"--min-accuracy",
str(floor),
"--min-per-class",
str(min_per),
"--epochs",
str(epochs),
]
if a.get("version"):
argv += ["--version", self._version(a["version"])]
return argv
if kind == "evaluate":
v = self._version(a.get("version", ""))
return base + [
"evaluate",
"--data",
str(self.data_dir),
"--model",
str(self.out_dir / v / MODEL_FILE),
]
if kind == "publish":
v = self._version(a.get("version", ""))
url = str(a.get("url") or self.publish_url)
if not url.startswith("https://") and not url.startswith("http://"):
raise ValueError("bad publish url")
return base + ["publish", str(self.out_dir / v), "--url", url]
raise ValueError("kind must be train, evaluate or publish")
@staticmethod
def _version(v: Any) -> str:
if not isinstance(v, str) or not _VERSION_RE.match(v) or v in ("cache", "jobs"):
raise ValueError("bad version")
return v
# ---- run -------------------------------------------------------------------------
def start(self, kind: str, args: dict[str, Any]) -> dict[str, Any]:
argv = self._argv(kind, args)
with self._lock:
if self._current is not None:
raise RuntimeError("busy")
job_id = f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
job: dict[str, Any] = {
"id": job_id,
"kind": kind,
"args": {k: v for k, v in args.items() if k != "token"},
"status": "running",
"startedAt": _now(),
"finishedAt": None,
"exitCode": None,
}
env = dict(os.environ)
if kind == "publish":
env["TRAINER_PUBLISH_TOKEN"] = self.publish_token
log = (self.jobs_dir / f"{job_id}.log").open("wb")
log.write(f"$ {' '.join(argv[3:])}\n".encode())
log.flush()
self._proc = subprocess.Popen(argv, stdout=log, stderr=subprocess.STDOUT, env=env)
self._current = job
self._write(job)
threading.Thread(target=self._wait, args=(job, log), daemon=True).start()
return dict(job)
def _wait(self, job: dict[str, Any], log: Any) -> None:
assert self._proc is not None
code = self._proc.wait()
log.close()
with self._lock:
job["exitCode"] = code
job["finishedAt"] = _now()
job["status"] = "done" if code == 0 else ("refused" if code in (2, 3) else "failed")
self._write(job)
self._current = None
self._proc = None
# ----------------------------------------------------------------------------------
def readiness(data_dir: Path, min_per_class: int = 20, val_fraction: float = 0.2) -> dict[str, Any]:
try:
samples, missing = load_labelled(data_dir)
except FileNotFoundError:
return {
"ready": False,
"labelled": {"total": 0, "byClass": {}},
"missingCrops": 0,
"error": "no collector database yet",
}
split = make_split(samples, val_fraction, min_per_class)
return {
"ready": len(split.classes) >= 2,
"labelled": summarise(samples),
"missingCrops": missing,
"run": {
"classes": list(split.classes),
"train": split.counts("train"),
"val": split.counts("val"),
"dropped": split.dropped,
"minPerClass": min_per_class,
"valFraction": val_fraction,
},
"defaults": {
"mode": "features",
"backbone": "resnet18",
"minAccuracy": 0.85,
"minPerClass": min_per_class,
},
"modes": list(MODES),
"backbones": list(BACKBONES),
}
def versions(out_dir: Path) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
if not out_dir.is_dir():
return out
for d in sorted(out_dir.iterdir(), key=lambda p: p.name, reverse=True):
if not d.is_dir() or d.name in ("cache", "jobs"):
continue
if not (d / REPORT_FILE).is_file() and not (d / METRICS_FILE).is_file():
continue
entry: dict[str, Any] = {
"version": d.name,
"written": (d / MODEL_FILE).is_file() and (d / SIDECAR_FILE).is_file(),
"hasReport": (d / REPORT_FILE).is_file(),
"modifiedAt": datetime.fromtimestamp(d.stat().st_mtime, tz=timezone.utc)
.replace(microsecond=0)
.isoformat(),
}
try:
m = json.loads((d / METRICS_FILE).read_text())
entry["accuracy"] = m.get("accuracy")
entry["macroRecall"] = m.get("macro_recall")
entry["n"] = m.get("n")
except (OSError, ValueError):
pass
if entry["written"]:
try:
side = Sidecar.read(d / SIDECAR_FILE)
entry["classes"] = side.classes
entry["mode"] = side.mode
entry["backbone"] = side.backbone
entry["trainedAt"] = side.trained_at
entry["labels"] = side.labels
entry["floor"] = side.metrics.get("floor")
except (OSError, ValueError):
pass
out.append(entry)
return out
# ----------------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
jobs: Jobs # set on the class by serve()
server_version = "parking-trainer"
def log_message(self, fmt: str, *args: Any) -> None: # quieter than the default
sys.stderr.write(f"[trainer.serve] {self.address_string()} {fmt % args}\n")
def _json(self, code: int, body: Any) -> None:
raw = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def _text(self, code: int, body: str, ctype: str = "text/markdown; charset=utf-8") -> None:
raw = body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
j = self.jobs
if path == "/health":
self._json(200, {"ok": True, "busy": j.busy, "version": "parking-trainer"})
elif path == "/readiness":
self._json(200, readiness(j.data_dir))
elif path == "/versions":
self._json(200, {"versions": versions(j.out_dir)})
elif path.startswith("/versions/") and path.endswith("/report"):
v = path[len("/versions/") : -len("/report")]
try:
p = j.out_dir / Jobs._version(v) / REPORT_FILE
except ValueError:
self._json(400, {"error": "bad version"})
return
if not p.is_file():
self._json(404, {"error": "no report"})
else:
self._text(200, p.read_text())
elif path == "/jobs":
self._json(200, {"jobs": j.recent(), "current": j.current()})
elif path.startswith("/jobs/"):
job = j.get(path[len("/jobs/") :])
self._json(200, job) if job else self._json(404, {"error": "no such job"})
else:
self._json(404, {"error": "not found"})
def do_POST(self) -> None: # noqa: N802
if self.path.split("?", 1)[0] != "/jobs":
self._json(404, {"error": "not found"})
return
n = int(self.headers.get("Content-Length") or 0)
if n > 64_000:
self._json(413, {"error": "too large"})
return
try:
body = json.loads(self.rfile.read(n) or b"{}")
if not isinstance(body, dict):
raise ValueError("object expected")
except ValueError as exc:
self._json(400, {"error": f"bad json: {exc}"})
return
kind = str(body.pop("kind", ""))
try:
job = self.jobs.start(kind, body)
except ValueError as exc:
self._json(400, {"error": str(exc)})
return
except RuntimeError:
self._json(409, {"error": "a job is already running", "current": self.jobs.current()})
return
self._json(202, job)
def serve(data_dir: Path, out_dir: Path, host: str, port: int, publish_url: str, publish_token: str) -> None:
Handler.jobs = Jobs(data_dir, out_dir, publish_url, publish_token)
httpd = ThreadingHTTPServer((host, port), Handler)
sys.stderr.write(f"[trainer.serve] listening on {host}:{port}, data {data_dir}, out {out_dir}\n")
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
httpd.server_close()
def wait_idle(jobs: Jobs, timeout: float = 60.0) -> None:
"""Test helper: block until no job runs."""
t0 = time.monotonic()
while jobs.busy and time.monotonic() - t0 < timeout:
time.sleep(0.1)
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": []
}
}
}
+1444
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
.venv/
**/__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.env
# weights are fetched inside the build (yolox) or pinned by models/bodytype.version
models/*
!models/bodytype.version
+17
View File
@@ -20,3 +20,20 @@ VISION_OCR_MODEL=cct-xs-v2-global-model
# Confidence floor — a best plate below this is flagged low_confidence so the Node side # Confidence floor — a best plate below this is flagged low_confidence so the Node side
# treats it as advisory and falls back to the ticket path. Keep in sync with the server. # treats it as advisory and falls back to the ticket path. Keep in sync with the server.
VISION_MIN_CONFIDENCE=0.5 VISION_MIN_CONFIDENCE=0.5
# Vehicle stage (phase A): a YOLOX ONNX graph (Apache-2.0) run on the same frame after the
# plate read; fills /analyze `vehicle.body_type` (car/truck/bus/motorcycle) + confidence for
# the Car Wash desk's category suggestion. Unset = off. The Docker image bakes the weights
# at /app/models/yolox_s.onnx; locally: curl the release file into apps/vision/models/.
# https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_s.onnx
# VISION_VEHICLE_MODEL_PATH=models/yolox_s.onnx
# VISION_VEHICLE_INPUT_SIZE=640
# VISION_VEHICLE_MIN_CONFIDENCE=0.4
# Phase B: the body-type classifier (sedan/hatchback/suv/… on the detector's crop), trained
# by apps/trainer on the reviewer's labels. The Docker image bakes it at
# /app/models/bodytype.onnx (+ .json sidecar) when models/bodytype.version pins a published
# version; locally copy a trainer output folder's two files into apps/vision/models/.
# Path set but no file = stage off (the normal state before the first model).
# VISION_VEHICLE_CLASSIFIER_PATH=models/bodytype.onnx
# VISION_VEHICLE_CLASSIFIER_MIN_CONFIDENCE=0.6
+2 -1
View File
@@ -7,5 +7,6 @@ __pycache__/
.ruff_cache/ .ruff_cache/
# Model weights (fetched at deploy / first run, never committed — can be large + license-scoped) # Model weights (fetched at deploy / first run, never committed — can be large + license-scoped)
models/ models/*
!models/bodytype.version
*.onnx *.onnx
+29 -2
View File
@@ -14,7 +14,7 @@ ENV UV_LINK_MODE=copy \
# System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal. # System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \ && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# ---- deps: resolve + install the venv from the lockfile (cache-friendly) ---- # ---- deps: resolve + install the venv from the lockfile (cache-friendly) ----
@@ -26,6 +26,31 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# ---- project source ---- # ---- project source ----
COPY vision_service/ ./vision_service/ COPY vision_service/ ./vision_service/
COPY README.md ./ COPY README.md ./
# Vehicle stage weights (phase A): YOLOX-S, Apache-2.0, ~36 MB, baked into the image so the
# air-gapped appliance never fetches at runtime and no operator-writable path holds a model
# (vision-service-hardening.md). Best-effort at build: without network the stage stays off.
ARG YOLOX_URL=https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_s.onnx
RUN mkdir -p /app/models \
&& (curl -fsSL -o /app/models/yolox_s.onnx "$YOLOX_URL" \
|| (echo "[build] yolox weights not fetched (no network) — vehicle stage off" && rm -f /app/models/yolox_s.onnx))
# Phase B body-type classifier (apps/trainer output, published to the Gitea generic package
# registry — weights are not code, they never live in git). models/bodytype.version PINS the
# version this image carries: empty = no classifier (phase B off). A pinned version that
# cannot be fetched FAILS the build — the image must carry what git says it carries. The
# registry may need auth: pass a BuildKit secret `bodytype_auth` holding "user:token".
ARG BODYTYPE_BASE_URL=https://git.infra.msai.al/api/packages/mca/generic/parking-bodytype
COPY models/bodytype.version ./models/bodytype.version
RUN --mount=type=secret,id=bodytype_auth \
v="$(tr -d '[:space:]' < /app/models/bodytype.version)"; \
if [ -n "$v" ]; then \
cfg=/tmp/curl.cfg; : > "$cfg"; \
[ -f /run/secrets/bodytype_auth ] && printf 'user = "%s"\n' "$(cat /run/secrets/bodytype_auth)" > "$cfg"; \
curl -fsSL -K "$cfg" -o /app/models/bodytype.onnx "$BODYTYPE_BASE_URL/$v/bodytype.onnx" \
&& curl -fsSL -K "$cfg" -o /app/models/bodytype.json "$BODYTYPE_BASE_URL/$v/bodytype.json" \
&& echo "[build] bodytype classifier $v baked" \
|| { echo "[build] bodytype classifier $v could not be fetched"; rm -f "$cfg"; exit 1; }; \
rm -f "$cfg"; \
else echo "[build] no bodytype version pinned — phase B off"; fi
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --extra alpr uv sync --frozen --extra alpr
@@ -49,7 +74,9 @@ RUN uv run python -c "from fast_alpr import ALPR; ALPR()" \
# Default to the stub recognizer (offline, no model load); override to fast_alpr in prod. # Default to the stub recognizer (offline, no model load); override to fast_alpr in prod.
ENV VISION_RECOGNIZER=stub \ ENV VISION_RECOGNIZER=stub \
VISION_HOST=0.0.0.0 \ VISION_HOST=0.0.0.0 \
VISION_PORT=8089 VISION_PORT=8089 \
VISION_VEHICLE_MODEL_PATH=/app/models/yolox_s.onnx \
VISION_VEHICLE_CLASSIFIER_PATH=/app/models/bodytype.onnx
EXPOSE 8089 EXPOSE 8089
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1 CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1
View File
+3
View File
@@ -35,6 +35,9 @@ dev = [
"pytest>=8.3", "pytest>=8.3",
"httpx>=0.27", # FastAPI TestClient transport "httpx>=0.27", # FastAPI TestClient transport
"mypy>=1.13", "mypy>=1.13",
# The vehicle stage's post-processing tests run on synthetic tensors without the model
# stack (CI syncs WITHOUT the alpr extra); the service itself imports numpy lazily.
"numpy>=1.26",
] ]
[tool.ruff] [tool.ruff]
+277
View File
@@ -0,0 +1,277 @@
"""Vehicle stage (phase A) — pure post-processing on synthetic tensors, and the
recognizer composition over the stub with a fake detector. No weights needed."""
from __future__ import annotations
import os
import numpy as np
import pytest
from fastapi.testclient import TestClient
from vision_service.schemas import BBox, VehicleResult
from vision_service.vehicle import (
COCO_VEHICLE_CLASSES,
Detection,
decode,
letterbox,
nms,
pick_vehicle,
vehicles_from_output,
)
SIZE = 64 # tiny "model" input: grids 8x8 + 4x4 + 2x2 = 84 rows
ROWS = (SIZE // 8) ** 2 + (SIZE // 16) ** 2 + (SIZE // 32) ** 2
def raw_output(hits: list[tuple[int, int, int, float, float, float, float]]) -> np.ndarray:
"""Build a YOLOX-style raw tensor [ROWS, 85] with the given (row, coco_class, _, obj,
cls_score, log_w, log_h) hits; everything else is background."""
raw = np.zeros((ROWS, 85), dtype=np.float32)
raw[:, 2:4] = -10.0 # exp → ~0 size for background rows
for row, cls, _, obj, score, lw, lh in hits:
raw[row, 0:2] = 0.5 # centre of its grid cell
raw[row, 2] = lw
raw[row, 3] = lh
raw[row, 4] = obj
raw[row, 5 + cls] = score
return raw
def test_decode_maps_grid_offsets_and_log_sizes_to_pixels() -> None:
raw = raw_output([(0, 2, 0, 1.0, 1.0, np.log(2.0), np.log(3.0))])
dec = decode(raw, SIZE)
# Row 0 = stride-8 grid cell (0,0): centre (0.5+0)*8 = 4, size exp(log 2)*8 = 16 / 24.
assert dec[0, :4].tolist() == [4.0, 4.0, 16.0, 24.0]
# Last row = stride-32 cell (1,1): centre (0.5+1)*32 = 48.
raw2 = raw_output([(ROWS - 1, 7, 0, 1.0, 1.0, 0.0, 0.0)])
dec2 = decode(raw2, SIZE)
assert dec2[ROWS - 1, :4].tolist() == [48.0, 48.0, 32.0, 32.0]
def test_vehicles_only_above_floor_mapped_to_vocabulary_and_scaled_back() -> None:
raw = raw_output(
[
(0, 2, 0, 0.9, 0.9, np.log(2.0), np.log(2.0)), # car, score .81
(1, 0, 0, 0.99, 0.99, np.log(2.0), np.log(2.0)), # person → ignored
(2, 7, 0, 0.5, 0.5, np.log(2.0), np.log(2.0)), # truck, score .25 → below floor
]
)
found = vehicles_from_output(raw, SIZE, scale=0.5, min_confidence=0.4)
assert [d.body_type for d in found] == ["car"]
assert round(found[0].confidence, 2) == 0.81
# Box 16px wide in the letterboxed input → 32px in the original (scale 0.5).
assert round(found[0].x2 - found[0].x1) == 32
assert set(COCO_VEHICLE_CLASSES.values()) == {"car", "motorcycle", "bus", "truck"}
def test_nms_keeps_the_best_of_overlapping_boxes() -> None:
boxes = np.array([[0, 0, 10, 10], [1, 1, 11, 11], [50, 50, 60, 60]], dtype=np.float32)
scores = np.array([0.5, 0.9, 0.7], dtype=np.float32)
assert sorted(nms(boxes, scores, 0.45)) == [1, 2]
def test_pick_prefers_the_box_holding_the_plate_else_the_largest() -> None:
near = Detection("car", 0.9, 0, 0, 100, 100)
far = Detection("truck", 0.8, 200, 200, 400, 400) # larger
inside = Detection("car", 0.7, 10, 10, 60, 60) # tighter box also holding the plate
assert pick_vehicle([near, far], None) is far
assert pick_vehicle([near, far], BBox(x1=20, y1=20, x2=30, y2=30)) is near
assert pick_vehicle([near, far, inside], BBox(x1=20, y1=20, x2=30, y2=30)) is inside
assert pick_vehicle([near, far], BBox(x1=900, y1=900, x2=910, y2=910)) is far # plate outside every box
assert pick_vehicle([], None) is None
def test_letterbox_keeps_aspect_and_pads_with_114() -> None:
pytest.importorskip("cv2") # letterbox resizes with OpenCV — only present with the alpr extra
frame = np.zeros((30, 60, 3), dtype=np.uint8)
tensor, scale = letterbox(frame, 64)
assert tensor.shape == (1, 3, 64, 64) and tensor.dtype == np.float32
assert abs(scale - 64 / 60) < 1e-9
assert tensor[0, 0, 63, 63] == 114.0 # padding
assert tensor[0, 0, 0, 0] == 0.0 # image
class FakeDetector:
model_version = "fake-vehicle"
def __init__(self, result: VehicleResult | None) -> None:
self.result = result
self.calls: list[BBox | None] = []
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
self.calls.append(plate)
return self.result
def test_composition_fills_vehicle_over_the_stub_and_survives_a_failing_stage() -> None:
from vision_service.recognizer import StubRecognizer, WithVehicle
from vision_service.settings import Settings
det = FakeDetector(VehicleResult(body_type="truck", confidence=0.77))
rec = WithVehicle(StubRecognizer(Settings()), det)
res = rec.analyze(b"jpeg-bytes")
assert res.plate is None
assert res.vehicle == VehicleResult(body_type="truck", confidence=0.77)
assert res.model_version == "stub-0+fake-vehicle"
assert det.calls == [None]
class Boom:
model_version = "boom"
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
raise RuntimeError("no model")
rec2 = WithVehicle(StubRecognizer(Settings()), Boom())
res2 = rec2.analyze(b"jpeg-bytes")
assert res2.vehicle is None
assert rec2.ready is True
assert "vehicle: RuntimeError: no model" in (rec2.error or "")
def test_app_reports_a_missing_model_file_and_keeps_serving() -> None:
from vision_service.app import app
os.environ["VISION_VEHICLE_MODEL_PATH"] = "/nonexistent/yolox.onnx"
try:
with TestClient(app) as client:
health = client.get("/health").json()
assert health["ready"] is True # the plate stage (stub) is fine
assert "vehicle:" in (health["detail"] or "")
res = client.post("/analyze", content=b"x", headers={"content-type": "application/octet-stream"})
assert res.status_code == 200
assert res.json()["vehicle"] is None
finally:
os.environ.pop("VISION_VEHICLE_MODEL_PATH", None)
# ----------------------------------------------------------------------------------
# Phase B: the classifier stage over the detector
# ----------------------------------------------------------------------------------
def test_crop_vehicle_adds_the_margin_clamps_and_blurs_the_plate() -> None:
cv2 = pytest.importorskip("cv2")
from vision_service.vehicle import crop_vehicle
frame = np.zeros((100, 200, 3), dtype=np.uint8)
frame[40:50, 90:110] = (0, 255, 0) # a green "plate"
box = BBox(x1=50, y1=20, x2=150, y2=80) # 100×60 → 8 % margin = 8 / 5 px
crop = crop_vehicle(frame, box, None, 0.08)
assert crop.shape == (70, 116, 3)
edge = crop_vehicle(frame, BBox(x1=0, y1=0, x2=100, y2=60), None, 0.08)
assert edge.shape == (65, 108, 3) # clamped at the frame's top-left
assert crop_vehicle(frame, BBox(x1=10, y1=10, x2=12, y2=12), None, 0.08) is None
blurred = crop_vehicle(frame, box, BBox(x1=90, y1=40, x2=110, y2=50), 0.08)
strip = blurred[40 - 20 + 5 : 50 - 20 + 5, 90 - 50 + 8 : 110 - 50 + 8, 1] # plate strip, green channel
assert strip.mean() < 200 and crop[20 + 5 : 30 + 5, 40 + 8 : 60 + 8, 1].mean() == 255
assert cv2 is not None
class FakeClassifier:
ready = True
error = None
min_confidence = 0.6
model_version = "bodytype:vfake"
def __init__(self, classes: list[str], answer: tuple[str, float] | None) -> None:
self.classes = classes
self.answer = answer
self.calls = 0
def classify(self, frame, box, plate): # type: ignore[no-untyped-def]
self.calls += 1
if isinstance(self.answer, Exception):
raise self.answer
return self.answer
class FrameDetector:
"""A detector that answers on decoded frames (like YOLOX) with a fixed result."""
model_version = "det"
ready = True
error = None
def __init__(self, result: VehicleResult | None) -> None:
self.result = result
def detect_frame(self, frame, plate): # type: ignore[no-untyped-def]
return self.result
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
raise AssertionError("the refined stage should share the decoded frame")
def _jpeg() -> bytes:
cv2 = pytest.importorskip("cv2")
ok, buf = cv2.imencode(".jpg", np.zeros((60, 80, 3), dtype=np.uint8))
assert ok
return bytes(buf)
def test_refined_detector_replaces_car_when_confident_else_keeps_the_detector() -> None:
from vision_service.vehicle import RefinedVehicleDetector
car = VehicleResult(body_type="car", confidence=0.85, bbox=BBox(x1=10, y1=10, x2=70, y2=50))
sure = FakeClassifier(["sedan", "suv"], ("suv", 0.91))
res = RefinedVehicleDetector(FrameDetector(car), sure).detect(_jpeg(), None)
assert (
res is not None and res.body_type == "suv" and res.confidence == 0.91 and res.detector_class == "car"
)
assert res.bbox == car.bbox
unsure = FakeClassifier(["sedan", "suv"], ("suv", 0.4))
res2 = RefinedVehicleDetector(FrameDetector(car), unsure).detect(_jpeg(), None)
assert (
res2 is not None
and res2.body_type == "car"
and res2.confidence == 0.85
and res2.detector_class == "car"
)
# A class the classifier never trained on is left alone (its softmax means nothing there).
bus = VehicleResult(body_type="bus", confidence=0.9, bbox=car.bbox)
skip = FakeClassifier(["sedan", "suv"], ("suv", 0.99))
res3 = RefinedVehicleDetector(FrameDetector(bus), skip).detect(_jpeg(), None)
assert res3 == bus and skip.calls == 0
# …unless it was: a classifier that knows trucks may override a truck.
knows = FakeClassifier(["sedan", "truck", "van"], ("van", 0.8))
truck = VehicleResult(body_type="truck", confidence=0.7, bbox=car.bbox)
res4 = RefinedVehicleDetector(FrameDetector(truck), knows).detect(_jpeg(), None)
assert res4 is not None and res4.body_type == "van" and res4.detector_class == "truck"
def test_refined_detector_survives_a_broken_classifier_and_reports_it() -> None:
from vision_service.vehicle import RefinedVehicleDetector
car = VehicleResult(body_type="car", confidence=0.85, bbox=BBox(x1=10, y1=10, x2=70, y2=50))
boom = FakeClassifier(["sedan"], RuntimeError("bad graph")) # type: ignore[arg-type]
ref = RefinedVehicleDetector(FrameDetector(car), boom)
assert ref.detect(_jpeg(), None) == car
assert ref.error == "classifier: RuntimeError: bad graph"
assert ref.ready is True and ref.model_version == "det+bodytype:vfake"
# No box, or a detector that found nothing → nothing to classify.
assert RefinedVehicleDetector(FrameDetector(None), boom).detect(_jpeg(), None) is None
boxless = VehicleResult(body_type="car", confidence=0.85)
assert RefinedVehicleDetector(FrameDetector(boxless), boom).detect(_jpeg(), None) == boxless
def test_classifier_without_files_is_not_ready_and_the_factory_skips_a_missing_model(tmp_path) -> None: # type: ignore[no-untyped-def]
from vision_service.recognizer import WithVehicle, build_recognizer
from vision_service.settings import Settings
from vision_service.vehicle import BodyTypeClassifier, RefinedVehicleDetector
clf = BodyTypeClassifier(str(tmp_path / "bodytype.onnx"))
assert clf.ready is False and "FileNotFoundError" in (clf.error or "")
(tmp_path / "bodytype.json").write_text('{"format": "other"}')
assert "unknown sidecar format" in (BodyTypeClassifier(str(tmp_path / "bodytype.onnx")).error or "")
# A path with no file = the normal pre-model state: phase A only, no error in health.
s = Settings(
vehicle_model_path="/nonexistent/yolox.onnx", vehicle_classifier_path=str(tmp_path / "none.onnx")
)
rec = build_recognizer(s)
assert isinstance(rec, WithVehicle)
assert not isinstance(rec._detector, RefinedVehicleDetector) # noqa: SLF001
assert "classifier" not in (rec.error or "")
+3
View File
@@ -760,6 +760,8 @@ alpr = [
dev = [ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "mypy" }, { name = "mypy" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -779,6 +781,7 @@ provides-extras = ["alpr"]
dev = [ dev = [
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "mypy", specifier = ">=1.13" }, { name = "mypy", specifier = ">=1.13" },
{ name = "numpy", specifier = ">=1.26" },
{ name = "pytest", specifier = ">=8.3" }, { name = "pytest", specifier = ">=8.3" },
{ name = "ruff", specifier = ">=0.8" }, { name = "ruff", specifier = ">=0.8" },
] ]
+67 -2
View File
@@ -14,11 +14,16 @@ Adding a recognizer (e.g. a fine-tuned YOLO + PaddleOCR) = a new class here, no
from __future__ import annotations from __future__ import annotations
import logging
import time import time
from pathlib import Path
from typing import Protocol from typing import Protocol
from .schemas import AnalyzeResponse, BBox, PlateResult from .schemas import AnalyzeResponse, BBox, PlateResult
from .settings import Settings from .settings import Settings
from .vehicle import BodyTypeClassifier, RefinedVehicleDetector, VehicleDetector, YoloxVehicleDetector
log = logging.getLogger("vision")
class Recognizer(Protocol): class Recognizer(Protocol):
@@ -165,10 +170,70 @@ class FastAlprRecognizer:
) )
class WithVehicle:
"""Composition: any plate recognizer + the vehicle stage. Runs the plate stage first
(its box picks WHICH vehicle), then fills `vehicle`. A failing vehicle stage is
logged into `error` and yields null — it must never cost the plate read."""
def __init__(self, inner: Recognizer, detector: VehicleDetector) -> None:
self._inner = inner
self._detector = detector
self.vehicle_error: str | None = None
@property
def model_version(self) -> str:
return f"{self._inner.model_version}+{self._detector.model_version}"
@property
def ready(self) -> bool:
return bool(self._inner.ready)
@property
def error(self) -> str | None:
inner = getattr(self._inner, "error", None)
det = getattr(self._detector, "error", None) or self.vehicle_error
parts = [p for p in (inner, f"vehicle: {det}" if det else None) if p]
return "; ".join(parts) if parts else None
def analyze(self, image_bytes: bytes) -> AnalyzeResponse:
started = time.perf_counter()
res = self._inner.analyze(image_bytes)
try:
vehicle = self._detector.detect(image_bytes, res.plate.bbox if res.plate else None)
except Exception as exc: # noqa: BLE001 - advisory stage, never fatal
self.vehicle_error = f"{type(exc).__name__}: {exc}"
vehicle = None
took_ms = (time.perf_counter() - started) * 1000.0
return res.model_copy(
update={"vehicle": vehicle, "model_version": self.model_version, "took_ms": took_ms}
)
def build_recognizer(settings: Settings) -> Recognizer: def build_recognizer(settings: Settings) -> Recognizer:
"""Factory: pick the recognizer from settings. Falls back to the stub if the real """Factory: pick the recognizer from settings. Falls back to the stub if the real
one can't load, so the service always comes up (with ready=False surfaced).""" one can't load, so the service always comes up (with ready=False surfaced). The
vehicle stage wraps whichever recognizer runs when a model path is configured."""
rec: Recognizer
if settings.recognizer == "fast_alpr": if settings.recognizer == "fast_alpr":
rec = FastAlprRecognizer(settings) rec = FastAlprRecognizer(settings)
else:
rec = StubRecognizer(settings)
if settings.vehicle_model_path:
detector: VehicleDetector = YoloxVehicleDetector(
settings.vehicle_model_path,
input_size=settings.vehicle_input_size,
min_confidence=settings.vehicle_min_confidence,
)
if settings.vehicle_classifier_path:
if Path(settings.vehicle_classifier_path).is_file():
detector = RefinedVehicleDetector(
detector,
BodyTypeClassifier(
settings.vehicle_classifier_path,
min_confidence=settings.vehicle_classifier_min_confidence,
),
)
else:
log.info("no body-type classifier at %s — phase B off", settings.vehicle_classifier_path)
return WithVehicle(rec, detector)
return rec return rec
return StubRecognizer(settings)
+14 -1
View File
@@ -31,10 +31,23 @@ class PlateResult(BaseModel):
class VehicleResult(BaseModel): class VehicleResult(BaseModel):
"""Job 2 — vehicle attributes / fingerprint (anti-spoofing). Not yet produced.""" """Job 2 — vehicle attributes. `body_type` is ADVISORY: the Node server records it
beside the plate and the Car Wash desk pre-selects the site category it maps to; the
operator decides, a disagreement is flagged, nothing is ever gated on it. Values come
from the shared vocabulary (car, sedan, hatchback, suv, minivan, pickup, van, truck,
bus, motorcycle) — anything else is ignored by Node. Phase A (a COCO detector) emits
car/truck/bus/motorcycle; the finer classes need the body-type classifier. Not yet
produced by any bundled recognizer."""
colour: str | None = None colour: str | None = None
body_type: str | None = None body_type: str | None = None
# Confidence of `body_type` (0–1). Node compares it to the site's threshold.
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
# The vehicle's box in frame pixels — the crop a reviewer sees / a classifier eats.
bbox: BBox | None = None
# Phase B: the detector's coarse class when the body-type classifier ran on this crop
# (body_type is then the classifier's answer if confident, else the detector's).
detector_class: str | None = None
make: str | None = None make: str | None = None
model: str | None = None model: str | None = None
+18
View File
@@ -32,6 +32,24 @@ class Settings(BaseSettings):
# Node side can fall back to the ticket path rather than trust it. # Node side can fall back to the ticket path rather than trust it.
min_confidence: float = 0.5 min_confidence: float = 0.5
# Vehicle stage (phase A — venue-modules.md §Vehicle category from vision): a YOLOX
# ONNX graph (Apache-2.0) run beside the plate recognizer. Unset = stage off (the
# response's `vehicle` stays null). Bake the file into the image (models/), never a
# path an operator can write (vision-service-hardening.md).
vehicle_model_path: str | None = None
vehicle_input_size: int = 640
# Detection score floor for a vehicle box to count at all (the Node side applies the
# site's own, stricter threshold before it FLAGS anything).
vehicle_min_confidence: float = 0.4
# Phase B — the body-type classifier on the detector's crop (bodytype.onnx + its .json
# sidecar, produced by apps/trainer, baked into the image when
# models/bodytype.version pins a published version). Path set but NO file = the normal
# state before the first model ships: the stage is simply off (logged, not an error).
vehicle_classifier_path: str | None = None
# Below this probability the classifier's answer is dropped and the detector's stands.
vehicle_classifier_min_confidence: float = 0.6
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings() return Settings()
+421
View File
@@ -0,0 +1,421 @@
"""Vehicle stage: a COCO object detector beside the plate recognizer (Job 2, phase A).
Answers "what KIND of vehicle is in this entry frame?" for the Car Wash desk's category
suggestion (wiki/decisions/venue-modules.md §Vehicle category from vision). ADVISORY by
design: the Node server records it next to the plate, the desk pre-selects the site
category it maps to, the operator decides, a confident downgrade is flagged. Nothing is
ever gated on it, so a wrong or missing detection costs nothing but a suggestion.
Model: YOLOX (Megvii, Apache-2.0) as an ONNX graph on the ONNX Runtime the plate stage
already uses — the licence rule that keeps Ultralytics (AGPL) out. COCO's vehicle classes
are car / motorcycle / bus / truck: enough to tell a van or a truck from a car, NOT enough
for SUV vs sedan — that is phase B (a body-type classifier on the pilot's own frames).
The detector's vehicle box is also the crop phase B will classify.
Pure numpy/cv2 pre/post-processing, no torch: letterbox to the model's square input
(pad 114, no normalisation — YOLOX's exported graphs take raw 0–255 BGR), decode the
stride grids, class-agnostic NMS, map COCO ids to the shared vocabulary, pick ONE
vehicle: the one whose box holds the plate (when a plate was read), else the largest.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from .schemas import BBox, VehicleResult
# COCO-80 class index → the shared VEHICLE_CLASSES vocabulary (packages/shared).
COCO_VEHICLE_CLASSES: dict[int, str] = {2: "car", 3: "motorcycle", 5: "bus", 7: "truck"}
# YOLOX feature strides; grids are input/stride per level (8400 anchors at 640).
_STRIDES = (8, 16, 32)
@dataclass(frozen=True)
class Detection:
body_type: str
confidence: float
x1: float
y1: float
x2: float
y2: float
@property
def area(self) -> float:
return max(0.0, self.x2 - self.x1) * max(0.0, self.y2 - self.y1)
def contains(self, x: float, y: float) -> bool:
return self.x1 <= x <= self.x2 and self.y1 <= y <= self.y2
class VehicleDetector(Protocol):
"""What the recognizer composition needs: frame bytes (+ the plate box) → a class."""
@property
def model_version(self) -> str: ...
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None: ...
# ----------------------------------------------------------------------------------
# Pre/post-processing (pure functions — unit-tested on synthetic tensors)
# ----------------------------------------------------------------------------------
def letterbox(frame: Any, size: int) -> tuple[Any, float]:
"""Resize keeping aspect, pad bottom/right with 114 to size×size. Returns the CHW
float32 tensor (batch dim added) and the scale to map boxes back."""
import cv2
import numpy as np
h, w = frame.shape[:2]
r = min(size / h, size / w)
nh, nw = int(round(h * r)), int(round(w * r))
resized = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_LINEAR)
padded = np.full((size, size, 3), 114, dtype=np.uint8)
padded[:nh, :nw] = resized
tensor = padded.transpose(2, 0, 1)[None].astype(np.float32)
return np.ascontiguousarray(tensor), r
def decode(raw: Any, size: int) -> Any:
"""YOLOX raw output [N, 5+classes] (batch squeezed) → same shape with xywh decoded
into pixel units of the letterboxed input. Rows are ordered stride 8, 16, 32."""
import numpy as np
out = raw.astype(np.float32).copy()
grids = []
strides = []
for s in _STRIDES:
n = size // s
ys, xs = np.meshgrid(np.arange(n), np.arange(n), indexing="ij")
grids.append(np.stack((xs, ys), axis=-1).reshape(-1, 2))
strides.append(np.full((n * n, 1), s, dtype=np.float32))
grid = np.concatenate(grids, axis=0).astype(np.float32)
stride = np.concatenate(strides, axis=0)
if out.shape[0] != grid.shape[0]:
raise ValueError(f"unexpected output rows {out.shape[0]} for input {size} (want {grid.shape[0]})")
out[:, :2] = (out[:, :2] + grid) * stride
out[:, 2:4] = np.exp(out[:, 2:4]) * stride
return out
def nms(boxes: Any, scores: Any, iou_threshold: float) -> list[int]:
"""Greedy class-agnostic non-max suppression over xyxy boxes; returns kept indices."""
import numpy as np
if len(boxes) == 0:
return []
order = scores.argsort()[::-1]
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
areas = np.clip(x2 - x1, 0, None) * np.clip(y2 - y1, 0, None)
keep: list[int] = []
while order.size > 0:
i = int(order[0])
keep.append(i)
if order.size == 1:
break
rest = order[1:]
xx1 = np.maximum(x1[i], x1[rest])
yy1 = np.maximum(y1[i], y1[rest])
xx2 = np.minimum(x2[i], x2[rest])
yy2 = np.minimum(y2[i], y2[rest])
inter = np.clip(xx2 - xx1, 0, None) * np.clip(yy2 - yy1, 0, None)
iou = inter / (areas[i] + areas[rest] - inter + 1e-9)
order = rest[iou <= iou_threshold]
return keep
def vehicles_from_output(
raw: Any, size: int, scale: float, min_confidence: float, iou_threshold: float = 0.45
) -> list[Detection]:
"""Full post-processing: decode → vehicle classes only → confidence floor → NMS →
boxes in ORIGINAL frame pixels."""
import numpy as np
dec = decode(raw, size)
cls_scores = dec[:, 5:]
cls_idx = cls_scores.argmax(axis=1)
score = dec[:, 4] * cls_scores[np.arange(len(dec)), cls_idx]
wanted = np.isin(cls_idx, list(COCO_VEHICLE_CLASSES)) & (score >= min_confidence)
if not wanted.any():
return []
d = dec[wanted]
s = score[wanted]
c = cls_idx[wanted]
boxes = np.stack(
(d[:, 0] - d[:, 2] / 2, d[:, 1] - d[:, 3] / 2, d[:, 0] + d[:, 2] / 2, d[:, 1] + d[:, 3] / 2), axis=1
)
keep = nms(boxes, s, iou_threshold)
out: list[Detection] = []
for i in keep:
b = boxes[i] / scale
out.append(
Detection(
body_type=COCO_VEHICLE_CLASSES[int(c[i])],
confidence=float(s[i]),
x1=float(b[0]),
y1=float(b[1]),
x2=float(b[2]),
y2=float(b[3]),
)
)
return out
def pick_vehicle(detections: list[Detection], plate: BBox | None) -> Detection | None:
"""ONE vehicle per frame: the box holding the plate's centre (the car that was read —
a lane frame can show the car behind too), else the largest box (nearest the camera)."""
if not detections:
return None
if plate is not None:
cx = (plate.x1 + plate.x2) / 2
cy = (plate.y1 + plate.y2) / 2
holders = [d for d in detections if d.contains(cx, cy)]
if holders:
return min(holders, key=lambda d: d.area) # the tightest box around the plate
return max(detections, key=lambda d: d.area)
# ----------------------------------------------------------------------------------
# The ONNX Runtime detector
# ----------------------------------------------------------------------------------
class YoloxVehicleDetector:
"""YOLOX ONNX on onnxruntime (CPU). Loads once; a load failure is surfaced through
`error` and the stage simply yields no vehicle (never breaks the plate path)."""
def __init__(self, model_path: str, input_size: int = 640, min_confidence: float = 0.4) -> None:
self._path = Path(model_path)
self._size = input_size
self._min_confidence = min_confidence
self._session = None
self._input_name = "images"
self._error: str | None = None
try:
import onnxruntime as ort
opts = ort.SessionOptions()
opts.intra_op_num_threads = 2 # one frame per entry; leave cores to the lane
self._session = ort.InferenceSession(
str(self._path), sess_options=opts, providers=["CPUExecutionProvider"]
)
self._input_name = self._session.get_inputs()[0].name
except Exception as exc: # noqa: BLE001 - not-ready, never fatal
self._error = f"{type(exc).__name__}: {exc}"
@property
def model_version(self) -> str:
return f"yolox:{self._path.name}@{self._size}"
@property
def ready(self) -> bool:
return self._session is not None
@property
def error(self) -> str | None:
return self._error
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
if self._session is None:
return None
import cv2
import numpy as np
frame = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
if frame is None:
return None
return self.detect_frame(frame, plate)
def detect_frame(self, frame: Any, plate: BBox | None) -> VehicleResult | None:
"""Same as detect() on an already-decoded BGR frame (the classifier stage decodes
once and shares it)."""
if self._session is None:
return None
tensor, scale = letterbox(frame, self._size)
raw = self._session.run(None, {self._input_name: tensor})[0][0]
found = vehicles_from_output(raw, self._size, scale, self._min_confidence)
best = pick_vehicle(found, plate)
if best is None:
return None
h, w = frame.shape[:2]
box = BBox(
x1=max(0, int(best.x1)), y1=max(0, int(best.y1)), x2=min(w, int(best.x2)), y2=min(h, int(best.y2))
)
return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4), bbox=box)
# ----------------------------------------------------------------------------------
# Phase B: the body-type classifier on the detector's crop
# ----------------------------------------------------------------------------------
SIDECAR_FORMAT = "parking-bodytype/1"
def crop_vehicle(frame: Any, box: BBox, plate: BBox | None, margin: float) -> Any:
"""The detector's box + margin, plate blurred — the SAME cut the collector stores
(apps/server review-outbox.ts makeReviewCrop), so the classifier sees at the booth
what it was trained on. Returns a BGR array, or None when the box is degenerate."""
import cv2
h, w = frame.shape[:2]
mw = round((box.x2 - box.x1) * margin)
mh = round((box.y2 - box.y1) * margin)
left, top = max(0, box.x1 - mw), max(0, box.y1 - mh)
right, bottom = min(w, box.x2 + mw), min(h, box.y2 + mh)
if right - left < 8 or bottom - top < 8:
return None
crop = frame[top:bottom, left:right].copy()
if plate is not None:
pad = round(max(plate.x2 - plate.x1, plate.y2 - plate.y1) * 0.25)
pl, pt = max(0, plate.x1 - pad - left), max(0, plate.y1 - pad - top)
pr, pb = min(right - left, plate.x2 + pad - left), min(bottom - top, plate.y2 + pad - top)
if pr - pl >= 2 and pb - pt >= 2:
sigma = max(6, round((pr - pl) / 6))
crop[pt:pb, pl:pr] = cv2.GaussianBlur(crop[pt:pb, pl:pr], (0, 0), sigma)
return crop
class BodyTypeClassifier:
"""`bodytype.onnx` + its `bodytype.json` sidecar (written by apps/trainer). The sidecar
carries the preprocessing contract — class list, input size, crop margin — and the graph
normalises internally, so this side only cuts, resizes (INTER_AREA, like the trainer)
and feeds raw RGB 0–255. Load failure → `error`, the stage yields nothing."""
def __init__(self, model_path: str, min_confidence: float = 0.6) -> None:
import json
self._path = Path(model_path)
self.min_confidence = min_confidence
self._session = None
self._input_name = "image"
self._error: str | None = None
self.classes: list[str] = []
self.version = "?"
self.input_size = 224
self.crop_margin = 0.08
try:
side = json.loads(self._path.with_suffix(".json").read_text())
if side.get("format") != SIDECAR_FORMAT:
raise ValueError(f"unknown sidecar format {side.get('format')!r}")
self.classes = [str(c) for c in side["classes"]]
self.version = str(side.get("version", "?"))
self.input_size = int(side.get("input_size", 224))
self.crop_margin = float(side.get("crop_margin", 0.08))
import onnxruntime as ort
opts = ort.SessionOptions()
opts.intra_op_num_threads = 2
self._session = ort.InferenceSession(
str(self._path), sess_options=opts, providers=["CPUExecutionProvider"]
)
self._input_name = self._session.get_inputs()[0].name
except Exception as exc: # noqa: BLE001 - not-ready, never fatal
self._error = f"{type(exc).__name__}: {exc}"
@property
def model_version(self) -> str:
return f"bodytype:{self.version}"
@property
def ready(self) -> bool:
return self._session is not None
@property
def error(self) -> str | None:
return self._error
def classify(self, frame: Any, box: BBox, plate: BBox | None) -> tuple[str, float] | None:
"""(class, probability) for the vehicle in `box`, or None when nothing could be cut."""
if self._session is None:
return None
import cv2
import numpy as np
crop = crop_vehicle(frame, box, plate, self.crop_margin)
if crop is None:
return None
resized = cv2.resize(crop, (self.input_size, self.input_size), interpolation=cv2.INTER_AREA)
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
x = np.ascontiguousarray(rgb.transpose(2, 0, 1)[None].astype(np.float32))
logits = self._session.run(None, {self._input_name: x})[0][0]
z = logits - logits.max()
p = np.exp(z) / np.exp(z).sum()
i = int(p.argmax())
return self.classes[i], float(p[i])
class RefinedVehicleDetector:
"""Detector + classifier. The detector finds the vehicle (and picks WHICH one); when its
class is `car` — or one the classifier was trained on — the classifier's answer replaces
it if confident enough, else the detector's stands. A truck or bus the classifier has
never seen is left alone: its softmax on an unknown thing means nothing."""
def __init__(self, detector: Any, classifier: BodyTypeClassifier) -> None:
self._detector = detector
self._classifier = classifier
self.stage_error: str | None = None
@property
def model_version(self) -> str:
return f"{self._detector.model_version}+{self._classifier.model_version}"
@property
def ready(self) -> bool:
return bool(getattr(self._detector, "ready", True))
@property
def error(self) -> str | None:
parts = [
getattr(self._detector, "error", None),
f"classifier: {self._classifier.error}" if self._classifier.error else None,
f"classifier: {self.stage_error}" if self.stage_error else None,
]
kept = [p for p in parts if p]
return "; ".join(kept) if kept else None
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
import cv2
import numpy as np
frame = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
if frame is None:
return None
detect_frame = getattr(self._detector, "detect_frame", None)
base: VehicleResult | None = (
detect_frame(frame, plate) if detect_frame else self._detector.detect(image_bytes, plate)
)
if base is None or base.bbox is None or not self._classifier.ready:
return base
if not (base.body_type == "car" or base.body_type in self._classifier.classes):
return base
try:
out = self._classifier.classify(frame, base.bbox, plate)
except Exception as exc: # noqa: BLE001 - advisory stage, never fatal
self.stage_error = f"{type(exc).__name__}: {exc}"
return base
if out is None:
return base
body_type, confidence = out
if confidence < self._classifier.min_confidence:
return base.model_copy(update={"detector_class": base.body_type})
return base.model_copy(
update={
"body_type": body_type,
"confidence": round(confidence, 4),
"detector_class": base.body_type,
}
)
def time_detect(
detector: VehicleDetector, image_bytes: bytes, plate: BBox | None
) -> tuple[VehicleResult | None, float]:
"""detect() with wall time in ms (for logs/benchmarks)."""
started = time.perf_counter()
result = detector.detect(image_bytes, plate)
return result, (time.perf_counter() - started) * 1000.0
+4
View File
@@ -18,8 +18,12 @@
"@radix-ui/react-tabs": "^1.1.15", "@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16", "@tanstack/react-router": "^1.170.16",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-http": "^2.5.2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-websocket": "^2.3.0",
"i18next": "^26.3.1", "i18next": "^26.3.1",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
+38
View File
@@ -3,27 +3,52 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router"; import { RouterProvider } from "@tanstack/react-router";
import { fetchMe, type SessionUser } from "./api.js"; import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js"; import { Login } from "./Login.js";
import { ConnectScreen } from "./ConnectScreen.js";
import { queryClient } from "./lib/query.js"; import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js"; import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js"; import { applyTheme, applyFontScale } from "./lib/theme.js";
import { router } from "./router.js"; import { router } from "./router.js";
import { initApiBase, inTauri } from "./lib/origin.js";
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands // App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
// off to TanStack Router inside the QueryClient provider. The router renders the // off to TanStack Router inside the QueryClient provider. The router renders the
// terminal chrome + screens; auth gating stays here (Login until signed in), and // terminal chrome + screens; auth gating stays here (Login until signed in), and
// the signed-in user flows into the router context for role-based route guards. // the signed-in user flows into the router context for role-based route guards.
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md. // See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
//
// Desktop shell only: BEFORE any of that, the backend origin itself must be
// known — the same installer is used at every booth (see lib/origin.ts /
// backend-config.ts), so on first launch (or after the operator clears it)
// there is no server to call fetchMe() against yet. ConnectScreen gates that;
// a browser build always has a same-origin backend, so `needsConnect` is
// always false there and this is skipped entirely.
export function App() { export function App() {
const [user, setUser] = useState<SessionUser | null>(null); const [user, setUser] = useState<SessionUser | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [needsConnect, setNeedsConnect] = useState(false);
useEffect(() => { useEffect(() => {
initApiBase().then((saved) => {
if (inTauri() && !saved) {
setNeedsConnect(true);
setLoading(false);
return;
}
fetchMe() fetchMe()
.then(setUser) .then(setUser)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
});
}, []); }, []);
// Route-context consumers (RootLayout's nav, route beforeLoad guards) only re-read
// the router context on navigation — NOT when this `user` state changes. So after
// any session refresh (login, profile edit, a venue-module flip in Setup → Site)
// re-validate the current matches once React has committed the new context.
useEffect(() => {
if (user) void router.invalidate();
}, [user]);
// Apply the signed-in user's preferred language + theme + font scale whenever they // Apply the signed-in user's preferred language + theme + font scale whenever they
// resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults // resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
// before auth resolves; on logout, fall back so the Login screen is consistent. // before auth resolves; on logout, fall back so the Login screen is consistent.
@@ -41,6 +66,19 @@ export function App() {
if (loading) { if (loading) {
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>; return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
} }
if (needsConnect) {
return (
<ConnectScreen
onConnected={() => {
setNeedsConnect(false);
setLoading(true);
fetchMe()
.then(setUser)
.finally(() => setLoading(false));
}}
/>
);
}
if (!user) { if (!user) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+17
View File
@@ -407,6 +407,23 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</div> </div>
)} )}
{/* Module charges folded into the settlement (e.g. a car wash ordered
with "pay at booth") — one "+" line each; the Total below includes
them. See wiki/decisions/venue-modules.md. */}
{!isSubscription &&
(s.chargeLines ?? []).length > 0 &&
s.currency != null && (
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
<div className="text-term-muted">{t("booth.charges")}</div>
{(s.chargeLines ?? []).map((c, i) => (
<div key={i} className="flex justify-between text-term-text">
<span>{c.label}</span>
<span className="tabular-nums">+{formatMoney(c.amountMinor, s.currency!)}</span>
</div>
))}
</div>
)}
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an {/* Total — a subscription is prepaid (no amount) UNLESS it owes an
out-of-window window charge; then show that amount. For an overstay the out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */} amount is the TOP-UP delta, not the whole stay. */}
+6 -3
View File
@@ -13,6 +13,7 @@ import { BoothPayModal } from "./BoothPayModal.js";
import { ActiveSessions } from "./ActiveSessions.js"; import { ActiveSessions } from "./ActiveSessions.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
import { EventDetailModal, EventRow } from "./ui/event-detail.js"; import { EventDetailModal, EventRow } from "./ui/event-detail.js";
import { tillOfEvent } from "@parking/shared";
// The live operator booth view — the real-time heart of the console. Occupancy // The live operator booth view — the real-time heart of the console. Occupancy
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and // gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
@@ -246,7 +247,7 @@ export function BoothScreen() {
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy }); const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({ const eventsQuery = useQuery({
queryKey: [...qk.events, shiftStart ?? "none"], queryKey: [...qk.events, shiftStart ?? "none"],
queryFn: () => fetchEvents(100, shiftStart ?? undefined), queryFn: () => fetchEvents(100, shiftStart ?? undefined, undefined, "booth"),
enabled: shiftOpen, enabled: shiftOpen,
}); });
@@ -275,13 +276,15 @@ export function BoothScreen() {
// Merge: live events first (newest), then the queried history, de-duped by id — // Merge: live events first (newest), then the queried history, de-duped by id —
// then clip to the current shift window (the live store spans shifts; the feed // then clip to the current shift window (the live store spans shifts; the feed
// must not show events from before this shift's start). No shift → no feed. // must not show events from before this shift's start) and to the BOOTH till (the
// socket also pushes wash-desk events to anyone with carwash:read; they are the wash
// shift's activity, not this one's — tillOfEvent). No shift → no feed.
const seen = new Set(liveFeed.map((e) => e.id)); const seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id)); const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const merged = [...liveFeed, ...history].slice(0, 200); const merged = [...liveFeed, ...history].slice(0, 200);
const scoped = const scoped =
shiftOpen && shiftStart shiftOpen && shiftStart
? merged.filter((e) => e.occurredAt >= shiftStart) ? merged.filter((e) => e.occurredAt >= shiftStart && tillOfEvent(e.type, e.payload) === "booth")
: []; : [];
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`) // Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
+116
View File
@@ -0,0 +1,116 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { setApiBase } from "./lib/origin.js";
// Desktop-only gate shown BEFORE Login whenever no backend has been
// configured yet (first launch of a generic .deb/.AppImage install, or after
// the operator clears it from Settings). Same installer works at any booth —
// see backend-config.ts for why this can't be a build-time value.
//
// backend-config.ts is imported dynamically (not at module top-level) purely
// to keep bundling consistent with origin.ts/router.tsx's other Tauri-only
// imports — this component itself only ever renders inside Tauri anyway, so
// it's not a functional requirement, just avoids an INEFFECTIVE_DYNAMIC_IMPORT
// warning from Vite (a static import here would defeat those other dynamic
// imports' chunk-splitting intent).
function normalizeHost(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return trimmed;
return /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
}
export function ConnectScreen({ onConnected }: { onConnected: () => void }) {
const { t } = useTranslation();
const [host, setHost] = useState("");
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<"ok" | "unreachable" | "bad_response" | null>(null);
const [detail, setDetail] = useState<string | undefined>(undefined);
const url = normalizeHost(host);
const canSubmit = url.length > 0 && !testing && !saving;
async function handleTest(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setTesting(true);
setResult(null);
setDetail(undefined);
try {
const { testBackendUrl } = await import("./lib/backend-config.js");
const check = await testBackendUrl(url);
setResult(check.ok ? "ok" : (check.reason ?? "unreachable"));
setDetail(check.detail);
} finally {
setTesting(false);
}
}
async function handleSave() {
setSaving(true);
try {
const { saveBackendUrl } = await import("./lib/backend-config.js");
await saveBackendUrl(url);
setApiBase(url);
onConnected();
} finally {
setSaving(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
<form onSubmit={handleTest} className="card w-full max-w-sm p-6">
<h1 className="mb-1 text-h5 font-semibold uppercase tracking-widest text-term-amber">
{t("connect.title")}
</h1>
<p className="mb-5 text-[0.75rem] text-term-muted">{t("connect.hint")}</p>
<div className="field mb-3">
<label className="label">{t("connect.serverAddress")}</label>
<input
className="input"
value={host}
onChange={(e) => {
setHost(e.target.value);
setResult(null);
}}
placeholder="192.168.1.50:3000"
autoFocus
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</div>
{result === "ok" && (
<p className="mb-3 text-[0.75rem] text-term-green">{t("connect.testOk")}</p>
)}
{result === "unreachable" && (
<p className="mb-3 text-[0.75rem] text-term-red">
{t("connect.testUnreachable")}
{detail ? ` (${detail})` : ""}
</p>
)}
{result === "bad_response" && (
<p className="mb-3 text-[0.75rem] text-term-red">{t("connect.testBadResponse")}</p>
)}
<div className="flex gap-2">
<button type="submit" className="btn flex-1" disabled={!canSubmit}>
{testing ? t("connect.testing") : t("connect.test")}
</button>
<button
type="button"
className="btn btn-primary flex-1"
disabled={!canSubmit || result !== "ok"}
onClick={handleSave}
>
{saving ? t("connect.saving") : t("connect.save")}
</button>
</div>
</form>
</main>
);
}
+55 -27
View File
@@ -7,24 +7,30 @@ import {
fetchEvents, fetchEvents,
fetchShift, fetchShift,
fetchShiftReport, fetchShiftReport,
fetchShiftTills,
fetchShifts, fetchShifts,
recordDrawerMovement, recordDrawerMovement,
reviewDrawerMovement, reviewDrawerMovement,
type DrawerMovement, type DrawerMovement,
type MovementStatus, type MovementStatus,
type SessionUser,
type ShiftSummary, type ShiftSummary,
type TillId,
} from "./api.js"; } from "./api.js";
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js"; import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { shiftKey } from "./lib/use-shift.js";
import { Panel } from "./ui/Panel.js"; import { Panel } from "./ui/Panel.js";
import type { LedgerEvent } from "@parking/shared"; import { tillGuards, tillOf, type LedgerEvent } from "@parking/shared";
import { can } from "./api.js";
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen // The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
// answers "what's in the till and why": the CURRENT drawer balance with the open // answers "what's in the till and why": the CURRENT drawer balance with the open
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash // shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
// activity (every cash payment and voucher, live), the movement record/review flow // activity (every cash payment and voucher, live), the movement record/review flow
// (unchanged), and the closed-shift drawer history. All figures come from the signed // (unchanged), and the closed-shift drawer history. All figures come from the signed
// chain — the drawer is a single site-wide till that carries across shifts. See // chain. TILLS (2026-09-05): there is one drawer PER TILL (booth, wash desk); the hub
// wiki/concepts/shift.md. // shows one till at a time — a switch appears when the site has more than one — and
// every panel below is scoped to it. See wiki/concepts/shift.md "Tills".
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? ""); const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
@@ -50,9 +56,17 @@ function StatusBadge({ status }: { status: MovementStatus }) {
); );
} }
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) { export function DrawerManager({ user, canReview }: { user: SessionUser | null; canReview: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// Which tills this role may READ (each desk's drawer is guarded by that desk's own
// permissions) — the first one is the default view.
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
const tills: TillId[] = status.data?.tills.map((x) => x.till) ?? [];
const [chosen, setChosen] = useState<TillId | null>(null);
const till = chosen && tills.includes(chosen) ? chosen : (tills[0] ?? "booth");
// Recording on a till needs that till's `cash` guard (booth drawer:create, wash carwash:cash).
const canCreate = can(user, tillGuards(till).cash);
const refresh = () => { const refresh = () => {
void qc.invalidateQueries({ queryKey: ["drawer"] }); void qc.invalidateQueries({ queryKey: ["drawer"] });
// A voucher moves the open shift's added/removed figures too (the X-report). // A voucher moves the open shift's added/removed figures too (the X-report).
@@ -61,17 +75,28 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
return ( return (
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden"> <div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
{/* Till switch — only when there is more than one drawer to look at. */}
{tills.length > 1 && (
<div className="flex shrink-0 items-center gap-1.5">
{tills.map((x) => (
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setChosen(x)}>
{t(`till.${x}Long`)}
</button>
))}
</div>
)}
{/* Row 1: the till NOW + the record form. */} {/* Row 1: the till NOW + the record form. */}
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]"> <div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
<StatePanel /> <StatePanel till={till} />
{canCreate && <RecordPanel onDone={refresh} />} {canCreate && <RecordPanel till={till} onDone={refresh} />}
</div> </div>
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */} {/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3"> <div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
<TodayPanel /> <TodayPanel till={till} />
<MovementsPanel canReview={canReview} onChanged={refresh} /> <MovementsPanel till={till} canReview={canReview} onChanged={refresh} />
<ShiftHistoryPanel /> <ShiftHistoryPanel till={till} />
</div> </div>
</div> </div>
); );
@@ -81,13 +106,13 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
// Balance from the chain + the open shift's running X-report breakdown, so the big // Balance from the chain + the open shift's running X-report breakdown, so the big
// number is always explainable: float + cash takings + in − out = expected = balance. // number is always explainable: float + cash takings + in − out = expected = balance.
function StatePanel() { function StatePanel({ till }: { till: TillId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 }); const balance = useQuery({ queryKey: ["drawer", "balance", till], queryFn: () => fetchDrawerBalance(till), refetchInterval: 10_000 });
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift }); const status = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
const report = useQuery({ const report = useQuery({
queryKey: ["shift", "xreport"], queryKey: ["shift", "xreport", till],
queryFn: fetchShiftReport, queryFn: () => fetchShiftReport(till),
enabled: status.data?.open != null, enabled: status.data?.open != null,
refetchInterval: 10_000, refetchInterval: 10_000,
}); });
@@ -149,17 +174,20 @@ function StatePanel() {
// Every drawer-touching event since local midnight: cash payments (the current // Every drawer-touching event since local midnight: cash payments (the current
// shift's incomings, live) + vouchers. Card payments never enter the till. // shift's incomings, live) + vouchers. Card payments never enter the till.
function TodayPanel() { function TodayPanel({ till }: { till: TillId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const q = useQuery({ const q = useQuery({
queryKey: ["drawer", "today"], queryKey: ["drawer", "today", till],
queryFn: () => fetchEvents(1000, startOfToday()), queryFn: () => fetchEvents(1000, startOfToday(), undefined, till),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
// This till's drawer-touching events only (a bay payment is wash-till money; a
// parking payment is booth money — tillOf() is the one shared rule).
const rows = (q.data?.events ?? []).filter((e) => { const rows = (q.data?.events ?? []).filter((e) => {
if (tillOf(e.payload) !== till) return false;
if (e.type === "cash_in" || e.type === "cash_out") return true; if (e.type === "cash_in" || e.type === "cash_out") return true;
if (e.type !== "payment") return false; if (e.type !== "payment" && e.type !== "carwash_payment") return false;
return (e.payload as { tender?: string } | null)?.tender !== "card"; return (e.payload as { tender?: string } | null)?.tender !== "card";
}); });
@@ -171,7 +199,7 @@ function TodayPanel() {
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string }; const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
const amt = pl.amountMinor ?? 0; const amt = pl.amountMinor ?? 0;
if (pl.currency) cur = pl.currency; if (pl.currency) cur = pl.currency;
if (e.type === "payment") { if (e.type === "payment" || e.type === "carwash_payment") {
cashIn += amt; cashIn += amt;
payments++; payments++;
} else { } else {
@@ -225,7 +253,7 @@ function TodayRow({ e }: { e: LedgerEvent }) {
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt); const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
const time = formatClock(e.occurredAt); const time = formatClock(e.occurredAt);
const label = const label =
e.type === "payment" e.type === "payment" || e.type === "carwash_payment"
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}` ? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`; : `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
return ( return (
@@ -243,13 +271,13 @@ function TodayRow({ e }: { e: LedgerEvent }) {
// --- Movements (record + review) — the pre-redesign feature, unchanged ------ // --- Movements (record + review) — the pre-redesign feature, unchanged ------
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) { function MovementsPanel({ till, canReview, onChanged }: { till: TillId; canReview: boolean; onChanged: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Reviewers can filter the list (the pending queue); operators always see their own, all. // Reviewers can filter the list (the pending queue); operators always see their own, all.
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">(""); const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
const q = useQuery({ const q = useQuery({
queryKey: ["drawer", "movements", canReview ? statusFilter : ""], queryKey: ["drawer", "movements", canReview ? statusFilter : "", till],
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined), queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined, till),
}); });
const movements = q.data?.movements ?? []; const movements = q.data?.movements ?? [];
const pendingCount = movements.filter((m) => m.status === "pending").length; const pendingCount = movements.filter((m) => m.status === "pending").length;
@@ -316,9 +344,9 @@ function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChange
// --- Closed shifts, drawer-focused ------------------------------------------- // --- Closed shifts, drawer-focused -------------------------------------------
// Scope follows /api/shifts: operators see their own, admins all. // Scope follows /api/shifts: operators see their own, admins all.
function ShiftHistoryPanel() { function ShiftHistoryPanel({ till }: { till: TillId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() }); const q = useQuery({ queryKey: ["shifts", "drawer-history", till], queryFn: () => fetchShifts({ till }) });
const shifts = (q.data?.shifts ?? []).slice(0, 50); const shifts = (q.data?.shifts ?? []).slice(0, 50);
const showOperator = q.data?.scope === "all"; const showOperator = q.data?.scope === "all";
@@ -377,14 +405,14 @@ function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: b
// --- Record form (unchanged from the pre-redesign feature) ------------------ // --- Record form (unchanged from the pre-redesign feature) ------------------
function RecordPanel({ onDone }: { onDone: () => void }) { function RecordPanel({ till, onDone }: { till: TillId; onDone: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [amount, setAmount] = useState(""); const [amount, setAmount] = useState("");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const record = useMutation({ const record = useMutation({
mutationFn: (type: "cash_in" | "cash_out") => mutationFn: (type: "cash_in" | "cash_out") =>
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }), recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim(), till }),
onSuccess: (r) => { onSuccess: (r) => {
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) }); setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
setAmount(""); setAmount("");
+117 -3
View File
@@ -13,12 +13,25 @@ import {
type SessionUser, type SessionUser,
} from "./api.js"; } from "./api.js";
import { Modal } from "./ui/Modal.js"; import { Modal } from "./ui/Modal.js";
import { MODULES, jobsBehind, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared";
// Role management (admin). Compose a role from the permission grid (a checkbox // Role management (admin). Compose a role from the permission grid (a checkbox
// matrix of resource × action) and name it; users are then assigned a role. The // matrix of resource × action) and name it; users are then assigned a role. The
// built-in `admin` role is shown read-only/locked (it always has every permission // built-in `admin` role is shown read-only/locked (it always has every permission
// and can't be edited or deleted). The server enforces the same. See // and can't be edited or deleted). The server enforces the same. See
// @parking/shared PERMISSIONS. // @parking/shared PERMISSIONS.
//
// JOBS (venue-modules.md §"Permissions matrix", move 2): each EFFECTIVE module brings
// named permission bundles ("Booth operator", "Wash operator", "Merchant") offered as
// one-click chips above the grid — a chip adds/removes its bundle; the grid stays the
// fine-tune + enforcement layer. The editor LINTS the result (warnings, never blocks):
// "mixes desks" (may open more than one till) and "partial job" (holds a module's read
// permission but not the rest of its job — a desk that can look but not act).
//
// A role REMEMBERS the jobs it follows (chips on at save, or bundles fully present). When
// a later release grows a job, the role shows as "behind" it — in the list (with a
// one-click re-apply) and in the editor — instead of silently falling short the way the
// wash operator's price list did (2026-09-06). Every save is signed on the ledger.
/** Group "resource:action" permissions by resource for the grid rows. */ /** Group "resource:action" permissions by resource for the grid rows. */
function groupByResource(perms: Permission[]): Record<string, Permission[]> { function groupByResource(perms: Permission[]): Record<string, Permission[]> {
@@ -75,6 +88,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
<RoleEditor <RoleEditor
role={editing === "new" ? null : editing} role={editing === "new" ? null : editing}
grouped={grouped} grouped={grouped}
effective={(user?.modules ?? []) as ModuleId[]}
onCancel={() => setEditing(null)} onCancel={() => setEditing(null)}
onSubmit={async (v) => { onSubmit={async (v) => {
try { try {
@@ -102,8 +116,17 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
<span className="text-[0.6875rem] text-term-muted"> <span className="text-[0.6875rem] text-term-muted">
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })} {t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
</span> </span>
{behindOf(r).map((b) => (
<span key={b.job} className="rounded-term border border-term-amber/60 px-1.5 py-0.5 text-[0.625rem] text-term-amber" title={b.missing.join(", ")}>
{t("roles.behind", { job: t(`jobs.${b.job}`) })}
</span>
))}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{canUpdate && !r.builtin && behindOf(r).length > 0 && (
<button type="button" className="btn btn-primary btn-sm" title={behindOf(r).flatMap((b) => b.missing).join(", ")}
onClick={() => reapply(r, invalidate, onError)}>{t("roles.reapply")}</button>
)}
{canUpdate && !r.builtin && ( {canUpdate && !r.builtin && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button> <button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
)} )}
@@ -124,23 +147,86 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown)
try { await deleteRole(id); ok(); } catch (e) { onError(e); } try { await deleteRole(id); ok(); } catch (e) { onError(e); }
} }
/** The jobs a role follows that have grown past it (this release's bundles). */
function behindOf(r: ManagedRole): { job: string; missing: Permission[] }[] {
const has = new Set(r.permissions);
return jobsBehind(r.jobs ?? [], (p) => has.has(p));
}
/** Re-apply = add what the followed jobs now carry. Nothing is removed; the save is
* signed like any other role edit. */
async function reapply(r: ManagedRole, ok: () => void, onError: (e: unknown) => void) {
const missing = behindOf(r).flatMap((b) => b.missing);
try { await updateRole(r.id, { permissions: [...new Set([...r.permissions, ...missing])] }); ok(); } catch (e) { onError(e); }
}
/** The jobs the composer offers: every effective module's, in registry order. */
function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] {
return MODULES.filter((m) => effective.includes(m.id)).flatMap((m) => m.jobs.map((job) => ({ module: m.id, job })));
}
/** Composer lints — warnings about what the admin just composed. */
function lintRole(perms: Set<Permission>, jobs: Set<string>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
const has = (p: Permission) => perms.has(p);
const out: { key: string; vars?: Record<string, string> }[] = [];
// Behind a job it follows: the bundle grew (a newer release) past what the role holds.
for (const b of jobsBehind([...jobs], has)) out.push({ key: "roles.lintJobBehind", vars: { job: b.job, missing: b.missing.join(", ") } });
// Mixes desks: may OPEN more than one till.
const workable: TillId[] = tillsFor(effective, has, "shift");
if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } });
// Partial job: holds a module's till-read (or a job's first permission) but not the
// rest of that job's OWN-resource permissions (a booth job also carries core
// permissions a supervisor legitimately leaves out — those don't count).
for (const { module, job } of jobsFor(effective)) {
const m = MODULES.find((x) => x.id === module)!;
const anchor = m.tillGuards?.read ?? job.permissions[0];
if (!anchor || !has(anchor)) continue;
const own = job.permissions.filter((p) => !has(p) && m.resources.some((r) => p.startsWith(`${r}:`)));
if (own.length > 0) out.push({ key: "roles.lintPartialJob", vars: { job: job.id, missing: own.join(", ") } });
}
return out;
}
function RoleEditor({ function RoleEditor({
role, grouped, onCancel, onSubmit, role, grouped, effective, onCancel, onSubmit,
}: { }: {
role: ManagedRole | null; role: ManagedRole | null;
grouped: Record<string, Permission[]>; grouped: Record<string, Permission[]>;
effective: readonly ModuleId[];
onCancel: () => void; onCancel: () => void;
onSubmit: (v: { name: string; permissions: Permission[] }) => void; onSubmit: (v: { name: string; permissions: Permission[]; jobs: string[] }) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(role?.name ?? ""); const [name, setName] = useState(role?.name ?? "");
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? [])); const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? []));
// The jobs this role follows: what was remembered, plus (at save) any bundle that is
// fully present — so a role composed before jobs were remembered picks them up.
const [jobIds, setJobIds] = useState<Set<string>>(new Set(role?.jobs ?? []));
const toggle = (p: Permission) => const toggle = (p: Permission) =>
setPerms((prev) => { setPerms((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.has(p) ? next.delete(p) : next.add(p); next.has(p) ? next.delete(p) : next.add(p);
return next; return next;
}); });
const jobs = useMemo(() => jobsFor(effective), [effective]);
const complete = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
const jobOn = (job: JobPreset) => jobIds.has(job.id) || complete(job);
const toggleJob = (job: JobPreset) => {
const on = jobOn(job);
setJobIds((prev) => {
const next = new Set(prev);
on ? next.delete(job.id) : next.add(job.id);
return next;
});
setPerms((prev) => {
const next = new Set(prev);
if (on) for (const p of job.permissions) next.delete(p);
else for (const p of job.permissions) next.add(p);
return next;
});
};
const lints = useMemo(() => lintRole(perms, jobIds, effective), [perms, jobIds, effective]);
const followed = () => jobs.filter(({ job }) => jobIds.has(job.id) || complete(job)).map(({ job }) => job.id);
const valid = name.trim().length > 0; const valid = name.trim().length > 0;
@@ -151,6 +237,34 @@ function RoleEditor({
<input className="input" value={name} onChange={(e) => setName(e.target.value)} /> <input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div> </div>
{jobs.length > 0 && (
<div className="mb-3">
<div className="label">{t("roles.jobs")}</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{jobs.map(({ module, job }) => (
<button
key={job.id}
type="button"
className={`btn btn-sm ${jobOn(job) ? "btn-primary" : ""}`}
title={job.permissions.join(", ")}
onClick={() => toggleJob(job)}
>
{t(`jobs.${job.id}`)} <span className="opacity-60">· {t(`modules.name.${module}`)}</span>
</button>
))}
</div>
<span className="hint">{t("roles.jobsHint")}</span>
</div>
)}
{lints.length > 0 && (
<div className="mb-3 rounded-term border border-term-amber/60 px-3 py-2 text-[0.75rem] text-term-amber">
{lints.map((l) => (
<div key={l.key + JSON.stringify(l.vars)}>{t(l.key, l.vars)}</div>
))}
</div>
)}
<div className="label">{t("roles.permissions")}</div> <div className="label">{t("roles.permissions")}</div>
<div className="mt-1 grid grid-cols-1 gap-1"> <div className="mt-1 grid grid-cols-1 gap-1">
{Object.entries(grouped).map(([resource, list]) => ( {Object.entries(grouped).map(([resource, list]) => (
@@ -171,7 +285,7 @@ function RoleEditor({
<div className="mt-3 flex justify-end gap-2"> <div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button> <button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}>{t("common.save")}</button> <button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms], jobs: followed() })}>{t("common.save")}</button>
</div> </div>
</div> </div>
); );

Some files were not shown because too many files have changed in this diff Show More