41 Commits

Author SHA1 Message Date
julian 215a3ac405 fix(ci): publish desktop installers via Gitea Release, not upload-artifact
Build desktop / desktop (push) Successful in 4m17s
CI / check (push) Successful in 39s
actions/upload-artifact@v4's backend fails on the Gitea runner (Upload installers
step errored). Mirror release.yml's proven path instead: curl + the built-in token
to the Releases API, into a ROLLING per-branch prerelease (tag desktop-<branch>,
deleted+recreated each push). Installers renamed space-free
(parking-desktop-<branch>-<sha>.{deb,AppImage}). Signed v* releases unchanged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 10:32:29 +02:00
julian e0cfeb5e71 fix(ci): unsigned desktop build must disable updater artifacts
CI / check (push) Successful in 38s
Build desktop / desktop (push) Failing after 3m56s
createUpdaterArtifacts:true (for release.yml's .sig signing) makes `tauri build`
demand TAURI_SIGNING_PRIVATE_KEY and fail without it — even though the .deb/.AppImage
built fine. Override it off for the unsigned per-commit build via
--config '{"bundle":{"createUpdaterArtifacts":false}}'. release.yml keeps signing.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 10:24:47 +02:00
julian 8129b63a8c feat(profile): self-service name/email/password + desktop installers in CI
Build desktop / desktop (push) Failing after 5m2s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s
Self-service profile: any signed-in user edits their OWN fullName/email and
changes their OWN password (proving the current one), without any user:*
permission. New routes PUT /api/auth/profile + /api/auth/password act only on
req.user.sub (cannot touch username/role), CSRF-guarded; SPA screen at /profile
reachable from the header username chip. email added to the session view +
SessionUser. 7 tests (routes/profile.test.ts); 148 server tests green.

Desktop in CI: new .gitea/workflows/build-desktop.yml builds .deb + .AppImage
on every push to dev/main and uploads them as unsigned workflow artifacts
(per-commit test build). Signed/versioned release stays on release.yml (tag v*).

Wiki: local-jwt-auth (self-service routes), desktop-shell-tauri (two-workflow CI
split), log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 10:15:34 +02:00
julian f9bd586265 docs(wiki): session context — first booth go-live (user split, Docker deploy, web access)
appliance-provisioning.md: new §5c (admin/operator OS user split — verified; strip
lxd/lpadmin/docker from the operator) + fleshed-out §6 runtime (resolute codename caveat,
the standalone deploy dir + .env, the deploy commands, seed-admin, healthy-startup signal,
and the web-access gotchas). log.md: the [2026-06-23] go-live entry (CI uv fix, compose env
passthrough, relative /api, Caddy proxy). Container-deployment "Web access" section already
landed last commit.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 19:29:36 +02:00
julian aa546235fb docs(wiki): container-deployment — relative /api + Caddy proxy web-access section
Build & push images / images (push) Successful in 2m40s
CI / check (push) Successful in 34s
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 19:15:44 +02:00
julian c637b2783c feat(deploy): Caddy reverse proxy — clean port-80 URL, server internal
Operators/admins reach the booth at http://<name-or-ip>/ (no :3000). Adds a caddy:2-alpine
proxy to the prod override that reverse-proxies :80 → server:3000 (the /api/ws WebSocket
upgrades pass through natively); the server is now `expose: 3000` (internal, no published
port), vision stays internal. The Caddyfile binds `:80` so it matches ANY hostname/IP —
works for the booth IP, localhost, AND parksystems.msai.al (pointed at the booth via
hosts/DNS on-site; no domain baked into any image). TLS later = swap `:80` for the real
hostname + uncomment :443 → Caddy auto-provisions HTTPS.

Pairs with the relative-/api SPA fix (77b2acb): together verified end-to-end locally —
through Caddy on :80 with Host: parksystems.msai.al, GET / serves the SPA, assets/health
200, and POST /api/auth/login reaches the server (real 401, no CORS/connection error).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 19:15:23 +02:00
julian 77b2acb1ca fix(docker): SPA must use same-origin API base in the server image (CORS)
apps/web/.env.production sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
desktop build (which loads from tauri://localhost and needs an absolute backend
origin). But Vite auto-loads .env.production for ANY `vite build`, so the server
image baked 127.0.0.1:3000 into the browser bundle — loading the UI from a real
host (e.g. http://parksystems.msai.al) then made the browser call 127.0.0.1:3000
cross-origin and fail the Same-Origin Policy on /api/auth/login.

Fix: the server Dockerfile writes apps/web/.env.production.local with an empty
VITE_API_BASE before the web build (.local has higher Vite precedence), so the SPA
served by Fastify stays relative/same-origin (/api/...). The desktop build is
unaffected (it doesn't use this Dockerfile). Verified: 127.0.0.1:3000 no longer in
the built bundle; /api/auth/login is relative.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 18:58:53 +02:00
julian 10923164ad fix(compose): pass COOKIE_SECURE, WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED
The base compose only forwarded DATABASE_URL/VISION_URL/JWT_SECRET, so a booth deploy
was missing the vars that actually make it usable on the plain-HTTP LAN:
- COOKIE_SECURE (default 0) — without it auth cookies are HTTPS-only and operators
  CANNOT log in over http. The #1 booth-deploy footgun.
- WS_ALLOWED_ORIGINS — the live-feed WS rejects the browser Origin without it.
- EVENT_SIGNING_KEY — dedicated ledger key (falls back to JWT_SECRET if empty).
- VISION_ENABLED=1 — the server's ANPR master switch.
All driven from .env; verified via `docker compose config` that the seven vars resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 18:37:52 +02:00
julian 0a22eab4a8 fix(ci): install uv via official script, not astral-sh/setup-uv action
Build & push images / images (push) Successful in 2m49s
CI / check (push) Successful in 35s
The Gitea runner can't reliably resolve the astral-sh/setup-uv@v5 action — the
"Set up uv" step failed (exit 1) in build-images.yml (and the same step exists in
ci.yml). Replace the action with uv's official standalone install script
(`curl -LsSf https://astral.sh/uv/install.sh | sh`) + add $HOME/.local/bin to
$GITHUB_PATH, matching how the rest of the pipeline provisions tools (apt, corepack).
No third-party action dependency. Verified the install method yields a working uv on
a clean HOME. Same fix in both workflows.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 15:59:10 +02:00
julian 9d65099d9b docs(wiki): appliance provisioning runbook — booth unit 1 hardened (LUKS+TPM+SecureBoot+GRUB)
CI / check (push) Successful in 45s
New wiki/decisions/appliance-provisioning.md: the hardware-verified step-by-step for
provisioning a booth PC (Dell OptiPlex 7070, i5-8500, discrete Nuvoton TPM 2.0) from
factory Windows to a hardened Ubuntu 26.04 LTS appliance. Every command was run on the
first real unit (2026-06-23). Captures the firmware-specific gotchas: Ventoy → 0x1A under
Secure Boot (flash ISO directly); the 7070 BIOS can't view db (verify via live USB); the
installer's hardware-backed encryption fails with PCR_UNUSABLE/dbt (use passphrase LUKS +
manual systemd-cryptenroll PCR-7 seal); GRUB password must be edit-only (--unrestricted)
to keep unattended boot.

OS hardening on unit 1 is COMPLETE + verified: LUKS FDE + TPM auto-unlock (PCR 7,
unattended) + Secure Boot (Deployed) + GRUB edit-lock (closes the init=/bin/bash root-shell
hole that PCR-7 sealing does not cover). Resolves the implementation half of
open-questions #12 for unit 1.

Cross-linked from disk-os-hardening; index + log updated. Still TODO on the box: Docker +
run the stack.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 15:53:56 +02:00
julian 8155ff456b feat(deploy): Docker images for server (API+SPA) and vision + branch-aware build pipeline
CI / check (push) Successful in 35s
Build & push images / images (push) Failing after 17s
Containerize the two non-desktop apps for the booth appliance. The desktop app stays
on its own tag-only release.yml.

- apps/server/Dockerfile: multi-stage node:22-alpine. `pnpm deploy --legacy --prod`
  (NOT prune — the monorepo native better-sqlite3 won't resolve under a root prune)
  yields a self-contained bundle; build stage adds node-gyp toolchain, runtime adds
  libstdc++; non-root, healthcheck. Migrates the mounted DB on boot via a drizzle-kit-
  free runtime migrator (packages/db/scripts/migrate-runtime.mjs) — drizzle-kit is a
  devDep, pruned from prod.
- apps/server/src/static-spa.ts: Fastify serves the built React SPA (one container
  serves API + UI). GET-only fallback to index.html, excludes /api + /health so it never
  shadows the backend; a no-op in dev (no dist). Registered last in server.ts.
- apps/vision/Dockerfile: uv base, --extra alpr, model weights PRE-WARMED into the image
  as the runtime user so fast_alpr boots offline (0 downloads at runtime). Engine env-
  selected (VISION_RECOGNIZER stub|fast_alpr).
- Branch-aware: docker-compose.yml (base) + .dev.yml (build local, stub, ports) +
  .prod.yml (pull pinned, fast_alpr, vision internal, restart always); REGISTRY/TAG from
  env so a branch deploy pulls that branch's image.
- .gitea/workflows/build-images.yml: on push to dev/main, run the full turbo build+lint+
  test gate, then buildx push both images to git.infra.msai.al/mca/parking_solution with
  branch + branch-<sha> tags (registry cache; optional Komodo webhook behind KOMODO_ENABLED).
- .dockerignore excludes **/parking.sqlite* so the signed ledger is NEVER baked.

Verified locally (Docker 29): server image migrates + serves API+SPA (/health 200, /
+ /booth HTML, /api/nope JSON 404, no sqlite outside /data); vision image boots fast_alpr
with 0 runtime downloads; compose stack healthy with server→vision over the private network.

Wiki: new container-deployment.md; vision-service-packaging open Qs resolved; index + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 15:07:52 +02:00
julian 492a08a079 fix(ci): lint must depend on ^build (resolve workspace dep types)
CI / check (push) Successful in 36s
`@parking/server#lint` (tsc --noEmit) failed in CI with "Cannot find module
'@parking/db' / '@parking/shared'" + a cascade of implicit-any errors. Root cause:
the root turbo `lint` task had no dependsOn, but those packages expose their types via
"./dist/index.d.ts" — only present after their `build` runs. In a clean CI tree lint
ran before the deps were built, so tsc couldn't resolve them. Locally it passed only
because a prior `dist/` happened to exist. Make `lint` depend on `^build`, exactly
like `typecheck` and `test`. Verified from a fully clean tree (rm dist + .turbo +
*.tsbuildinfo): `turbo run build lint` → 14/14, 0 cached.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 20:17:17 +02:00
julian 8a437d0c4b feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
CI / check (push) Failing after 56s
Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.

Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.

CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.

- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 20:13:21 +02:00
julian 65328b8c11 feat(anpr): subscriber-entry bridge + admin disable toggle
CI / check (push) Failing after 15s
Wire the lane camera's vehicle event into the gated subscription flow: on a
vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh
snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and —
matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read.
The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens
the barrier. A plate is never the sole authority: it routes through the same gate
(active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget,
subscriber-only by construction. Field-verified end to end (plate AA504LX opened the
entry barrier and appended a signed vehicle_entry).

Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site
Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and
lane busy/free are unaffected. Read live per event, so toggling takes effect with no
restart. Migration 0013 (additive ALTER ADD COLUMN, default 1).

- New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9)
- hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3)
- server.ts reorders the read flows above the hik-alarm registration
- snapshot.ts exports buildCamera for reuse
- env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000)
- site route + SiteSettings checkbox + i18n (sq/en parity)
- wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 19:49:18 +02:00
julian 411572511d docs(camera): lane presence + ANPR subscriber-entry bridge design
Captures this session's back-and-forth as a new concept page
[[lane-presence-and-anpr-entry]] and cross-links it:

- BUILT: advisory lane busy/free booth lights (LaneStatus + WS), with the
  measured camera limits behind the 30s timeout (no leave signal; movement-
  driven re-fire; notificationRecurrence locked to "beginning" — ISAPI flip
  silently reverts).
- PLANNED: the ANPR "bridge" — explicitly a small apps/server HANDLER (~40
  lines), NOT a new service/container. On a camera vehicle event: snapshot ->
  ANPR -> high-confidence match -> debounce -> emitRead{kind:"plate"}, then
  the existing subscription match/dispatch/gate admits the subscriber. Both
  directions, opt-in (config.anpr), plate never the sole authority.
- Records the decisions (high confidence floor, debounce-for-correctness)
  and the REJECTED ideas (continuous livestream / per-car queue tracking /
  make-model) with why, plus the open hardware question (booth-PC test).

Updates subscription.md (plate matching is built; the live source is this
bridge) and lpr-camera.md (the two consumers of the vehicle event).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 19:12:49 +02:00
julian a2bdf99db2 fix(lane-status): TTL 5s -> 30s after measuring the real re-fire pattern
Controlled in/out test on the camera: the `active` re-fire rate is
MOVEMENT-driven, not steady — ~1-3s apart while the car moves, but up to
~15-25s when it sits MOTIONLESS in the zone. A 5s TTL would flicker a
parked car free; the TTL must exceed the still-car gap. The camera has
~no dwell lag (goes silent within ~1s of the car leaving — measured: last
event 16:15:17 vs car-left ~16:15:30), so 30s keeps a motionless car busy
while clearing promptly after departure. This also confirms vision-based
tracking isn't warranted: the camera's leave signal is already tight.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 18:16:42 +02:00
julian 89542d4ab6 fix(lane-status): drop busy TTL 90s -> 5s (camera re-fires ~1s)
Measured the real re-fire rate on the camera: while a vehicle is in the
zone it POSTs `active` about every ~1 second (not the ~30-80s I'd guessed).
The camera sends no leave signal, so "free" is timeout-driven — but with a
~1s re-fire, 90s made the lane stay red for a minute and a half after the
car left. 5s of silence reliably means the car is gone; the light now
clears within seconds. Still override-able via LANE_BUSY_TTL_MS.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:58:25 +02:00
julian e0b9442acc feat(booth): live lane busy/free barrier lights from camera vehicle detection
A Hikvision vehicle detection (eventType=VMD, targetType=vehicle) on a
camera bound to entry/exit now marks that lane "busy" and shows it as a
barrier light beside the scan input on the booth (green=free, red=busy).
Advisory only — it gates nothing (never blocks a ticket or opens a barrier).

- Parse eventState (active/inactive) from the Hik payload.
- LaneStatus tracker: a vehicle `active` event marks the camera's bound lane
  busy + arms an auto-clear timer. This camera class sends no leave/`inactive`
  signal, so "free" is timeout-driven (LANE_BUSY_TTL_MS, default 90s; the
  camera re-fires `active` while a car sits there, refreshing the timer). A
  "both"-direction camera marks both lanes.
- Push lane-status over the existing booth WS (+ in the hello snapshot);
  live-store holds { entry, exit }; two BarrierLight icons render it.
- i18n booth.laneEntry/laneExit (sq + en).

Tests: lane-status.test.ts (7 — busy/free, TTL auto-clear, timer re-arm,
no re-emit while busy, both/exit direction, unknown device). server 120/120;
web + server build/lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:42:54 +02:00
julian 6f4e390c05 feat(dev): bind Vite to 0.0.0.0 for LAN access (phone over wifi)
Vite had no host set (localhost only). Bind 0.0.0.0 so the dev booth UI is
reachable from other LAN devices at http://<host-lan-ip>:5173. The SPA
already uses relative paths + the page origin for API and the live WS, so
no app code changes — but loading from a non-localhost origin means the
/api/ws handshake's Origin is the LAN address, which the backend's
WS_ALLOWED_ORIGINS must include (documented in .env.example; the host's own
.env is gitignored).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:34:35 +02:00
julian df6a1ca63a docs(camera): correct the "dead camera" conclusion — root cause was undrawn detection area
The Hik DS-2CD1043G2-LIU was NOT defective. An earlier wiki entry wrongly
concluded it needed RMA (dead event engine) based on a silent alertStream
+ diskfull/EventScribe:except + dead RTC surviving a full factory reset.

Real cause: no detection AREA was drawn on the frame. With no region, the
camera detects nothing -> generates no event -> posts nothing. The instant
an area was drawn, the first vehicle produced a clean POST.

- Flag "draw the detection area" as the FIRST thing to check.
- Document the confirmed real payload: multipart/form-data (MoveDetection.xml),
  EventNotificationAlert with eventType=VMD, eventState=active,
  targetType=vehicle (vehicle/human classified on-device), targetRect bbox.
  Note the dateTime is garbage (dead RTC) -> use our own receive time.
- Reframe the SSH diagnostics: diskfull/EventScribe/RTC are RED HERRINGS,
  not proof of a dead camera; don't escalate to hardware fault while a basic
  config precondition is unmet.
- Append a log correction (append-only) superseding the earlier conclusion.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 16:53:22 +02:00
julian 547061edf9 docs(camera): Hik event-push gotchas + dead-camera diagnostic method
Captures the hard-won findings from the field session: the WSL source-IP
rewrite + skipSourceIpCheck fix, the boolean-as-string setup bug, the
unreliable "Test" button, the latching httpBroken flag, and Notify-
Surveillance-Center vs HTTP-Alarm-Server.

Adds a "diagnose a non-pushing camera from its OWN state" runbook
(alertStream heartbeat silence, SSH showStatus EventScribe:except, dmesg
RTC/UBIFS, netstat outbound watch) and documents the verified-dead
DS-2CD1043G2-LIU unit (defective event engine, survives factory reset ->
RMA), with the pull+vision fallback.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 12:50:44 +02:00
julian b7300ec080 fix(hik-alarm): listen on all methods + skippable source-IP guard (WSL)
Diagnosed why no camera push ever landed: (1) the route only registered
POST/GET, so a probe with another method got a generic 404 the camera
reads as "service available" while our handler never ran; (2) more
fundamentally, WSL mirrored mode REWRITES the inbound source IP to the
host's own address (10.0.10.203), so the camera's real IP (10.0.10.12)
never survives and the source-IP guard rejected every push as a mismatch.

- Register the event route on POST/GET/PUT/PATCH/DELETE/OPTIONS (HEAD comes
  with GET) so ANYTHING hitting the path reaches the handler and is recorded.
- Log + store the HTTP method of each hit; log every hit on arrival, before
  any guard, so even a rejected probe is visible immediately.
- Add per-device skipSourceIpCheck (a Setup checkbox) to bypass the
  source-IP guard where the network rewrites the source (WSL). Digest auth +
  the signed ledger remain the real guards.

Tests: hik-alarm 10 (skip-IP accept + method capture). server green;
web build green (new checkbox renderer + this field).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 11:02:17 +02:00
julian 461275521d fix(setup): render boolean config fields as a checkbox (not a text box)
The generic config-field loop had no boolean branch, so a type:"boolean"
field (e.g. the camera's alarmPushEnabled) fell through to a TEXT input and
saved the STRING "true" instead of a real boolean. Downstream checks use
=== true, so the feature read as disabled even when the admin ticked it.

- Web: render type:"boolean" config fields as a real checkbox; store/merge
  a true/false boolean (and persist false on edit so toggling off sticks);
  normalize a legacy string "true"/"false" on load.
- Server: isOn() coerces the flag when reading config (accepts true/"true"/
  1/"yes"/"on") so an existing row saved as the string "true" still works
  without a re-save, and no other boolean field hits the same trap.

Tests: hik-alarm accepts string "true" for alarmPushEnabled. server
112/112; web typecheck + build green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:39:10 +02:00
julian 3db8f517d3 feat(hik-alarm): record rejected pushes + a read endpoint to see arrivals
Debugging "is the camera event coming or not?" was painful: a rejected
push only logged a warning and recorded nothing, so "no event" was
ambiguous (never sent vs sent-and-refused), and the only durable record
was an unreadable device_events row.

- Record EVERY push, accepted or rejected: accepted -> kind:"alarm",
  rejected -> kind:"alarm-rejected" with the precise reason (unknown
  device / not-hikvision / push-disabled / source-IP mismatch / digest
  fail). The 404 body now also returns the reason.
- New GET /api/devices/hikvision/alarms (device:read): the recent pushes
  newest-first as JSON (accepted+rejected, with ip/reason/eventType/
  target/plate/rawHead) so you can SEE arrivals in the browser instead of
  grepping the dev log or querying SQLite.

Tests: hikvision-alarm.test.ts now 8 (rejection-recorded + read-endpoint
list + gating). server 111/111; build+lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:28:09 +02:00
julian 6133923094 feat(camera): Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware can PUSH events to us: Event -> Smart/VCA with
"Detection Target: Human/Vehicle" + Notify Surveillance Center + Alarm
Settings -> Alarm Server makes the camera HTTP-POST an
EventNotificationAlert on each detection.

- New POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts):
  same machine-push pattern as the Dingtian Input Link — source-IP guarded
  + optional HTTP Digest, not behind the SPA cookie/CSRF.
- Discovery-first / permissive: a wildcard content-type parser accepts ANY
  body as raw bytes (event XML, multipart+JPEG, or JSON — Hik varies by
  firmware), records it verbatim as a kind:"alarm" device_event, and
  best-effort extracts eventType/target/plate/dateTime/channelID for the
  summary + a loud log line. The point is to SEE exactly what a camera
  sends before wiring it further.
- hikvision driver gains alarmPushEnabled + pushUser/pushPassword config and
  pushesToBackend:true (setup offers the backend push IP).
- NOT yet a barrier trigger / DeviceReadEvent — records only. A plate read
  is advisory, never the sole reason a barrier opens; the read-bus/ANPR
  wiring is a deliberate next step once the real payload is known.

Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw
JSON, wrong-IP 404, disabled 404, unknown-device 404). server 109/109;
build+lint 14/14. Wiki: lpr-camera.md + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:02:35 +02:00
julian 7680d9a0ed feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were
hard and unrecoverable. Now they soft-delete into a recycle bin.

Schema (migration 0012): nullable deleted_at + deleted_by on users, roles,
subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified
against a copy of the live DB.

Backend: each resource's DELETE route STAMPS instead of removing; every
catalog list filters deleted_at IS NULL. New recycle-bin module + routes
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a
new recyclebin:read/update/delete permission. A 6-hourly + startup sweep
auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 =
forever).

Invariants: soft-deleted users can't log in (login rejects deleted_at;
no-lockout counts live admins only); a soft-deleted subscription doesn't
open the barrier; plans are versioned so a delete stamps all versions of
the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't
auto-cascade a dangling role (guard resolves missing role to empty perms).
The signed append-only ledger is OUT of scope (no delete path).

Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge +
purge confirm; api client + i18n (sq + en parity).

Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4
integration: delete -> can't-login -> restore -> login, purge, gating,
409 reuse). server 103/103; build+lint+test 19/19.

Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 09:33:54 +02:00
julian 3527f48d76 refactor(reports): top-level /reports section in the header, not a Setup tab
CI / check (push) Failing after 30s
Moves Reports out of the Setup tab bar into a standalone top-level route
(/reports) with its own header nav link, alongside Booth/Shifts/
Subscriptions. Adds a /setup/reports → /reports legacy redirect. Same
report:read gate. Wiki note updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 00:20:30 +02:00
julian 5a5f5c554b feat(reports): admin Reports dashboard — ledger-first charts
Adds an admin Reports screen (/setup/reports, gated report:read) — an
on-demand dashboard over the signed event log.

Server (ledger-first): GET /api/reports/summary?from&to&bucket aggregates
in one call — entry/exit counts + all money summed straight from
ledger_events (same source the shift Z-report reconciles, so totals tie
out to the drawer); revenue split into ticket / subscription-sale /
out-of-window mirrors the Z-report. Duration stats come from the sessions
cache (flagged). All bucketing is in the SITE timezone (siteTz). A .csv
export of the per-bucket series. reports.ts + routes/reports.ts.

Web: Reports.tsx — date-range presets (today/7d/30d/90d), hour/day/month
grain, KPI cards, entry/exit line, revenue bar + cash/card split,
revenue-mix pie, peak-hours histogram, numeric breakdown, subscription
stats. Charts via Recharts (MIT), lazy-loaded into its own chunk
(~111KB gz) so the booth bundle is untouched. New Setup tab + nav + i18n
(sq + en parity). asc() exported from @parking/db; formatMinutes helper.

Tests: reports.test.ts (10) pin the sums, tz bucketing, money split,
duration stats, subscription counts. server 90/90; build+lint 14/14.

Wiki: reporting-analytics.md "Built v1" section + log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 00:16:07 +02:00
julian 742653aefb feat(setup): "Test ANPR" probe on ANPR-enabled cameras
Adds a bottom-of-modal "Test ANPR" button (shown only when a camera's
Plate recognition opt-in is checked) that captures a live snapshot off
the camera and runs it through the vision service, reporting the plate
read + confidence + elapsed time, or which stage failed.

- New POST /api/setup/test-anpr: builds the camera from the unsaved
  config (no DB write/device change, like /test), captures a snapshot,
  runs vision.analyze. Fail-soft like the runtime path (snapshot.ts):
  camera/vision failures are reported results, never a 500.
- Thread the existing VisionClient into setupRoutes; add an isCamera()
  type guard to @parking/devices.
- Web: testAnpr() client + AnprTestResult; button, hint, result line.
- i18n keys in sq + en (Catalog parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 00:02:31 +02:00
julian 66c1291578 docs(deploy): COOKIE_SECURE=0 runbook for the plain-HTTP appliance
Documents the deploy-time requirement that the cookie fail-safe fix (7629d5d)
introduced: the LAN appliance serves the SPA same-origin over plain http, where a
Secure cookie is never sent — so it MUST set COOKIE_SECURE=0 or operators can't log
in. A TLS deploy leaves it unset.

- wiki/concepts/disk-os-hardening.md: new "Deploy-time server configuration (runbook)"
  section listing the security-load-bearing env (JWT_SECRET, EVENT_SIGNING_KEY,
  COOKIE_SECURE=0) with the why + the network-scoped justification.
- wiki/entities/local-jwt-auth.md: corrected the stale "Secure when NODE_ENV=production"
  cookie line to the Secure-by-default / opt-out model.
- wiki/log.md: entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 23:52:21 +02:00
julian 7629d5d7b1 fix(auth): make the Secure cookie flag fail-safe (default on)
secureCookies() keyed off NODE_ENV === "production", so an appliance deployed
without that var silently sent the auth + CSRF cookies WITHOUT the Secure flag —
the review's one Medium finding.

Now Secure is the DEFAULT and you only ever opt OUT: a misconfigured/forgotten env
can only make cookies more restrictive, never drop the flag. Dropped only on a
deliberate COOKIE_SECURE=0/false/no/off (or an explicit NODE_ENV=development as a
dev fallback). The LAN appliance that serves the SPA over plain http sets
COOKIE_SECURE=0 on purpose (a Secure cookie would never be sent over its http origin
and would lock operators out); a TLS deploy leaves it unset and gets Secure.

- auth.test.ts (5): pins the matrix — default Secure, production Secure, dev opt-out,
  COOKIE_SECURE falsey opts out, any other value opts in.
- .env.example documents COOKIE_SECURE (replaces the stale NODE_ENV cookie note).
- dev .env sets COOKIE_SECURE=0 (local http://localhost login keeps working).

server 80/80; build+lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 23:50:40 +02:00
julian 2fb947e908 test(vision): fix stub-mode tests; close the testing-gap wiki note
The two failing apps/vision smoke tests assumed stub mode but the local .env sets
VISION_RECOGNIZER=fast_alpr (real-model work, 2026-06-19), so the app built the real
recognizer: /health reported "fast_alpr" not "stub", and /analyze on garbage bytes
422'd (real decode reject) instead of returning the empty stub contract.

Fix is test isolation: a conftest autouse fixture pins VISION_RECOGNIZER=stub for the
session (an OS env var overrides the .env in pydantic-settings), restoring it after.
vision 7/7.

Updates wiki/concepts/booth-console.md (the "no automated tests" Open note now reflects
the coverage that landed) and appends wiki/log.md.

Full workspace: shared 87, server 75, devices 18, web 17, vision 7 = 204 tests across
8 turbo test tasks, 0 failures; build/lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:25:21 +02:00
julian cae900afd2 test(web): Phase 4 — booth formatters + focus-independent scanner hook
Closes the standing "no automated frontend tests" gap for the pure, testable logic:

- format.test.ts (12): the booth display formatters — formatMoney (minor units →
  currency, malformed-code fallback), formatDuration (m / h+m / 0m / em-dash on
  negative-invalid), formatTime, and formatRelativeDateTime (today/yesterday words +
  catalog month names, no Intl dependence).
- use-scanner.test.ts (5): the 2026-06-21 focus-independent hardware scan — a fast
  burst+Enter on <body> fires onScan; slow human typing (gap > 50ms) does not; paused
  (modal open) no-ops; keystrokes into an editable field are ignored; a lone Enter /
  too-short burst is ignored.

Wires Vitest (jsdom + @testing-library/react) into @parking/web. web 17/17.

Full workspace green: shared 87, devices 18, server 75, web 17 (= 197) + build/lint
14/14. (apps/vision still has its 2 pre-existing failures — next.)

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:23:15 +02:00
julian 7e912e193b test(server): Phase 3 — HTTP route integration (auth + RBAC guards)
Boots the REAL Fastify app over a fresh in-memory DB (buildServer({ db }), driven by
app.inject — no listen) to exercise the security seam end to end:

- routes.test.ts (7): /health open; login rejects bad creds and sets token+csrf
  cookies on good ones; an unauthenticated GET /api/occupancy is 401; a site:read-only
  role GETs occupancy but is 403 on PUT /api/site-config (the permission gate, with a
  valid CSRF so the 403 is the perm check); an admin passes the same PUT; and a mutation
  with the auth cookie but NO csrf header is 403 (double-submit enforced).

Adds seedUser()/login() helpers (real bcrypt + the real /api/auth/login route) and
LOG_LEVEL=silent in the vitest env so asserted 401/403 responses don't flood output.

server 75/75 green (8 suites).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:20:14 +02:00
julian 352c643009 test(devices): Phase 2 — ESC/POS byte stream + printer routing
Pins the device-layer bugs we kept hand-verifying, as pure byte-stream assertions
(no sockets, no hardware):

- printer-escpos.test.ts (12): CP852 codepage select; the ë→0x89 / Ë→0xD3 mapping
  and the em-dash/⚠ ASCII fallbacks (never a stray 0x3f "?"); and the Code128 MODULE
  WIDTH contract — a short ticket id at width 3, but the ~20-char out-of-window
  occurrence id at width 2 so it fits the 80mm head (width 3 overflows ~576 dots and
  the firmware silently aborts the barcode). Plus the QR-and-Code128 dual encoding and
  the Albanian stamp() format.
- printer-routing.test.ts (6): the failover order (booth printer is a fallback for
  entry tickets; a receipt never prints on the outside dispenser), rank-then-id
  tiebreak, and printWithFailover walking the order + NoPrinterAvailableError.

Wires Vitest into @parking/devices. devices 18/18 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:17:51 +02:00
julian 5e9be16f65 test(server): Phase 1 — server-core suites (occupancy, pay, exit, shift)
Completes the anti-fraud/safety core coverage on a fresh in-memory DB:

- occupancy.test.ts (12): the ledger-fold count, the capacity/full gate, and the
  reserved-subscriber-spots model — never double-count a parked subscriber, reserve
  tightens only the TRANSIENT gate.
- pay-station.test.ts (12): quote math against the frozen tariff, the signed-payment
  side effect (+ chain verify), no-session / no-tariff errors, the booth lookup view,
  active-session listing.
- exit-flow.test.ts (9): the GATE — refuse unknown / unpaid / grace-expired (no exit
  signed); a paid-within-grace session signs the exit; the booth transient path has NO
  subscription bypass; a prepaid subscriber leaves via the assist (reopenBarrier) path.
- shift-service.test.ts (14): site-wide single-open invariant, the takings SPLIT by
  source (subscription sales vs out-of-window vs transient tickets), drawer carry-
  forward + cash_in/out vouchers, Z-report sign + listShifts read-back.
- entry-flow.test.ts (5): the exported validateTicketCode Luhn typo-guard. (The
  capacity-gate/print-hold/sign-before-open paths need device fakes — covered in the
  device + route phases.)

Adds test-helpers.ts (real EventLog, silent logger, tariff seeder). server 68/68 green.

Note: apps/vision has 2 PRE-EXISTING failures (test_app.py) — environment drift now
that fast_alpr + the ONNX model are installed (the "stub mode" assertions are stale).
Untouched here; to be fixed in the vision phase.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:15:56 +02:00
julian 0985b86fa7 test(server): add fresh-SQLite test harness + anti-fraud core suites
Foundation for testing every service. Adds @parking/db/testing — createTestDb()
spins a fresh in-memory SQLite and applies the real Drizzle migrations, so server
tests run against the production schema with zero live-DB risk.

Wires Vitest into apps/server (test script + config; test signing keys via env)
and adds the first Phase-1 suites against the anti-fraud core:

- signer.test.ts (10): sign/verify round-trip, tamper + forgery rejection,
  malformed-signature guard, determinism, keyId rotation (buildVerifier).
- event-log.test.ts (12): monotonic index, prevHash linkage, payload-in-signature,
  append serialization, and verifyChain() catching every tamper class — edited
  payload, deleted row (index gap), broken prevHash, unknown keyId — plus
  canonicalize byte-stability.

Also stops *.test.ts leaking into shipped dist/ (tsconfig exclude in server +
shared; shared had been emitting compiled tests all along).

server 22/22, shared 87/87 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 15:20:38 +02:00
julian 3ed785c33e feat(booth): open the pay/exit modal on a hardware scan regardless of focus
A barcode/QR scanner is an HID "keyboard wedge" — it types the id + Enter into
whatever holds focus. Previously that only worked while the ticket <input> was
focused; a scan with focus elsewhere (or nowhere) went nowhere.

New useScanner hook (apps/web/src/lib/use-scanner.ts): a document-level keydown
listener that detects the scanner's FAST keystroke burst ended by Enter and opens
the pay/exit modal via setActiveTicket — regardless of focus. A gap > 50ms resets
the buffer, so human-paced typing with nothing focused never registers as a scan
(min length 3 guards stray Enters). Keystrokes into an input/textarea/select/
contenteditable are ignored, so the manual ticket field still works by hand. The
hook is paused while a modal is already open — a scan must not abandon an
in-progress payment; the operator finishes/closes, then scans the next car.

Verified at runtime (Playwright): a fast burst with focus on BODY opens the modal;
a second scan while the modal is open is ignored; slow (120ms) human typing does
NOT open it; the manual input submit still opens it. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 15:03:01 +02:00
julian 35c10a7310 feat(shifts): /shift→/shifts, clickable activity log (shared event-detail), booth-style full-height layout
Three changes to the shift hub, addressing the report:

1. Route rename /shift → /shifts (matches the plural "Turnet" label and the
   section). /shift and /setup/shifts both redirect to /shifts; the header link
   and the operator-landing fallback point at /shifts.

2. The activity-log rows are now CLICKABLE and open the same read-only
   event-detail modal the booth live feed uses (full signed payload + entry/exit
   snapshots + chain provenance) — previously they were static rows. Extracted
   EVENT_STYLE, the feed row, the detail modal, and their helpers out of
   BoothScreen into a shared apps/web/src/ui/event-detail.tsx imported by both the
   booth and the shift log, so the two render and behave identically and can't
   drift.

3. Reworked the /shifts layout to fill the viewport like /booth: a fixed
   title + filters, then a two-pane area (shift list | activity log) where each
   pane scrolls independently (min-h-0/flex-1 + overflow-y-auto) instead of the
   whole page growing. ShiftActivityLog is now a flex column with a fixed header
   and a scrollable list.

Verified at runtime (Playwright): /shift redirects to /shifts, an activity row
opens the detail modal, the layout fills height, and the booth still works (0
console errors after the extraction). build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:58:50 +02:00
julian 2a9e6846a1 fix(nav): header "Turni"→"Turnet" (plural); remove duplicate Setup shifts tab
The header shift link used nav.shift (singular: Turni/Shift) but points at the
/shift HISTORY hub, so it now uses nav.shifts (plural: Turnet/Shifts).

The Setup "Turnet" tab was a duplicate — /setup/shifts and the standalone /shift
both rendered ShiftsHistory. Removed the Setup tab + its child route; /setup/shifts
redirects to /shift for old bookmarks, and the operator-landing fallback (a
shift:read user opening /setup) now points at /shift. The orphaned nav.shift key is
left in both catalogs (harmless).

Verified at runtime (Playwright): header reads Kabina·Turnet·Abonimet·Konfigurimi,
Setup no longer lists Turnet, /setup/shifts redirects to /shift. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:47:00 +02:00
julian 051b440627 feat(nav): promote Subscriptions to a top-level section with its own tabs
Subscriptions, Plans, and Tariff Lab were tabs under /setup. Moved them into a
standalone /subscriptions section with its own header nav entry (between Turni and
Konfigurimi) and a tab bar: Abonimet (/subscriptions), Planet
(/subscriptions/plans), Lab Tarife (/subscriptions/tariff-lab).

- New SubscriptionsLayout (tab bar + <Outlet>); the three screens are now its
  child routes at the top level, not under setupRoute.
- Removed Subscriptions/Plans/Tariff-Lab from SetupLayout and SETUP_TABS. Setup
  now holds Devices/Tariff/Site/Users/Roles/Shifts/Logs.
- Header gains the "Abonimet" link, gated on subscription:read OR subscription:plan
  OR tariff:read (shown if the user can reach any sub-tab).
- Tabs are permission-gated; the /subscriptions index redirects a user lacking
  subscription:read to the first sub-tab they can see (or the booth).
- Legacy redirects: /setup/subscriptions → /subscriptions, /setup/plans →
  /subscriptions/plans, /setup/tariff-lab → /subscriptions/tariff-lab. Dropped the
  old /subscriptions → /setup redirect (it's a real route now).
- The Tariff COMPOSER stays in Setup; only the Tariff LAB simulator moved.

Verified at runtime (Playwright): header order Kabina·Turni·Abonimet·Konfigurimi,
the three sub-tabs render, Setup no longer lists them, /setup/subscriptions
redirects cleanly. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:43:58 +02:00
118 changed files with 9395 additions and 500 deletions
+44
View File
@@ -0,0 +1,44 @@
# Build context hygiene for the server + vision images (context = repo root).
# Keep the context small and NEVER bake build artifacts, secrets, or the live DB.
# Node / build outputs (rebuilt inside the image)
**/node_modules/
**/dist/
**/.turbo/
**/*.tsbuildinfo
.turbo/
# Python (vision) — rebuilt by uv inside the image
**/.venv/
**/__pycache__/
**/.mypy_cache/
**/.pytest_cache/
**/.ruff_cache/
# Secrets + local env (the image gets config via runtime env, never baked)
**/.env
**/.env.local
# NEVER bake the live signed-ledger DB (or any of its WAL/SHM/backup variants) into an
# image — it lives on a mounted volume. Match the base file AND every -wal/-shm/.bak-*
# sibling (deploy copies the package dir's files, ignoring .gitignore).
**/*.sqlite
**/*.sqlite-*
**/parking.sqlite*
# Desktop app is built by its own tag-only release.yml, not these images
apps/desktop/
# VCS, logs, caches, editor cruft
.git/
.github/
*.log
**/.DS_Store
.vscode/
.idea/
# Wiki raw sources / large docs (not needed to build)
wiki/raw/
# Plans / scratch
.planning/
+134
View File
@@ -0,0 +1,134 @@
name: Build desktop
# Build the Tauri desktop installers (.deb + .AppImage) on every push to dev/main and
# upload them as workflow ARTIFACTS — a downloadable, per-commit build for testing the
# native shell. This is NOT a release: it's unsigned (no updater key) and creates no Gitea
# Release. Signed, versioned releases stay on release.yml (tag v* → .deb/.rpm/.AppImage +
# latest.json for the auto-updater). See wiki/decisions/desktop-shell-tauri.md.
on:
push:
branches: [dev, main]
paths:
- 'apps/desktop/**'
- 'apps/web/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/build-desktop.yml'
workflow_dispatch:
jobs:
desktop:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install Tauri system deps
# Same set release.yml uses (verified): WebKitGTK 4.1 + libsoup-3 + the GTK/
# appindicator/rsvg stack + AppImage tooling (patchelf, file).
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
file \
build-essential \
curl \
wget
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo + target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
apps/desktop/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build desktop bundle (.deb + .AppImage)
# Unsigned — no TAURI_SIGNING_* here (this is a test artifact, not an updater
# release). The config sets createUpdaterArtifacts:true (release.yml signs them),
# which makes tauri DEMAND the signing key and fail without it — so override it to
# false for this build via --config (a JSON patch merged over tauri.conf.json).
# --bundles restricts to the two installers we ship; tauri builds the web SPA
# first (beforeBuildCommand), so the desktop UI matches.
run: >
pnpm --filter @parking/desktop bundle
--bundles deb,appimage
--config '{"bundle":{"createUpdaterArtifacts":false}}'
- name: Collect installers
id: collect
# Copy out the two installers under SPACE-FREE names (tauri names them
# "Parking System_0.0.0_amd64.deb" — spaces break asset URLs). Short SHA in the
# name so a downloaded file is traceable to its commit.
run: |
set -e
BUNDLE=apps/desktop/src-tauri/target/release/bundle
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
mkdir -p dist
deb=$(find "$BUNDLE/deb" -name '*.deb' | head -1)
app=$(find "$BUNDLE/appimage" -name '*.AppImage' | head -1)
cp "$deb" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.deb"
cp "$app" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.AppImage"
echo "Artifacts:"; ls -la dist/
- name: Publish to a rolling per-branch pre-release
# actions/upload-artifact's backend isn't reliable on this Gitea runner, so we
# publish to a Gitea RELEASE via the API instead (the proven pattern from
# release.yml — built-in token, plain curl). One ROLLING pre-release per branch
# (tag desktop-<branch>): delete + recreate each push so it always holds the
# latest dev/main installer. This is NOT the signed updater release (release.yml,
# tag v*) — it's a prerelease, unsigned, with no latest.json.
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.api_url }}
REPO: ${{ github.repository }}
TAG: desktop-${{ github.ref_name }}
run: |
set -e
auth="Authorization: token ${TOKEN}"
# Drop any existing rolling release for this branch (ignore if absent) so its
# tag + stale assets don't pile up; recreate it fresh below.
OLD=$(curl -sS -H "$auth" "${API}/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -n "$OLD" ]; then
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/releases/${OLD}" || true
# Also delete the tag itself so the recreate points at this commit.
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/git/refs/tags/${TAG}" || true
fi
REL=$(curl -sS -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"Desktop build (${GITHUB_REF_NAME})\",\"body\":\"Unsigned per-commit desktop installers from ${GITHUB_REF_NAME} @ ${GITHUB_SHA}. Rolling — overwritten each push. Not an updater release.\",\"draft\":false,\"prerelease\":true}" \
"${API}/repos/${REPO}/releases")
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
echo "release id: ${REL_ID}"
for f in dist/*; do
name=$(basename "$f")
echo "uploading ${name}"
curl -sS -X POST -H "$auth" -H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
done
echo "done"
+122
View File
@@ -0,0 +1,122 @@
name: Build & push images
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, 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 wiki/decisions/container-deployment.md.
on:
push:
branches: [dev, main]
paths:
- 'apps/server/**'
- 'apps/web/**'
- 'apps/vision/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'docker-compose*.yml'
- '.dockerignore'
- '.gitea/workflows/build-images.yml'
workflow_dispatch:
env:
REGISTRY: git.infra.msai.al/mca/parking_solution
jobs:
images:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set up uv (for @parking/vision checks)
# Install uv via its official standalone script rather than a third-party action —
# the Gitea runner can't reliably resolve astral-sh/setup-uv. uv provisions the
# pinned Python (apps/vision/.python-version) itself. Add it to PATH for later steps.
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Sync vision deps
working-directory: apps/vision
run: uv sync --frozen
# Don't publish a broken image — run the same checks as ci.yml first.
- name: Build + lint + test (Turbo)
run: pnpm turbo run build lint test
- name: Compute tags
id: meta
# BRANCH = the pushed branch (dev|main); SHA = short commit. Two tags per image:
# the moving branch tag + an immutable branch-SHA tag.
run: |
BRANCH="${GITHUB_REF_NAME}"
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.infra.msai.al
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build & push SERVER (API + SPA)
uses: docker/build-push-action@v5
with:
context: .
file: apps/server/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max
- name: Build & push VISION (ANPR)
uses: docker/build-push-action@v5
with:
context: apps/vision
file: apps/vision/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache,mode=max
# 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.
- name: Trigger Komodo redeploy
if: success() && vars.KOMODO_ENABLED == 'true'
env:
URL: ${{ secrets.KOMODO_STACK_WEBHOOK_URL }}
SECRET: ${{ secrets.KOMODO_WEBHOOK_SECRET }}
run: |
body="{\"ref\":\"refs/heads/${GITHUB_REF_NAME}\"}"
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -fsS -X POST \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$sig" \
-d "$body" \
"$URL"
+19 -2
View File
@@ -31,9 +31,26 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Set up uv (Python toolchain for @parking/vision)
# The vision service is a Python package wired into the Turbo graph via a
# package.json shim; its lint/typecheck/test scripts shell to `uv run …`. CI
# has no Python by default, so `uv run` would fail with "uv: not found" and
# break the whole Turbo run. Install uv via its official standalone script
# (the Gitea runner can't reliably resolve astral-sh/setup-uv); uv provisions the
# pinned Python (.python-version) itself. See wiki/decisions/vision-service-packaging.md.
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Sync vision deps
# Light deps + the dev group (ruff/mypy/pytest) only — NOT the optional `alpr`
# extra (heavy onnx/model stack), which isn't needed to lint/typecheck/test.
working-directory: apps/vision
run: uv sync --frozen
- name: Build + lint (Turbo) - name: Build + lint (Turbo)
# Covers tsc typecheck, vite build, and i18n catalog type-parity (a missing # Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
# sq/en key fails the build). 14 tasks across the workspace. # key fails the build), AND the vision service's ruff lint via uv.
run: pnpm turbo run build lint run: pnpm turbo run build lint
- name: Test - name: Test
+13
View File
@@ -0,0 +1,13 @@
# Booth reverse proxy. `:80` matches ANY hostname/IP, so the booth is reachable as
# http://<booth-ip>/, http://localhost/, or http://parksystems.msai.al/ (the name pointed
# at the booth's IP via hosts/DNS on-site) — with no domain baked into any image. The SPA
# uses a relative /api base, so everything (HTTP + the /api/ws WebSocket, which Caddy
# upgrades automatically) just flows through to the server container.
#
# TLS later: replace `:80` with the real hostname (e.g. `parksystems.msai.al`), uncomment
# Caddy's :443 in docker-compose.prod.yml, and Caddy auto-provisions HTTPS. For a private
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
:80 {
encode gzip
reverse_proxy server:3000
}
+22 -2
View File
@@ -20,7 +20,18 @@ EVENT_SIGNING_KEY=
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
# LOG_LEVEL=info # LOG_LEVEL=info
# DATABASE_URL=./parking.sqlite # DATABASE_URL=./parking.sqlite
# NODE_ENV=production # set in prod: makes auth cookies Secure (HTTPS-only) #
# Auth-cookie Secure flag. FAIL-SAFE: cookies are Secure (HTTPS-only) BY DEFAULT —
# you only ever opt OUT, never in. Set COOKIE_SECURE=0 for a plain-HTTP deployment
# (e.g. the LAN appliance serving the SPA same-origin over http, where a Secure
# cookie would never be sent and would lock operators out). Local dev over
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
# COOKIE_SECURE=0
# Recycle bin retention: a soft-deleted user/role/subscription/plan/tariff is auto-purged
# this many days after deletion (a 6-hourly sweep). Default 30. Set 0 to keep deleted
# items forever (manual purge only). See wiki/concepts/soft-delete.md.
# RECYCLE_BIN_RETENTION_DAYS=30
# First admin (seed once): pnpm --filter @parking/server seed-admin # First admin (seed once): pnpm --filter @parking/server seed-admin
# ADMIN_USER=admin # ADMIN_USER=admin
@@ -31,6 +42,9 @@ EVENT_SIGNING_KEY=
# The Tauri DESKTOP shell loads from tauri://localhost (Linux may also send # The Tauri DESKTOP shell loads from tauri://localhost (Linux may also send
# http://tauri.localhost), which is NOT same-origin with the backend — add both # http://tauri.localhost), which is NOT same-origin with the backend — add both
# so the desktop app's live feed connects. See apps/desktop. # so the desktop app's live feed connects. See apps/desktop.
# To open the dev SPA from another LAN device (phone over wifi), Vite must bind
# 0.0.0.0 (vite.config.ts) AND the host's LAN origin must be listed here, e.g.
# http://10.0.10.203:5173 — the WS handshake's Origin is that LAN address.
WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhost WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhost
# Vision / ANPR (optional) ------------------------------------------------- # Vision / ANPR (optional) -------------------------------------------------
@@ -42,4 +56,10 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
# VISION_ENABLED=1 # master switch — nothing runs without it # VISION_ENABLED=1 # master switch — nothing runs without it
# VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT # VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT
# VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane # VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane
# VISION_MIN_CONFIDENCE=0.5 # confidence floor; keep in sync with the service # VISION_MIN_CONFIDENCE=0.5 # advisory confidence floor; keep in sync with the service
#
# ANPR subscriber-entry bridge (anpr-entry.ts): a subscriber's plate, read off a lane
# camera's vehicle detection, admits them through the gated SubscriptionFlow. Opt-in per
# 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)
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
+78
View File
@@ -0,0 +1,78 @@
# syntax=docker/dockerfile:1.7
# Parking SERVER image: Fastify API + the bundled React SPA (one container serves both —
# offline-first single appliance). Build CONTEXT is the REPO ROOT (it's a pnpm/turbo
# monorepo). better-sqlite3 is a native module → build stage needs node-gyp toolchain,
# runtime needs libstdc++. Mirrors the house multi-stage pattern (cf. trm/processor).
# See wiki/decisions/container-deployment.md.
# ---- deps: cache-friendly pnpm fetch (only manifests change the layer) ----
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
# Workspace manifests + lock first, so the fetch layer caches across source edits.
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 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
# ---- build: install (offline from the fetched store) + turbo build everything ----
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
# Force the SPA to use a SAME-ORIGIN (relative) API base for THIS image. Vite auto-loads
# apps/web/.env.production, which sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
# DESKTOP build — but here Fastify serves the SPA same-origin, so an absolute base would
# make the browser hit 127.0.0.1:3000 cross-origin and fail CORS. `.env.production.local`
# has higher precedence than `.env.production`, so this empties it for the server image only.
RUN echo 'VITE_API_BASE=' > apps/web/.env.production.local
# Builds shared/db/devices, the server dist, AND the web SPA dist (apps/web/dist).
RUN pnpm turbo run build --filter=@parking/server --filter=@parking/web
# `pnpm deploy` produces a SELF-CONTAINED prod bundle for the server in /deploy: a hoisted
# node_modules with only @parking/server's prod deps (incl. the workspace packages' built
# dist + their native deps like better-sqlite3 — properly linked, unlike `prune` at root).
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter=@parking/server --legacy deploy --prod /deploy
# The server's own dist + scripts (deploy copies the package's package.json + files, but we
# copy dist explicitly so the layout under /deploy is predictable). The web SPA + db
# migrations are copied in the runtime stage from their build locations.
# ---- runtime: slim, non-root ----
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
RUN addgroup -S app && adduser -S -G app app
# The self-contained deploy bundle: dist/ + a hoisted node_modules carrying the server's
# prod deps AND the workspace packages (@parking/db|devices|shared) with their built dist,
# the drizzle migrations, and the native better-sqlite3 binding. Single COPY — no scattered
# package dirs, no root node_modules.
COPY --from=build --chown=app:app /deploy ./
# The built SPA — served by Fastify static at WEB_DIST_DIR. (Not part of the server's deploy
# bundle, so copied from the web build output.)
COPY --from=build --chown=app:app /app/apps/web/dist ./web/dist
# DB lives on a mounted volume (never in the image). Default points at /data.
ENV DATABASE_URL=/data/parking.sqlite
ENV WEB_DIST_DIR=/app/web/dist
ENV HOST=0.0.0.0
ENV PORT=3000
RUN mkdir -p /data && chown app:app /data
VOLUME ["/data"]
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- "http://localhost:${PORT:-3000}/health" >/dev/null 2>&1 || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Container entrypoint for the parking server. Applies DB migrations against the mounted
# volume (DATABASE_URL), optionally seeds the first admin, then execs the server. Idempotent:
# the runtime migrator (drizzle-orm migrator, no drizzle-kit) only applies pending migrations,
# so a restart is a no-op. See packages/db/scripts/migrate-runtime.mjs.
set -e
echo "[entrypoint] DATABASE_URL=${DATABASE_URL}"
# Apply migrations against the mounted DB file (creates it + the schema on first boot).
# The migrator ships inside the @parking/db package in the deploy bundle's node_modules.
node node_modules/@parking/db/scripts/migrate-runtime.mjs
# Optional first-boot admin seed: set SEED_ADMIN=1 plus ADMIN_USER + ADMIN_PASS (the seed
# script PROMPTS when these are unset, which would hang a container — so require ADMIN_PASS).
# The seed is idempotent: it won't overwrite an existing user unless FORCE=1.
if [ "${SEED_ADMIN}" = "1" ]; then
if [ -z "${ADMIN_PASS}" ]; then
echo "[entrypoint] SEED_ADMIN=1 but ADMIN_PASS is unset — skipping seed (would hang on prompt)"
else
echo "[entrypoint] seeding admin (${ADMIN_USER:-admin})"
node scripts/seed-admin.mjs || echo "[entrypoint] seed-admin skipped/failed (non-fatal)"
fi
fi
echo "[entrypoint] starting server"
exec "$@"
+4 -2
View File
@@ -9,7 +9,8 @@
"start": "node --env-file-if-exists=.env dist/index.js", "start": "node --env-file-if-exists=.env dist/index.js",
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs", "seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
@@ -28,6 +29,7 @@
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"@types/node": "25.9.3", "@types/node": "25.9.3",
"tsx": "4.22.4", "tsx": "4.22.4",
"typescript": "6.0.3" "typescript": "6.0.3",
"vitest": "^4.1.9"
} }
} }
+191
View File
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
import type { VisionClient, VisionResult } from "./vision-client.js";
import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js";
// The ANPR bridge: a camera vehicle detection → (opt-in) snapshot → plate → MATCH a
// subscriber → emit a plate read. We mock the camera build (buildCamera) so no real
// snapshot HTTP is made, and pass fake Vision/Subscription so the test is the bridge's
// own logic only. See anpr-entry.ts.
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
// (no registry, no network). The factory returns a fresh shot each call.
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
vi.mock("./snapshot.js", () => ({
buildCamera: () => ({ captureSnapshot }),
}));
// Import AFTER the mock is registered.
const { AnprBridge } = await import("./anpr-entry.js");
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
captureSnapshot.mockClear();
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
delete process.env.ANPR_DEBOUNCE_MS;
});
afterEach(() => {
vi.restoreAllMocks();
});
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
function seedCamera(opts: { anpr?: boolean } = {}): string {
const controllerId = randomUUID();
db.insert(devices).values({
id: controllerId,
category: "access",
driverId: "dingtian",
config: { host: "10.0.0.5", relays: [{ relay: 1, direction: "entry" }] },
enabled: true,
}).run();
const camId = randomUUID();
db.insert(devices).values({
id: camId,
category: "camera",
driverId: "hikvision",
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
enabled: true,
}).run();
return camId;
}
/** A fake VisionClient: enabled, returning a chosen plate/confidence (or null). */
function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: number } = {}): VisionClient {
const enabled = opts.enabled ?? true;
const result: VisionResult | null =
opts.plate == null
? null
: {
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
plates: [],
lowConfidence: false,
modelVersion: "test",
tookMs: 1,
};
return {
enabled,
analyze: vi.fn(async () => (enabled ? result : null)),
} as unknown as VisionClient;
}
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
}
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
/** Capture read events emitted during `fn` (async). */
async function captureReads(fn: () => Promise<void>): Promise<DeviceReadEvent[]> {
const got: DeviceReadEvent[] = [];
const off = deviceEvents.onRead((e) => got.push(e));
try {
await fn();
} finally {
off();
}
return got;
}
describe("AnprBridge", () => {
it("does nothing for an opt-OUT camera (no anpr flag) — no analyze, no read", async () => {
const cam = seedCamera({ anpr: false });
const vision = fakeVision({ plate: "AA111BB" });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
expect(vision.analyze).not.toHaveBeenCalled();
expect(captureSnapshot).not.toHaveBeenCalled();
});
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ deviceId: cam, value: "AA111BB", kind: "plate", driverId: "hikvision" });
});
it("ignores a plate below the entry confidence floor", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.6 }); // < default 0.85
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
});
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
expect(skips).toHaveLength(1);
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
});
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
await bridge.onVehicleDetected(cam);
await bridge.onVehicleDetected(cam); // within the 12s window → suppressed
});
expect(reads).toHaveLength(1);
expect(captureSnapshot).toHaveBeenCalledTimes(1); // 2nd was gated before the snapshot
});
it("is a no-op (no throw) when vision is disabled or reads nothing", async () => {
const cam = seedCamera({ anpr: true });
const disabled = new AnprBridge(db, fakeVision({ enabled: false, plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
const noPlate = new AnprBridge(db, fakeVision({ plate: undefined }), fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
await disabled.onVehicleDetected(cam);
await noPlate.onVehicleDetected(cam);
});
expect(reads).toEqual([]);
});
it("never throws on an unknown device id", async () => {
const bridge = new AnprBridge(db, fakeVision({ plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
await expect(bridge.onVehicleDetected("nope")).resolves.toBeUndefined();
});
it("does NOTHING when the admin has disabled the bridge (site_config.anprEntryEnabled = false)", async () => {
const cam = seedCamera({ anpr: true });
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: false }).run();
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
// The flag is checked FIRST — no snapshot, no analyze, no match attempt.
expect(captureSnapshot).not.toHaveBeenCalled();
expect(vision.analyze).not.toHaveBeenCalled();
});
it("still emits when the bridge is explicitly enabled (anprEntryEnabled = true)", async () => {
const cam = seedCamera({ anpr: true });
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: true }).run();
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, type DeviceRow } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
import { directionOf, type FlowDirection } from "./device-resolve.js";
import { buildCamera } from "./snapshot.js";
import type { SubscriptionFlow } from "./subscription-flow.js";
import type { VisionClient } from "./vision-client.js";
// The ANPR "bridge": a subscriber's plate, read from the lane camera, admits them through
// the SAME gated SubscriptionFlow a QR/card scan uses. It is the one missing wire between
// the camera's vehicle PUSH (hikvision-alarm.ts) and the read bus — NOT a new service.
//
// On a `vehicle`/`active` event from an OPT-IN camera (config.anpr === true), the bridge:
// pull a fresh snapshot → vision.analyze → entry confidence floor → debounce → MATCH the
// plate to a subscription → emit a DeviceReadEvent{kind:"plate"} ONLY if it matched.
// The existing onRead → ReadDispatcher then re-matches and runs the gated SubscriptionFlow
// (active / window / blocklist / car-count), which signs the entry/exit and opens the relay.
//
// INVARIANTS (see wiki/concepts/lane-presence-and-anpr-entry.md §2, append-only-event-chain.md):
// - Advisory, never sole authority: the bridge only emitRead()s — the signed decision +
// barrier open stay inside the existing flow. A spoofed printed plate is just another
// credential through the same gate.
// - Subscriber-ONLY: it MATCHES before emitting, so a random plate never reaches the
// transient plate-as-ticket exit flow.
// - Fail-soft + fire-and-forget: any snapshot/vision error degrades to the card/QR path;
// never throws into the push handler, never awaited on the camera's 200 response.
// - Opt-in per camera, and debounced (the camera re-fires ~1Hz while a car sits).
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
interface CameraConfig {
readonly anpr?: boolean;
readonly [k: string]: unknown;
}
/** Stricter-than-advisory confidence floor for a BARRIER-driving plate read. A near-miss
* read falls back to the subscriber's card/QR, so we'd rather skip than wrongly admit.
* Distinct from vision-client's advisory VISION_MIN_CONFIDENCE. */
function entryMinConfidence(): number {
const raw = Number(process.env.VISION_ENTRY_MIN_CONFIDENCE ?? 0.85);
return Number.isFinite(raw) && raw > 0 ? raw : 0.85;
}
/** Same plate/camera within this window = ONE credential presentation. The camera re-fires
* ~1Hz while a car is present; emitting every second would drive repeat entries (a fleet
* sub opens a 2nd occurrence) or exit spam. Required for correctness, not CPU. */
function debounceMs(): number {
const raw = Number(process.env.ANPR_DEBOUNCE_MS ?? 12_000);
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
}
export class AnprBridge {
readonly #db: Db;
readonly #vision: VisionClient | null;
readonly #subscription: SubscriptionFlow;
readonly #logger: FastifyBaseLogger;
readonly #entryMinConfidence: number;
readonly #debounceMs: number;
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
readonly #lastFire = new Map<string, number>();
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db;
this.#vision = vision;
this.#subscription = subscription;
this.#logger = logger;
this.#entryMinConfidence = entryMinConfidence();
this.#debounceMs = debounceMs();
}
/**
* A camera reported a vehicle. If the camera opts into ANPR, pull a snapshot, read the
* plate, and — only if it matches a subscription — emit a plate read onto the bus.
* Fire-and-forget; fail-soft. Never throws (the push handler must always 200).
*/
async onVehicleDetected(deviceId: string): Promise<void> {
try {
if (!this.#vision?.enabled) return; // no recognizer configured
// Admin master switch (read LIVE so toggling in Site Settings takes effect with no
// restart). Gates ONLY this barrier-driving bridge — advisory snapshot-ANPR and lane
// busy/free are unaffected. Absent/unreadable config ⇒ enabled (the default).
const site = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (site && site.anprEntryEnabled === false) return;
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row || !row.enabled || row.category !== "camera") return;
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
// snapshot + analyze every second.
if (this.#debounced(deviceId)) return;
this.#stamp(deviceId);
const camera = buildCamera(row);
if (!camera) {
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
return;
}
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
// the gated flow infers the verb from the camera's bound relay direction).
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
const shot = await camera.captureSnapshot({ direction });
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate) return; // nothing read
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
// object with its confidence even when its own lowConfidence flag is set).
if (result.plate.confidence < this.#entryMinConfidence) {
this.#logger.info(
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
);
return;
}
const plate = result.plate.text.trim().toUpperCase();
if (!plate) return;
const e: DeviceReadEvent = {
driverId: row.driverId,
deviceId,
value: plate,
kind: "plate",
at: new Date().toISOString(),
};
// MATCH BEFORE EMIT — subscriber-only. A non-subscriber plate records advisory
// telemetry and stops; it must NEVER reach the transient plate-as-ticket exit flow.
const match = this.#subscription.match(e);
if (!match) {
this.#recordSkip(deviceId, plate, result.plate.confidence);
return;
}
// Plate-level debounce — belt-and-suspenders against a gap that slips the
// camera-level gate re-emitting the SAME plate.
const plateKey = `${deviceId}:${plate}`;
if (this.#debounced(plateKey)) return;
this.#stamp(plateKey);
this.#logger.info(
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
);
deviceEvents.emitRead(e); // → onRead → ReadDispatcher → gated SubscriptionFlow
} catch (err) {
// Fail-soft: an ANPR failure degrades to the subscriber's card/QR, never strands the lane.
this.#logger.warn(`anpr-bridge failed (${deviceId}): ${(err as Error).message}`);
}
}
#debounced(key: string): boolean {
const last = this.#lastFire.get(key);
return last != null && Date.now() - last < this.#debounceMs;
}
#stamp(key: string): void {
this.#lastFire.set(key, Date.now());
}
/** Advisory telemetry: a plate was read at the lane but matched no subscription. Not a
* read on the bus — just a breadcrumb so the operator can see ANPR is working. */
#recordSkip(deviceId: string, plate: string, confidence: number): void {
this.#logger.info(`anpr-bridge: plate '${plate}' matched no subscription — skipped`);
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId,
category: "camera",
kind: "anpr-skip",
detail: { plate, confidence, source: "anpr-bridge", reason: "no subscription match" },
occurredAt: new Date().toISOString(),
})
.run();
} catch (err) {
this.#logger.error(`anpr-bridge skip-record insert failed: ${(err as Error).message}`);
}
}
}
// DeviceRow is re-exported for the test's seed typing convenience.
export type { DeviceRow };
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { secureCookies } from "./auth.js";
// The auth/CSRF cookies' Secure flag must be FAIL-SAFE: Secure by default, dropped only
// on a deliberate opt-out. The old behaviour (Secure iff NODE_ENV==="production") leaked
// cookies over plain HTTP on an appliance that forgot to set NODE_ENV — this pins the
// corrected matrix.
let savedCookieSecure: string | undefined;
let savedNodeEnv: string | undefined;
beforeEach(() => {
savedCookieSecure = process.env.COOKIE_SECURE;
savedNodeEnv = process.env.NODE_ENV;
delete process.env.COOKIE_SECURE;
delete process.env.NODE_ENV;
});
afterEach(() => {
restore("COOKIE_SECURE", savedCookieSecure);
restore("NODE_ENV", savedNodeEnv);
});
function restore(key: string, val: string | undefined) {
if (val === undefined) delete process.env[key];
else process.env[key] = val;
}
describe("secureCookies — fail-safe Secure flag", () => {
it("defaults to Secure when nothing is set (the appliance-forgot-NODE_ENV case)", () => {
expect(secureCookies()).toBe(true);
});
it("stays Secure in production", () => {
process.env.NODE_ENV = "production";
expect(secureCookies()).toBe(true);
});
it("drops Secure only for an explicit local-dev NODE_ENV", () => {
process.env.NODE_ENV = "development";
expect(secureCookies()).toBe(false);
});
it("COOKIE_SECURE override wins: falsey values opt OUT", () => {
for (const v of ["0", "false", "no", "off", "FALSE", " Off "]) {
process.env.COOKIE_SECURE = v;
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(false);
}
});
it("COOKIE_SECURE override wins: any other value opts IN (even in dev)", () => {
process.env.NODE_ENV = "development";
for (const v of ["1", "true", "yes", "on", ""]) {
process.env.COOKIE_SECURE = v;
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(true);
}
});
});
+22 -3
View File
@@ -50,9 +50,28 @@ export function requireJwtSecret(): string {
return secret; return secret;
} }
/** Cookies are secure in production; relaxed for local http dev. */ /**
function secureCookies(): boolean { * Whether to set the `Secure` flag on the auth/CSRF cookies. FAIL-SAFE: default is
return process.env.NODE_ENV === "production"; * `true` (Secure) — a misconfigured/forgotten env can only ever make cookies MORE
* restrictive, never silently drop the flag.
*
* The previous gate keyed off `NODE_ENV === "production"`, which meant an appliance
* deployed without that var leaked cookies over plain HTTP. Now `Secure` is the
* default and is dropped ONLY for an explicit, deliberate opt-out — `COOKIE_SECURE`
* set to a falsey value (`0/false/no/off`), or the legacy `NODE_ENV !== production`
* signal kept as a fallback so existing dev setups still work over http://localhost.
*
* The parking appliance often serves the SPA same-origin over the LAN with no TLS;
* THAT box sets `COOKIE_SECURE=0` on purpose (a Secure cookie would never be sent
* over its http origin and would lock operators out). Everything else stays secure.
*/
export function secureCookies(): boolean {
const override = process.env.COOKIE_SECURE;
if (override !== undefined) {
return !/^(0|false|no|off)$/i.test(override.trim());
}
// No explicit override: secure unless this is an obvious local-dev run.
return process.env.NODE_ENV !== "development";
} }
export function newCsrfToken(): string { export function newCsrfToken(): string {
+20
View File
@@ -76,6 +76,16 @@ export interface DeviceStatusEvent {
readonly checkedAt: string; // ISO-8601 readonly checkedAt: string; // ISO-8601
} }
/** Lane occupancy from a camera's vehicle detection — a per-direction "busy/free"
* the booth shows as barrier lights. ADVISORY ONLY: a detection is a hint, never a
* gate (it never blocks a ticket or opens a barrier). "busy" is set by a vehicle
* `active` event; it auto-clears to "free" after a timeout (this camera class sends
* no leave/`inactive` signal — see wiki/entities/lpr-camera.md). */
export interface LaneStatusEvent {
readonly entry: boolean; // true = busy (a vehicle is at the entry vicinity)
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
}
class DeviceEventBus extends EventEmitter { class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void { emitInput(event: DeviceInputEvent): void {
this.emit("input", event); this.emit("input", event);
@@ -128,6 +138,16 @@ class DeviceEventBus extends EventEmitter {
this.on("ledger", cb); this.on("ledger", cb);
return () => this.off("ledger", cb); return () => this.off("ledger", cb);
} }
/** Emitted whenever a lane's busy/free state CHANGES (from camera vehicle
* detection). Drives the booth's barrier lights. Advisory only. */
emitLaneStatus(event: LaneStatusEvent): void {
this.emit("lane-status", event);
}
onLaneStatus(cb: (event: LaneStatusEvent) => void): () => void {
this.on("lane-status", cb);
return () => this.off("lane-status", cb);
}
} }
/** Process-wide device event bus. */ /** Process-wide device event bus. */
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { validateTicketCode } from "./entry-flow.js";
// validateTicketCode is the manual-entry typo guard: an all-digit code whose last digit
// is the Luhn check of the rest. The booth uses it to reject a mistyped ticket up front
// (instead of a confusing "session not found"). The capacity-gate / print-hold / sign-
// before-open paths of EntryFlow need device fakes and are exercised in the device +
// route phases; here we pin the pure, exported checksum contract.
describe("validateTicketCode (Luhn)", () => {
it("accepts a well-formed 11-digit id", () => {
// 10-digit body + its Luhn check digit. 0000000000 → check digit 0.
expect(validateTicketCode("00000000000")).toBe(true);
});
it("rejects a single-digit typo", () => {
expect(validateTicketCode("00000000000")).toBe(true);
expect(validateTicketCode("00000000010")).toBe(false); // flipped a digit, checksum now wrong
});
it("rejects non-digit and out-of-length strings", () => {
expect(validateTicketCode("abc")).toBe(false);
expect(validateTicketCode("123")).toBe(false); // too short
expect(validateTicketCode("123456789012345")).toBe(false); // too long
expect(validateTicketCode("")).toBe(false);
});
it("round-trips a generated body+check (Luhn is self-consistent)", () => {
// Construct a valid code: pick a body, compute its check the same way the issuer does.
const body = "4992739871";
// brute the check digit 0..9 — exactly one makes a valid code.
const valid = Array.from({ length: 10 }, (_, d) => body + d).filter(validateTicketCode);
expect(valid).toHaveLength(1);
});
it("accepts a legacy 13-digit id shape", () => {
// 12-digit body 000000000000 → check 0; the validator is length-agnostic in 10..14.
expect(validateTicketCode("0000000000000")).toBe(true);
});
});
+129
View File
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { EventLog, canonicalize, hashEvent } from "./event-log.js";
import { SoftwareSigner, buildVerifier } from "./signer.js";
// The append-only, hash-chained, signed event log is THE anti-fraud primitive
// (threat model: the operator at the booth). These tests pin every integrity rule:
// monotonic index, prevHash linkage, payload-in-signature, and that verifyChain()
// catches each class of tamper (content edit, reorder, deletion gap, forged sig,
// missing key). No live DB is touched — a fresh in-memory SQLite per test.
const SECRET = "test-event-signing-key-0123456789";
let db: Db;
let close: () => void;
let log: EventLog;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
});
afterEach(() => close());
describe("EventLog.append — chain construction", () => {
it("assigns a monotonic index starting at 1", async () => {
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
expect(a.index).toBe(1);
expect(b.index).toBe(2);
});
it("genesis event has a null prevHash; the next chains to it", async () => {
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
expect(a.prevHash).toBeNull();
expect(b.prevHash).toBe(hashEvent(canonicalize(a)));
});
it("signs each row under the active keyId", async () => {
const row = await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 100 } });
expect(row.keyId).toBe("sw-hmac-v2");
expect(new SoftwareSigner(SECRET).verify(canonicalize(row), row.signature)).toBe(true);
});
it("serializes concurrent appends without index collisions", async () => {
const rows = await Promise.all(
Array.from({ length: 25 }, (_, i) => log.append({ type: "vehicle_entry", identity: `T${i}` })),
);
const indices = rows.map((r) => r.index).sort((a, b) => a - b);
expect(indices).toEqual(Array.from({ length: 25 }, (_, i) => i + 1));
});
});
describe("EventLog.verifyChain — integrity", () => {
async function seed() {
await log.append({ type: "vehicle_entry", identity: "T1", direction: "entry" });
await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 200, tariffVersionId: "tv1" } });
await log.append({ type: "vehicle_exit", identity: "T1", direction: "exit" });
}
it("accepts an untampered chain", async () => {
await seed();
expect(log.verifyChain()).toEqual({ ok: true });
});
it("accepts an empty chain", () => {
expect(log.verifyChain()).toEqual({ ok: true });
});
it("detects a tampered payload (the money amount)", async () => {
await seed();
// Rewrite the payment amount directly in the DB — exactly the booth-operator
// fraud the signed payload defends against.
db.update(ledgerEvents).set({ payload: { amountMinor: 1, tariffVersionId: "tv1" } }).where(eq(ledgerEvents.index, 2)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.index).toBe(2);
expect(r.reason).toMatch(/signature invalid/);
}
});
it("detects a deleted row as an index gap", async () => {
await seed();
db.delete(ledgerEvents).where(eq(ledgerEvents.index, 2)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/index gap/);
});
it("detects a broken prevHash link (reordering / re-chaining)", async () => {
await seed();
db.update(ledgerEvents).set({ prevHash: "0".repeat(64) }).where(eq(ledgerEvents.index, 3)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.index).toBe(3);
expect(r.reason).toMatch(/prevHash/);
}
});
it("detects an event signed under a key that is no longer configured", async () => {
await seed();
// Re-sign row 2 under an unknown keyId — buildVerifier can't resolve it.
db.update(ledgerEvents).set({ keyId: "atecc608-slot9" }).where(eq(ledgerEvents.index, 2)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/no signer for keyId/);
});
});
describe("canonicalize — byte-stability", () => {
it("is independent of payload key order (sorted recursively)", () => {
const base = { index: 1, type: "payment", direction: null, source: null, identity: "T1", occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
const a = canonicalize({ ...base, payload: { amountMinor: 100, tariffVersionId: "tv1" } });
const b = canonicalize({ ...base, payload: { tariffVersionId: "tv1", amountMinor: 100 } });
expect(a).toBe(b);
});
it("changes when any signed field changes", () => {
const base = { index: 1, type: "payment" as const, direction: null, source: null, identity: "T1", payload: { amountMinor: 100 }, occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, payload: { amountMinor: 101 } }));
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, identity: "T2" }));
});
});
+118
View File
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js";
import type { EventLog } from "./event-log.js";
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
// The exit flow is the anti-fraud GATE: no car leaves without a covering payment within
// the walk-back grace (the no-unpaid-bypass + no-free-overstay rules), and the booth has
// no bypass. With no relay configured a clean exit returns { opened:false } — we assert
// the DECISION (refuse vs. sign the exit), not the hardware open.
let db: Db;
let close: () => void;
let log: EventLog;
let exit: ExitFlow;
let pay: PayStation;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = makeLog(db);
exit = new ExitFlow(db, log, silentLogger());
pay = new PayStation(db, log, silentLogger());
});
afterEach(() => close());
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
}
function exitsSigned(identity: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
}
describe("exitForBooth — refusal gates", () => {
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
const r = await exit.exitForBooth("ghost");
expect(r).toMatchObject({ ok: false, status: "no_session" });
const anomalies = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all();
expect(anomalies).toHaveLength(1);
expect(exitsSigned("ghost")).toHaveLength(0);
});
it("refuses an UNPAID open session — no exit signed (no-unpaid-bypass)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("T1", minutesAgo(90));
const r = await exit.exitForBooth("T1");
expect(r).toMatchObject({ ok: false, status: "unpaid" });
expect(exitsSigned("T1")).toHaveLength(0); // the car did NOT leave
});
it("refuses a paid session whose walk-back grace has EXPIRED (no free overstay)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(200));
// A payment made 60 min ago → its 15-min walk-back grace lapsed long ago.
await log.append({
type: "payment", source: "manual", identity: "T1", occurredAt: minutesAgo(60),
payload: { sessionRef: "T1", amountMinor: 10000, currency: "ALL", tender: "cash", graceExitMin: 15 },
});
const r = await exit.exitForBooth("T1");
expect(r).toMatchObject({ ok: false, status: "grace_expired" });
expect(exitsSigned("T1")).toHaveLength(0);
});
});
describe("exitForBooth — valid exit signs the vehicle_exit", () => {
it("a paid session within grace signs an exit (opened:false — no relay in tests)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(90));
await pay.pay("T1", "cash"); // fresh payment → within grace
const r = await exit.exitForBooth("T1");
expect(r.ok).toBe(true);
if (r.ok) expect(r.opened).toBe(false); // signed, but no barrier resolves in tests
expect(exitsSigned("T1")).toHaveLength(1); // the exit IS on the chain
expect(log.verifyChain()).toEqual({ ok: true });
});
// NB: a subscriber's normal exit runs through SubscriptionFlow (the reader/credential
// path), not exitForBooth — the booth's transient exit has no subscription bypass and
// applies the same paid/grace gate to any identity it's handed. Asserting that here so
// the boundary is explicit: handing a bare occurrence to exitForBooth is refused, and a
// subscriber leaves via reopenBarrier (assist) or the subscription reader flow instead.
it("does NOT give the booth transient-exit path a subscription bypass", async () => {
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
const r = await exit.exitForBooth("SUBSESS-1");
expect(r).toMatchObject({ ok: false, status: "unpaid" });
expect(exitsSigned("SUBSESS-1")).toHaveLength(0);
});
it("lets a prepaid subscriber out via the assist (reopenBarrier) path", async () => {
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
const r = await exit.reopenBarrier("SUBSESS-1", "op1");
expect(r.ok).toBe(true);
expect(exitsSigned("SUBSESS-1")).toHaveLength(1); // assist closes the open occurrence
});
});
describe("reopenBarrier — no unpaid re-open", () => {
it("refuses to re-open an unpaid transient session", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("T1", minutesAgo(90));
const r = await exit.reopenBarrier("T1", "op1");
expect(r.ok).toBe(false);
expect(exitsSigned("T1")).toHaveLength(0);
});
it("re-opening a paid OPEN session also closes it (signs the exit)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(90));
await pay.pay("T1", "cash");
const r = await exit.reopenBarrier("T1", "op1");
expect(r.ok).toBe(true);
// The open session is closed by the human-intervention exit so it leaves the list.
expect(exitsSigned("T1")).toHaveLength(1);
});
});
+3 -1
View File
@@ -418,7 +418,9 @@ export class ExitFlow {
const entry = rows.find((r) => r.type === "vehicle_entry"); const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null; if (!entry) return null;
const exited = rows.some((r) => r.type === "vehicle_exit"); // A `void` (cancelled ticket) closes the session like an exit, so a voided ticket
// presented at exit reads as "already closed" — never re-opens. See void-flow.ts.
const exited = rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
let paidAt: string | null = null; let paidAt: string | null = null;
let graceExitMin: number | null = null; let graceExitMin: number | null = null;
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { LaneStatus } from "./lane-status.js";
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// LaneStatus: a camera's vehicle detection marks its bound lane busy, then auto-clears
// after a timeout (this camera class sends no leave signal). Advisory; emits a
// lane-status change only when the busy/free state actually flips.
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
/** Seed a controller (relay 1=entry, 2=exit, 3=both) + a camera bound to the relay
* whose direction we want, so directionOf resolves from the real bound relay. */
function seedCamera(direction: "entry" | "exit" | "both"): string {
const controllerId = randomUUID();
db.insert(devices).values({
id: controllerId,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
{ relay: 3, direction: "both" },
],
},
enabled: true,
}).run();
const relay = direction === "entry" ? 1 : direction === "exit" ? 2 : 3;
const camId = randomUUID();
db.insert(devices).values({
id: camId,
category: "camera",
driverId: "hikvision",
config: { host: "10.0.0.9", controllerId, relay },
enabled: true,
}).run();
return camId;
}
/** Capture lane-status events emitted during `fn`. */
function captureEmits(fn: () => void): LaneStatusEvent[] {
const got: LaneStatusEvent[] = [];
const off = deviceEvents.onLaneStatus((e) => got.push(e));
try {
fn();
} finally {
off();
}
return got;
}
describe("LaneStatus", () => {
it("marks the camera's bound lane busy on a vehicle detection, free until then", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
const emits = captureEmits(() => lane.vehicleDetected(cam));
expect(lane.snapshot()).toEqual({ entry: true, exit: false });
expect(emits).toEqual([{ entry: true, exit: false }]); // emitted on the flip
});
it("auto-clears to free after the TTL (no leave signal from the camera)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot().entry).toBe(true);
const emits = captureEmits(() => vi.advanceTimersByTime(90_001));
expect(lane.snapshot().entry).toBe(false);
expect(emits).toEqual([{ entry: false, exit: false }]);
});
it("re-arms the timer on each detection (a parked car keeps the lane busy)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
// Re-fire just before the TTL — should NOT clear, and should push the clear out.
vi.advanceTimersByTime(80_000);
lane.vehicleDetected(cam);
vi.advanceTimersByTime(80_000); // 160s total, but only 80s since the last detect
expect(lane.snapshot().entry).toBe(true);
// Now let it lapse fully.
vi.advanceTimersByTime(90_001);
expect(lane.snapshot().entry).toBe(false);
});
it("does NOT re-emit on a repeat detection while already busy (only state flips)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam); // flip -> emits
const emits = captureEmits(() => {
lane.vehicleDetected(cam); // already busy -> no emit
lane.vehicleDetected(cam);
});
expect(emits).toEqual([]);
});
it("a 'both'-direction camera marks BOTH lanes busy", () => {
const cam = seedCamera("both");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot()).toEqual({ entry: true, exit: true });
});
it("exit camera marks only the exit lane", () => {
const cam = seedCamera("exit");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot()).toEqual({ entry: false, exit: true });
});
it("ignores an unknown device id", () => {
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected("nope");
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
});
});
+103
View File
@@ -0,0 +1,103 @@
import { eq, devices, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
import { directionOf } from "./device-resolve.js";
// Lane busy/free, driven by a camera's vehicle detection. ADVISORY ONLY — a detection
// is a hint the booth shows as barrier lights; it never gates a ticket or opens a
// barrier (see wiki/entities/lpr-camera.md, the advisory-only rule).
//
// A vehicle `active` event on a camera bound to entry/exit marks THAT lane busy and
// (re)arms an auto-clear timer. This camera class sends NO leave/`inactive` signal, so
// "free" is timeout-driven: the camera re-fires `active` while a car sits in the zone
// (each refreshing the timer); once the car leaves, the actives stop and the lane
// flips free after BUSY_TTL_MS. A "both"-direction camera marks BOTH lanes.
/** How long after the last vehicle detection a lane stays "busy" before clearing.
* Must exceed the camera's `active` re-fire interval so a still-present car keeps the
* lane busy. MEASURED on the test unit (controlled in/out test): the re-fire rate is
* MOVEMENT-driven, not a fixed rate — ~1-3s apart while the car moves, but stretching
* to ~15-25s when it sits MOTIONLESS in the zone. So the TTL must clear the still-car
* gap (~25s) or a parked car flickers free. The camera has ~no dwell lag (it goes
* silent within a second of the car leaving), so 30s clears promptly after departure
* while keeping a motionless car solidly busy. Override with LANE_BUSY_TTL_MS. */
export function busyTtlMs(): number {
const raw = Number(process.env.LANE_BUSY_TTL_MS ?? 30_000);
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
}
export class LaneStatus {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #ttlMs: number;
#entry = false;
#exit = false;
#entryTimer: ReturnType<typeof setTimeout> | null = null;
#exitTimer: ReturnType<typeof setTimeout> | null = null;
constructor(db: Db, logger: FastifyBaseLogger, ttlMs = busyTtlMs()) {
this.#db = db;
this.#logger = logger;
this.#ttlMs = ttlMs;
}
/** Current snapshot (for the WS hello). */
snapshot(): LaneStatusEvent {
return { entry: this.#entry, exit: this.#exit };
}
/**
* A vehicle was detected by camera `deviceId`. Resolves the camera's bound direction
* and marks that lane busy + (re)arms its auto-clear. Best-effort: an unknown camera
* or a non-vehicle caller is the caller's concern — this only handles a confirmed
* vehicle detection. Emits a lane-status change only when the state actually flips.
*/
vehicleDetected(deviceId: string): void {
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row) return;
const dir = directionOf(this.#db, row);
if (dir === "entry" || dir === "both") this.#mark("entry");
if (dir === "exit" || dir === "both") this.#mark("exit");
}
#mark(lane: "entry" | "exit"): void {
const was = lane === "entry" ? this.#entry : this.#exit;
if (lane === "entry") this.#entry = true;
else this.#exit = true;
// (Re)arm the auto-clear — each detection pushes the free-flip further out.
const existing = lane === "entry" ? this.#entryTimer : this.#exitTimer;
if (existing) clearTimeout(existing);
const timer = setTimeout(() => this.#clear(lane), this.#ttlMs);
timer.unref?.(); // never hold the process open
if (lane === "entry") this.#entryTimer = timer;
else this.#exitTimer = timer;
if (!was) {
this.#logger.info(`lane-status: ${lane} -> busy`);
this.#emit();
}
}
#clear(lane: "entry" | "exit"): void {
if (lane === "entry") {
this.#entry = false;
this.#entryTimer = null;
} else {
this.#exit = false;
this.#exitTimer = null;
}
this.#logger.info(`lane-status: ${lane} -> free`);
this.#emit();
}
#emit(): void {
deviceEvents.emitLaneStatus(this.snapshot());
}
/** Clear timers on shutdown. */
stop(): void {
if (this.#entryTimer) clearTimeout(this.#entryTimer);
if (this.#exitTimer) clearTimeout(this.#exitTimer);
}
}
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
import { getOccupancy, occupancyCount, reservedSubscriberSpots } from "./occupancy.js";
// Occupancy is a FOLD over the signed ledger, never a stored counter. These tests
// pin: the entries-minus-exits count, the capacity/full gate, and the reserved-
// subscriber-spots model (its trickiest invariant — never double-count a parked
// subscriber, and never gate the subscriber's own entry).
let db: Db;
let close: () => void;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
});
afterEach(() => close());
// Insert a ledger row directly (these fns read raw rows; signing is event-log's job).
let idx = 0;
function entry(identity: string, payload?: Record<string, unknown>) {
idx += 1;
db.insert(ledgerEvents).values({
id: `e${idx}`, index: idx, type: "vehicle_entry", direction: "entry",
identity, payload: payload ?? null, occurredAt: new Date().toISOString(),
signature: "x", keyId: "test",
}).run();
}
function exit(identity: string) {
idx += 1;
db.insert(ledgerEvents).values({
id: `e${idx}`, index: idx, type: "vehicle_exit", direction: "exit",
identity, payload: null, occurredAt: new Date().toISOString(),
signature: "x", keyId: "test",
}).run();
}
function voidEvt(identity: string) {
idx += 1;
db.insert(ledgerEvents).values({
id: `e${idx}`, index: idx, type: "void",
identity, payload: { sessionRef: identity, voidReason: "misprint" }, occurredAt: new Date().toISOString(),
signature: "x", keyId: "test",
}).run();
}
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
}
describe("occupancyCount", () => {
beforeEach(() => { idx = 0; });
it("is 0 with no events", () => {
expect(occupancyCount(db)).toBe(0);
});
it("counts open sessions (entries minus matching exits)", () => {
entry("A"); entry("B"); entry("C");
exit("B");
expect(occupancyCount(db)).toBe(2);
});
it("a re-entry after exit counts again", () => {
entry("A"); exit("A"); entry("A");
expect(occupancyCount(db)).toBe(1);
});
it("a voided (cancelled) entry does NOT count inside", () => {
entry("A"); entry("B");
voidEvt("B"); // B's ticket was a misprint — cancelled
expect(occupancyCount(db)).toBe(1);
});
});
describe("getOccupancy — capacity + full gate", () => {
beforeEach(() => { idx = 0; });
it("uncapped: never full, free/effectiveFree null", () => {
setSite({ capacity: null });
entry("A");
const o = getOccupancy(db);
expect(o.full).toBe(false);
expect(o.free).toBeNull();
expect(o.effectiveFree).toBeNull();
});
it("capped: full when count reaches capacity", () => {
setSite({ capacity: 2 });
entry("A");
expect(getOccupancy(db).full).toBe(false);
entry("B");
const o = getOccupancy(db);
expect(o.full).toBe(true);
expect(o.free).toBe(0);
});
});
describe("reservedSubscriberSpots", () => {
beforeEach(() => { idx = 0; });
function addSub(id: string, opts: Partial<typeof subscriptions.$inferInsert> = {}) {
db.insert(subscriptions).values({ id, status: "active", quantity: 1, period: "month", ...opts }).run();
}
it("is 0 when the toggle is off (default)", () => {
setSite({ capacity: 10, reserveSubscriberSpots: false });
addSub("s1", { quantity: 2 });
expect(reservedSubscriberSpots(db)).toBe(0);
});
it("holds quantity spots for an active, not-parked subscription", () => {
setSite({ capacity: 10, reserveSubscriberSpots: true });
addSub("s1", { quantity: 2 });
expect(reservedSubscriberSpots(db)).toBe(2);
});
it("does NOT double-count a subscriber already parked (holds only the rest)", () => {
setSite({ capacity: 10, reserveSubscriberSpots: true });
addSub("s1", { quantity: 2 });
// One of the family's two cars is inside (occurrence entry carries permitId = sub id).
entry("SUBSESS-1", { permitId: "s1" });
expect(reservedSubscriberSpots(db)).toBe(1); // 2 quantity − 1 inside
});
it("ignores suspended/revoked and out-of-window subscriptions", () => {
setSite({ capacity: 10, reserveSubscriberSpots: true });
addSub("active", { quantity: 1 });
addSub("suspended", { quantity: 5, status: "suspended" });
addSub("expired", { quantity: 5, validTo: "2000-01-01T00:00:00.000Z" });
expect(reservedSubscriberSpots(db)).toBe(1);
});
});
describe("getOccupancy — reserved tightens the transient gate", () => {
beforeEach(() => { idx = 0; });
it("transient sees full once count + reserved ≥ capacity", () => {
setSite({ capacity: 3, reserveSubscriberSpots: true });
db.insert(subscriptions).values({ id: "s1", status: "active", quantity: 2, period: "month" }).run();
entry("A"); // 1 inside + 2 reserved = 3 ≥ capacity 3
const o = getOccupancy(db);
expect(o.reserved).toBe(2);
expect(o.effectiveFree).toBe(0);
expect(o.full).toBe(true);
});
});
+5 -2
View File
@@ -30,8 +30,11 @@ export function occupancyCount(db: Db): number {
.all(); .all();
const balance = new Map<string, number>(); const balance = new Map<string, number>();
for (const r of rows) { for (const r of rows) {
// A `void` (cancelled ticket) closes the session like an exit — the car never entered
// (misprint), so it must not count inside. See void-flow.ts.
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1); if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1); else if (r.type === "vehicle_exit" || r.type === "void")
balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
} }
let open = 0; let open = 0;
for (const v of balance.values()) if (v > 0) open += 1; for (const v of balance.values()) if (v > 0) open += 1;
@@ -68,7 +71,7 @@ export function reservedSubscriberSpots(db: Db): number {
if (pl.permitId == null) continue; // transient if (pl.permitId == null) continue; // transient
net.set(id, (net.get(id) ?? 0) + 1); net.set(id, (net.get(id) ?? 0) + 1);
subOf.set(id, pl.permitId); subOf.set(id, pl.permitId);
} else if (r.type === "vehicle_exit") { } else if (r.type === "vehicle_exit" || r.type === "void") {
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1); if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
} }
} }
+137
View File
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { PayStation, NoOpenSessionError, NoTariffError } from "./pay-station.js";
import type { EventLog } from "./event-log.js";
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
// The pay station prices an open session against the tariff frozen at entry and writes
// a SIGNED payment event (never a mutable "paid" flag). These tests pin the quote math,
// the signed-payment side effect, the no-session / no-tariff errors, and the lookup
// view the booth modal reads.
let db: Db;
let close: () => void;
let log: EventLog;
let pay: PayStation;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = makeLog(db);
pay = new PayStation(db, log, silentLogger());
});
afterEach(() => close());
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
}
describe("PayStation.quote", () => {
it("throws NoOpenSessionError for an unknown ticket", () => {
seedTariff(db);
expect(() => pay.quote("nope")).toThrow(NoOpenSessionError);
});
it("throws NoTariffError when no site tariff is configured", async () => {
await enter("T1", minutesAgo(120));
expect(() => pay.quote("T1")).toThrow(NoTariffError);
});
it("prices a stay against the frozen tariff (90min → 2 increments at 100/h = 200)", async () => {
// 90 min rounds UP to a 2nd 60-min increment; well clear of the boundary so a few
// ms of test runtime can't tip it into a 3rd increment.
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
await enter("T1", minutesAgo(90));
const q = pay.quote("T1");
expect(q.amountMinor).toBe(20000);
expect(q.currency).toBe("ALL");
expect(q.overstay).toBe(false);
});
it("prices 0 within the entry grace (quick in-and-out)", async () => {
seedTariff(db, { gracePeriodEntryMin: 10 });
await enter("T1", minutesAgo(5));
expect(pay.quote("T1").amountMinor).toBe(0);
});
});
describe("PayStation.pay — signed payment side effect", () => {
it("appends a signed payment event carrying amount, currency, tender, grace", async () => {
const { currency } = seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(90));
const res = await pay.pay("T1", "cash");
expect(res.amountMinor).toBe(20000);
expect(res.currency).toBe(currency);
const payments = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all();
expect(payments).toHaveLength(1);
const pl = payments[0].payload as Record<string, unknown>;
expect(pl.amountMinor).toBe(20000);
expect(pl.tender).toBe("cash");
expect(pl.graceExitMin).toBe(15);
// It must be a real signed chain event.
expect(log.verifyChain()).toEqual({ ok: true });
});
it("honours an operator override amount (lost ticket / dispute)", async () => {
seedTariff(db);
await enter("T1", minutesAgo(120));
const res = await pay.pay("T1", "card", 99900);
expect(res.amountMinor).toBe(99900);
const pl = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all()[0].payload as Record<string, unknown>;
expect(pl.amountMinor).toBe(99900);
expect(pl.reason).toBe("operator-set amount");
});
});
describe("PayStation.lookup — booth modal view", () => {
it("reports not-found for an unknown ticket", () => {
const v = pay.lookup("ghost");
expect(v.found).toBe(false);
expect(v.open).toBe(false);
});
it("shows an open unpaid transient with the amount owed", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("T1", minutesAgo(90));
const v = pay.lookup("T1");
expect(v.found).toBe(true);
expect(v.open).toBe(true);
expect(v.paidAt).toBeNull();
expect(v.amountMinor).toBe(20000);
expect(v.subscription).toBe(false);
});
it("after payment shows paid + within grace, amount cleared", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(120));
await pay.pay("T1", "cash");
const v = pay.lookup("T1");
expect(v.paidAt).not.toBeNull();
expect(v.withinGrace).toBe(true);
expect(v.overstay).toBe(false);
});
it("flags a subscription occurrence (prepaid — never a transient charge)", async () => {
seedTariff(db);
await enter("SUBSESS-1", minutesAgo(120), { permit: true, permitId: "sub-1" });
const v = pay.lookup("SUBSESS-1");
expect(v.subscription).toBe(true);
expect(v.subscriptionId).toBe("sub-1");
expect(v.amountMinor).toBeNull(); // no timeframes → nothing owed
});
});
describe("PayStation.activeSessions", () => {
it("lists open sessions newest-first and omits exited-past-grace", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("OLD", minutesAgo(200));
await enter("NEW", minutesAgo(30));
const list = pay.activeSessions();
expect(list.map((s) => s.identity)).toEqual(["NEW", "OLD"]);
expect(list.every((s) => s.open)).toBe(true);
});
});
+5 -2
View File
@@ -282,7 +282,9 @@ export class PayStation {
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string }; const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const isSubscription = entryPl.permit === true || entryPl.permitId != null; const isSubscription = entryPl.permit === true || entryPl.permitId != null;
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null; const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
const exitRow = rows.find((r) => r.type === "vehicle_exit"); // A `void` (cancelled ticket) closes the session like an exit — a voided ticket is no
// longer open and can't be paid/exited. See void-flow.ts.
const exitRow = rows.find((r) => r.type === "vehicle_exit" || r.type === "void");
const open = !exitRow; const open = !exitRow;
let paidAt: string | null = null; let paidAt: string | null = null;
@@ -366,7 +368,8 @@ export class PayStation {
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string }; const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null; if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
byId.set(id, a); byId.set(id, a);
} else if (r.type === "vehicle_exit") { } else if (r.type === "vehicle_exit" || r.type === "void") {
// A `void` closes the session like an exit — drop it from the active list.
const a = byId.get(id); const a = byId.get(id);
if (a) a.exitedAt = r.occurredAt; if (a) a.exitedAt = r.occurredAt;
} else if (r.type === "payment") { } else if (r.type === "payment") {
+189
View File
@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it } from "vitest";
import { randomUUID } from "node:crypto";
import {
eq,
isNull,
roles,
rolePermissions,
subscriptionCredentials,
subscriptionPlans,
subscriptions,
tariffs,
users,
type Db,
} from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import {
listRecycleBin,
purge,
restore,
restoreBlockedReason,
softDelete,
sweepExpired,
} from "./recycle-bin.js";
// Soft delete / recycle bin. Pins: a delete STAMPS (keeps the row), the bin lists
// soft-deleted items across kinds, restore brings them back, purge does the real
// DELETE (+ children), a restore that would collide with a live row is blocked, and the
// retention sweep purges only items past the window.
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
});
function seedUser(username: string): string {
const id = randomUUID();
db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run();
db.insert(users).values({ id, username, passwordHash: "x", roleId: "admin" }).run();
return id;
}
function seedRole(name: string): string {
const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run();
db.insert(rolePermissions).values({ roleId: id, permission: "site:read" }).run();
return id;
}
function seedSubscription(holder: string): string {
const id = randomUUID();
db.insert(subscriptions).values({ id, holderName: holder, period: "month" }).run();
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: "qr", value: `qr-${id}` }).run();
return id;
}
function seedPlan(planId: string, versions = 2): void {
for (let i = 0; i < versions; i++) {
db.insert(subscriptionPlans).values({
id: randomUUID(),
planId,
name: planId,
period: "month",
pricePerPeriodMinor: 100000,
currency: "ALL",
effectiveFrom: `2026-0${i + 1}-01T00:00:00.000Z`,
}).run();
}
}
describe("softDelete + restore + purge", () => {
it("stamps the row instead of removing it, and hides it from a live query", () => {
const id = seedUser("alice");
expect(softDelete(db, "user", id, "admin-1")).toBe(true);
const row = db.select().from(users).where(eq(users.id, id)).get();
expect(row).toBeDefined(); // still there
expect(row?.deletedAt).toBeTruthy();
expect(row?.deletedBy).toBe("admin-1");
// A live-only query no longer sees it.
expect(db.select().from(users).where(isNull(users.deletedAt)).all()).toHaveLength(0);
});
it("soft-deleting an already-deleted row is a no-op (returns false)", () => {
const id = seedUser("bob");
expect(softDelete(db, "user", id, "a")).toBe(true);
expect(softDelete(db, "user", id, "a")).toBe(false);
});
it("restore clears the stamps and brings the row back to the live set", () => {
const id = seedRole("valet");
softDelete(db, "role", id, "a");
expect(restore(db, "role", id)).toBe(true);
const row = db.select().from(roles).where(eq(roles.id, id)).get();
expect(row?.deletedAt).toBeNull();
expect(db.select().from(roles).where(isNull(roles.deletedAt)).all().map((r) => r.id)).toContain(id);
});
it("purge removes a soft-deleted row + its children; refuses a LIVE row", () => {
const id = seedSubscription("carlos");
// Cannot purge while live (purge only touches soft-deleted rows).
expect(purge(db, "subscription", id)).toBe(false);
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeDefined();
softDelete(db, "subscription", id, "a");
expect(purge(db, "subscription", id)).toBe(true);
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeUndefined();
// Children gone too.
expect(db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all()).toHaveLength(0);
});
});
describe("versioned plans", () => {
it("soft-deletes / restores / purges ALL versions of a planId together", () => {
seedPlan("hotel-daily", 3);
expect(softDelete(db, "plan", "hotel-daily", "a")).toBe(true);
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(0);
// The bin lists the plan as ONE item, not three.
const planItems = listRecycleBin(db).filter((i) => i.kind === "plan");
expect(planItems).toHaveLength(1);
expect(planItems[0]?.id).toBe("hotel-daily");
expect(restore(db, "plan", "hotel-daily")).toBe(true);
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(3);
softDelete(db, "plan", "hotel-daily", "a");
expect(purge(db, "plan", "hotel-daily")).toBe(true);
expect(db.select().from(subscriptionPlans).all()).toHaveLength(0);
});
});
describe("listRecycleBin", () => {
it("collects soft-deleted items across every kind, newest-deleted first", () => {
const u = seedUser("dora");
const r = seedRole("guard");
const t = randomUUID();
db.insert(tariffs).values({ id: t, scope: "site", name: "Site" }).run();
softDelete(db, "user", u, "a");
softDelete(db, "role", r, "a");
softDelete(db, "tariff", t, "a");
const items = listRecycleBin(db);
expect(items.map((i) => i.kind).sort()).toEqual(["role", "tariff", "user"]);
// Each carries a human label + the deletedAt stamp.
expect(items.find((i) => i.kind === "user")?.label).toBe("dora");
expect(items.every((i) => i.deletedAt)).toBe(true);
});
});
describe("restoreBlockedReason", () => {
// NB: the DB `username`/`name` UNIQUE spans live AND soft-deleted rows, so a live
// duplicate can't even be INSERTed while the deleted one exists (the create route
// returns a clear 409 instead — see routes/users.ts). restoreBlockedReason is a
// belt-and-suspenders guard at restore time; verify it returns null in the normal
// case (nothing colliding) so a clean restore is never wrongly blocked.
it("does not block a normal restore (no live collision)", () => {
const u = seedUser("eve");
softDelete(db, "user", u, "a");
expect(restoreBlockedReason(db, "user", u)).toBeNull();
const r = seedRole("cleaner");
softDelete(db, "role", r, "a");
expect(restoreBlockedReason(db, "role", r)).toBeNull();
});
});
describe("sweepExpired (retention)", () => {
it("purges items deleted longer than the window ago, keeps recent ones", () => {
const old = seedUser("old");
const fresh = seedUser("fresh");
softDelete(db, "user", old, "a");
softDelete(db, "user", fresh, "a");
// Backdate `old`'s deletion to 40 days ago.
const longAgo = new Date(Date.now() - 40 * 86_400_000).toISOString();
db.update(users).set({ deletedAt: longAgo }).where(eq(users.id, old)).run();
const purged = sweepExpired(db, 30);
expect(purged.user).toBe(1);
expect(db.select().from(users).where(eq(users.id, old)).get()).toBeUndefined();
expect(db.select().from(users).where(eq(users.id, fresh)).get()).toBeDefined();
});
it("days <= 0 disables the sweep (keep forever)", () => {
const id = seedUser("keeper");
softDelete(db, "user", id, "a");
db.update(users).set({ deletedAt: new Date(Date.now() - 999 * 86_400_000).toISOString() }).where(eq(users.id, id)).run();
const purged = sweepExpired(db, 0);
expect(purged.user).toBe(0);
expect(db.select().from(users).where(eq(users.id, id)).get()).toBeDefined();
});
});
+206
View File
@@ -0,0 +1,206 @@
import {
and,
eq,
isNotNull,
isNull,
lte,
rolePermissions,
roles,
subscriptionCredentials,
subscriptionPlans,
subscriptionPlates,
subscriptions,
tariffs,
users,
type Db,
} from "@parking/db";
// Soft delete + recycle bin. Accidental hard-deletes of master data (a user, role,
// subscription, plan, tariff) used to be unrecoverable. Now a DELETE STAMPS the row
// (`deleted_at` = now, `deleted_by` = admin) instead of removing it; it disappears from
// every catalog (the list queries filter `deleted_at IS NULL`) but survives in the
// recycle bin, where an admin can RESTORE it (clear the stamps) or PURGE it (the real
// DELETE). A retention sweep auto-purges items deleted longer than the window ago.
//
// Scope: only the MUTABLE master-data tables below. The signed, append-only ledger is
// NOT here — it has no delete path by design. See wiki/concepts/soft-delete.md.
/** The soft-deletable resource kinds, as they appear in the recycle-bin API. */
export type ResourceKind = "user" | "role" | "subscription" | "plan" | "tariff";
export const RESOURCE_KINDS: ResourceKind[] = ["user", "role", "subscription", "plan", "tariff"];
/** Default retention window before a soft-deleted item is auto-purged (days). Override
* with RECYCLE_BIN_RETENTION_DAYS. 0/negative disables the sweep (keep forever). */
export function retentionDays(): number {
const raw = Number(process.env.RECYCLE_BIN_RETENTION_DAYS ?? 30);
return Number.isFinite(raw) ? raw : 30;
}
/** A row surfaced in the recycle bin (normalised across resource kinds). */
export interface RecycleBinItem {
readonly kind: ResourceKind;
/** The id used to restore/purge. For a versioned PLAN this is the stable planId. */
readonly id: string;
/** Human label for the list (username, role/plan/tariff name, subscriber holder). */
readonly label: string;
readonly deletedAt: string;
readonly deletedBy: string | null;
}
const NOW = () => new Date().toISOString();
// --- Per-resource helpers ----------------------------------------------------
// Subscriptions/users/roles/tariffs are 1 row per id. PLANS are versioned (N rows per
// plan_id) — stamp/clear/delete ALL versions of the plan_id together.
/** Soft-delete a row by id. Returns false if no live row matched (404). PLAN uses planId. */
export function softDelete(db: Db, kind: ResourceKind, id: string, byUserId: string): boolean {
const stamp = { deletedAt: NOW(), deletedBy: byUserId };
switch (kind) {
case "user":
return db.update(users).set(stamp).where(and(eq(users.id, id), isNull(users.deletedAt))).run().changes > 0;
case "role":
return db.update(roles).set(stamp).where(and(eq(roles.id, id), isNull(roles.deletedAt))).run().changes > 0;
case "subscription":
return db.update(subscriptions).set(stamp).where(and(eq(subscriptions.id, id), isNull(subscriptions.deletedAt))).run().changes > 0;
case "plan":
return db.update(subscriptionPlans).set(stamp).where(and(eq(subscriptionPlans.planId, id), isNull(subscriptionPlans.deletedAt))).run().changes > 0;
case "tariff":
return db.update(tariffs).set(stamp).where(and(eq(tariffs.id, id), isNull(tariffs.deletedAt))).run().changes > 0;
}
}
/** Restore a soft-deleted row (clear the stamps). Returns false if nothing was restored. */
export function restore(db: Db, kind: ResourceKind, id: string): boolean {
const clear = { deletedAt: null, deletedBy: null };
switch (kind) {
case "user":
return db.update(users).set(clear).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0;
case "role":
return db.update(roles).set(clear).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0;
case "subscription":
return db.update(subscriptions).set(clear).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0;
case "plan":
return db.update(subscriptionPlans).set(clear).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0;
case "tariff":
return db.update(tariffs).set(clear).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0;
}
}
/** True if restoring would collide with a LIVE row (e.g. a user with the same username
* was re-created after the delete). The caller turns this into a 409 so the admin
* understands why restore is blocked. */
export function restoreBlockedReason(db: Db, kind: ResourceKind, id: string): string | null {
if (kind === "user") {
const row = db.select().from(users).where(eq(users.id, id)).get();
if (row && db.select().from(users).where(and(eq(users.username, row.username), isNull(users.deletedAt))).get()) {
return `a live user named "${row.username}" already exists`;
}
} else if (kind === "role") {
const row = db.select().from(roles).where(eq(roles.id, id)).get();
if (row && db.select().from(roles).where(and(eq(roles.name, row.name), isNull(roles.deletedAt))).get()) {
return `a live role named "${row.name}" already exists`;
}
}
return null;
}
// --- Restore ordering note --------------------------------------------------
// A restored USER points at a roleId; if that role is itself deleted, the user reappears
// with a dangling role. We don't auto-cascade (keep it predictable); the bin lists both
// and the admin restores the role too. The role guard already resolves a missing role to
// an empty permission set (safe-by-default), so a dangling role never escalates.
/** Hard-delete (purge) a soft-deleted row + its children. The real DELETE. Returns false
* if no soft-deleted row matched (so you can't purge a live row through this path). */
export function purge(db: Db, kind: ResourceKind, id: string): boolean {
switch (kind) {
case "user":
return db.delete(users).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0;
case "role": {
// Children (role_permissions) only matter once the role row is gone; purge both.
const ok = db.delete(roles).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0;
if (ok) deleteRolePermissions(db, id);
return ok;
}
case "subscription": {
const ok = db.delete(subscriptions).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0;
if (ok) deleteSubscriptionChildren(db, id);
return ok;
}
case "plan":
return db.delete(subscriptionPlans).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0;
case "tariff":
return db.delete(tariffs).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0;
}
}
// Child cleanup on purge (role_permissions / subscription credentials + plates).
function deleteRolePermissions(db: Db, roleId: string): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
}
function deleteSubscriptionChildren(db: Db, id: string): void {
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
}
// --- Listing the bin --------------------------------------------------------
/** All soft-deleted items across every resource kind, newest-deleted first. */
export function listRecycleBin(db: Db): RecycleBinItem[] {
const items: RecycleBinItem[] = [];
for (const r of db.select().from(users).where(isNotNull(users.deletedAt)).all()) {
items.push({ kind: "user", id: r.id, label: r.fullName || r.username, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
for (const r of db.select().from(roles).where(isNotNull(roles.deletedAt)).all()) {
items.push({ kind: "role", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
for (const r of db.select().from(subscriptions).where(isNotNull(subscriptions.deletedAt)).all()) {
items.push({ kind: "subscription", id: r.id, label: r.holderName || r.id, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
// Plans are versioned: collapse to one item per plan_id (the latest version's name).
const planSeen = new Set<string>();
const planRows = db.select().from(subscriptionPlans).where(isNotNull(subscriptionPlans.deletedAt)).all();
planRows.sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom));
for (const r of planRows) {
if (planSeen.has(r.planId)) continue;
planSeen.add(r.planId);
items.push({ kind: "plan", id: r.planId, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
for (const r of db.select().from(tariffs).where(isNotNull(tariffs.deletedAt)).all()) {
items.push({ kind: "tariff", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
}
// --- Retention sweep --------------------------------------------------------
/** Purge every soft-deleted row deleted more than `retentionDays()` ago. Returns the
* count purged per kind. Safe to call repeatedly (idempotent). */
export function sweepExpired(db: Db, days = retentionDays()): Record<ResourceKind, number> {
const out: Record<ResourceKind, number> = { user: 0, role: 0, subscription: 0, plan: 0, tariff: 0 };
if (!Number.isFinite(days) || days <= 0) return out; // keep-forever
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
// Collect ids first so children purge through the same path as a manual purge.
for (const r of db.select().from(users).where(and(isNotNull(users.deletedAt), lte(users.deletedAt, cutoff))).all()) {
if (purge(db, "user", r.id)) out.user++;
}
for (const r of db.select().from(roles).where(and(isNotNull(roles.deletedAt), lte(roles.deletedAt, cutoff))).all()) {
if (purge(db, "role", r.id)) out.role++;
}
for (const r of db.select().from(subscriptions).where(and(isNotNull(subscriptions.deletedAt), lte(subscriptions.deletedAt, cutoff))).all()) {
if (purge(db, "subscription", r.id)) out.subscription++;
}
const planIds = new Set(
db.select().from(subscriptionPlans).where(and(isNotNull(subscriptionPlans.deletedAt), lte(subscriptionPlans.deletedAt, cutoff))).all().map((r) => r.planId),
);
for (const planId of planIds) if (purge(db, "plan", planId)) out.plan++;
for (const r of db.select().from(tariffs).where(and(isNotNull(tariffs.deletedAt), lte(tariffs.deletedAt, cutoff))).all()) {
if (purge(db, "tariff", r.id)) out.tariff++;
}
return out;
}
+183
View File
@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it } from "vitest";
import { sessions, siteConfig, subscriptions, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { randomUUID } from "node:crypto";
import { makeLog } from "./test-helpers.js";
import { reportSummary } from "./reports.js";
import type { EventLog } from "./event-log.js";
// Reports aggregation — LEDGER-FIRST. These pin that the numbers an admin sees are
// summed straight from the signed ledger (entry/exit counts + payment money, split the
// same way the shift Z-report splits it), bucketed in the SITE TIMEZONE, with duration
// stats from the closed-sessions cache and subscription counts as of the range end.
let db: Db;
let log: EventLog;
beforeEach(() => {
({ db } = createTestDb());
log = makeLog(db);
// Fix the site timezone so bucket labels are deterministic regardless of the test host.
db.insert(siteConfig).values({ id: 1, timezone: "Europe/Tirane" }).run();
});
/** ISO at a UTC instant, for deterministic bucket assertions. */
function at(iso: string): string {
return new Date(iso).toISOString();
}
async function entry(occurredAt: string): Promise<void> {
await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt });
}
async function exit(occurredAt: string): Promise<void> {
await log.append({ type: "vehicle_exit", direction: "exit", identity: randomUUID(), occurredAt });
}
async function payment(
occurredAt: string,
amountMinor: number,
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
): Promise<void> {
await log.append({
type: "payment",
occurredAt,
payload: {
amountMinor,
currency: "ALL",
tender: opts.tender ?? "cash",
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
},
});
}
const RANGE = { from: at("2026-06-01T00:00:00Z"), to: at("2026-06-30T23:59:59Z") };
describe("reportSummary — ledger-first totals", () => {
it("counts entries and exits from the signed ledger", async () => {
await entry(at("2026-06-10T08:00:00Z"));
await entry(at("2026-06-10T09:00:00Z"));
await exit(at("2026-06-10T18:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.entries).toBe(2);
expect(r.totals.exits).toBe(1);
});
it("excludes events outside [from, to)", async () => {
await entry(at("2026-05-31T23:00:00Z")); // before
await entry(at("2026-06-15T10:00:00Z")); // inside
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.entries).toBe(1);
});
it("sums payment money and splits cash vs card", async () => {
await payment(at("2026-06-12T10:00:00Z"), 20000, { tender: "cash" });
await payment(at("2026-06-12T11:00:00Z"), 5000, { tender: "card" });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.payments).toBe(2);
expect(r.totals.revenueMinor).toBe(25000);
expect(r.totals.cashMinor).toBe(20000);
expect(r.totals.cardMinor).toBe(5000);
});
it("splits revenue into ticket / subscription-sale / out-of-window, mirroring the Z-report", async () => {
await payment(at("2026-06-12T10:00:00Z"), 10000); // transient ticket
await payment(at("2026-06-12T10:05:00Z"), 30000, { subscriptionSale: true });
await payment(at("2026-06-12T10:06:00Z"), 1500, { subscriptionWindowCharge: true });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.ticketMinor).toBe(10000);
expect(r.totals.subscriptionSalesMinor).toBe(30000);
expect(r.totals.subscriptionWindowMinor).toBe(1500);
// The three add up to the gross revenue.
expect(r.totals.revenueMinor).toBe(41500);
});
it("picks up the currency from a payment in range", async () => {
await payment(at("2026-06-12T10:00:00Z"), 10000);
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.currency).toBe("ALL");
});
});
describe("reportSummary — time bucketing (site timezone)", () => {
it("buckets by local day; a 23:30 UTC event lands on the NEXT local day in Tirane (UTC+2/3)", async () => {
// 2026-06-15T23:30Z is 2026-06-16 01:30 local (summer, UTC+2) → the 16th bucket.
await entry(at("2026-06-15T23:30:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const point = r.series.find((p) => p.entries > 0);
expect(point?.bucket).toBe("2026-06-16");
});
it("series points are sorted and carry per-bucket entries/exits/revenue", async () => {
await entry(at("2026-06-10T08:00:00Z"));
await payment(at("2026-06-10T09:00:00Z"), 7000);
await entry(at("2026-06-12T08:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const labels = r.series.map((p) => p.bucket);
expect(labels).toEqual([...labels].sort());
const d10 = r.series.find((p) => p.bucket === "2026-06-10");
expect(d10?.entries).toBe(1);
expect(d10?.revenueMinor).toBe(7000);
});
it("entriesByHour is a 24-slot local-hour histogram", async () => {
// 06:00Z = 08:00 local (summer) → hour slot 8.
await entry(at("2026-06-10T06:00:00Z"));
await entry(at("2026-06-11T06:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.entriesByHour).toHaveLength(24);
expect(r.entriesByHour[8]).toBe(2);
expect(r.entriesByHour.reduce((a, b) => a + b, 0)).toBe(2);
});
});
describe("reportSummary — duration (sessions cache) + subscriptions", () => {
it("computes parked-minute stats from closed sessions whose exit fell in range", async () => {
// 60-min and 120-min stays → avg 90, median 90.
db.insert(sessions).values({
id: "s1",
identity: "t1",
enteredAt: at("2026-06-10T08:00:00Z"),
exitedAt: at("2026-06-10T09:00:00Z"),
state: "closed",
}).run();
db.insert(sessions).values({
id: "s2",
identity: "t2",
enteredAt: at("2026-06-10T08:00:00Z"),
exitedAt: at("2026-06-10T10:00:00Z"),
state: "closed",
}).run();
// An OPEN session (no exit) must not count.
db.insert(sessions).values({ id: "s3", identity: "t3", enteredAt: at("2026-06-10T08:00:00Z"), state: "open" }).run();
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.closedSessions).toBe(2);
expect(r.totals.totalParkedMinutes).toBe(180);
expect(r.totals.avgParkedMinutes).toBe(90);
expect(r.totals.medianParkedMinutes).toBe(90);
});
it("counts subscriptions by status and currently-valid coverage as of `to`", async () => {
const base = { holderName: "x", period: "month" as const, createdAt: at("2026-06-01T00:00:00Z") };
// active + valid window covering `to`, quantity 2.
db.insert(subscriptions).values({
id: "a", status: "active", quantity: 2,
validFrom: at("2026-06-01T00:00:00Z"), validTo: at("2026-07-01T00:00:00Z"), ...base,
}).run();
// active but EXPIRED before `to` → not currently valid.
db.insert(subscriptions).values({
id: "b", status: "active", quantity: 1,
validFrom: at("2026-05-01T00:00:00Z"), validTo: at("2026-06-05T00:00:00Z"), ...base,
}).run();
// suspended.
db.insert(subscriptions).values({ id: "c", status: "suspended", quantity: 1, ...base }).run();
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.subscriptions.active).toBe(2);
expect(r.subscriptions.suspended).toBe(1);
expect(r.subscriptions.revoked).toBe(0);
expect(r.subscriptions.currentlyValid).toBe(1);
expect(r.subscriptions.coveredCars).toBe(2);
});
});
+288
View File
@@ -0,0 +1,288 @@
import {
and,
asc,
desc,
eq,
gte,
lte,
ledgerEvents,
sessions,
subscriptions,
tariffVersions,
tariffs,
type Db,
} from "@parking/db";
import { siteTz } from "./subscription-window.js";
// Admin reporting — LEDGER-FIRST aggregation (decision 2026-06-22). The numbers an
// admin sees on the Reports page are summed from the SIGNED, hash-chained
// ledger_events (vehicle_entry/exit + payment), the same source the shift Z-report
// reconciles against — so a chart total always ties out to the drawer. Only the
// duration/occupancy view leans on the derived `sessions` cache, where the ledger is
// awkward (you'd have to pair every entry with its exit by hand); that's flagged as a
// cache, not the financial truth. See wiki/concepts/reports.md, event-streams-split.md.
//
// All bucketing is in the SITE TIMEZONE (siteConfig.timezone) — a "day" is a local
// calendar day, not a UTC one, so a 01:00-local payment lands on the right date and the
// peak-hour chart reads in wall-clock. Pure date math on the stored ISO strings; no
// floats (money is integer minor units throughout).
export type Bucket = "hour" | "day" | "month";
export interface ReportQuery {
/** Inclusive lower bound (ISO instant). */
readonly from: string;
/** Exclusive upper bound (ISO instant). */
readonly to: string;
/** Time grain for the series. Default "day". */
readonly bucket: Bucket;
}
/** One point in a time series, keyed by its local-time bucket label (e.g. "2026-06-22"
* for a day, "2026-06-22 14" for an hour). */
export interface SeriesPoint {
readonly bucket: string;
readonly entries: number;
readonly exits: number;
/** Net transient revenue collected in the bucket (minor units), all tenders. */
readonly revenueMinor: number;
/** Payment COUNT in the bucket (transactions, not amount). */
readonly payments: number;
}
export interface ReportTotals {
readonly entries: number;
readonly exits: number;
readonly payments: number;
readonly revenueMinor: number;
readonly cashMinor: number;
readonly cardMinor: number;
/** Revenue split by what was sold. ticket = transient parking; subscriptionSales =
* new/renewed subscriptions; subscriptionWindow = out-of-window tariff-bridge charges. */
readonly ticketMinor: number;
readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number;
/** Closed transient sessions in range + their parked-minutes stats (from the cache). */
readonly closedSessions: number;
readonly totalParkedMinutes: number;
readonly avgParkedMinutes: number;
readonly medianParkedMinutes: number;
}
export interface SubscriptionStats {
readonly active: number;
readonly suspended: number;
readonly revoked: number;
/** Active subscriptions whose window covers `to` (the report's "now"). */
readonly currentlyValid: number;
/** Cars covered by currently-valid subscriptions (Σ quantity). */
readonly coveredCars: number;
}
export interface ReportSummary {
readonly from: string;
readonly to: string;
readonly bucket: Bucket;
readonly tz: string;
readonly currency: string | null;
readonly totals: ReportTotals;
readonly series: SeriesPoint[];
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
readonly entriesByHour: number[];
readonly subscriptions: SubscriptionStats;
}
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
});
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
return {
y: Number(parts.year),
mo: Number(parts.month),
d: Number(parts.day),
h: Number(parts.hour),
};
}
/** Bucket label for an instant at the chosen grain, in local time. Sorts lexically. */
function bucketLabel(iso: string, tz: string, bucket: Bucket): string {
const p = localParts(iso, tz);
const mo = String(p.mo).padStart(2, "0");
const d = String(p.d).padStart(2, "0");
const h = String(p.h).padStart(2, "0");
if (bucket === "month") return `${p.y}-${mo}`;
if (bucket === "hour") return `${p.y}-${mo}-${d} ${h}`;
return `${p.y}-${mo}-${d}`;
}
interface PaymentPayload {
amountMinor?: number;
currency?: string;
tender?: "cash" | "card";
subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean;
}
function median(sorted: number[]): number {
if (sorted.length === 0) return 0;
const mid = Math.floor(sorted.length / 2);
const hi = sorted[mid] ?? 0;
if (sorted.length % 2) return hi;
const lo = sorted[mid - 1] ?? 0;
return Math.round((lo + hi) / 2);
}
/**
* Build the admin report summary for [from, to) at the chosen grain. Entry/exit counts
* and money are summed from the signed ledger; duration stats from the closed sessions
* in range; subscription counts from the subscriptions table as of `to`.
*/
export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const tz = siteTz(db);
// --- Ledger: entry/exit/payment in range, oldest-first so the series builds in order.
const rows = db
.select()
.from(ledgerEvents)
.where(and(gte(ledgerEvents.occurredAt, q.from), lte(ledgerEvents.occurredAt, q.to)))
.orderBy(asc(ledgerEvents.index))
.all();
// Currency for display: money everywhere is { minorUnits, currency }; payments carry
// the currency they were taken in, so take it from a payment in range (then fall back
// to the active tariff version). Reports never mix currencies (single-currency site).
let currency: string | null = null;
const seriesMap = new Map<string, SeriesPoint>();
const entriesByHour = new Array<number>(24).fill(0);
const totals = {
entries: 0,
exits: 0,
payments: 0,
revenueMinor: 0,
cashMinor: 0,
cardMinor: 0,
ticketMinor: 0,
subscriptionSalesMinor: 0,
subscriptionWindowMinor: 0,
};
function point(label: string): SeriesPoint {
let p = seriesMap.get(label);
if (!p) {
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
seriesMap.set(label, p);
}
return p;
}
// Pre-pass: identities cancelled by a `void` in range. A voided entry was a wrongly-
// printed ticket (no car entered), so it must NOT inflate the "entries" stat. (The void's
// entry is normally in the same window; this skips it when both are in range.)
const voided = new Set<string>();
for (const row of rows) if (row.type === "void" && row.identity) voided.add(row.identity);
for (const row of rows) {
const label = bucketLabel(row.occurredAt, tz, q.bucket);
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
if (row.type === "vehicle_entry") {
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
totals.entries++;
p.entries++;
const h = localParts(row.occurredAt, tz).h;
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
} else if (row.type === "vehicle_exit") {
totals.exits++;
p.exits++;
} else if (row.type === "payment") {
const pl = (row.payload ?? {}) as PaymentPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (!currency && typeof pl.currency === "string") currency = pl.currency;
totals.payments++;
totals.revenueMinor += amt;
p.payments++;
p.revenueMinor += amt;
if (pl.tender === "card") totals.cardMinor += amt;
else totals.cashMinor += amt;
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
// (the rest is) transient ticket revenue.
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt;
else totals.ticketMinor += amt;
}
}
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
// No payment in range? Fall back to the site tariff's latest version currency, so a
// zero-revenue range still labels its money column.
if (!currency) {
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (tariff) {
const tv = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.get();
currency = tv?.currency ?? null;
}
}
// --- Duration: closed transient sessions whose EXIT fell in range (the cache; flagged).
const closed = db
.select()
.from(sessions)
.where(and(gte(sessions.exitedAt, q.from), lte(sessions.exitedAt, q.to)))
.all();
const durations: number[] = [];
for (const s of closed) {
if (!s.enteredAt || !s.exitedAt) continue;
const mins = Math.max(0, Math.round((Date.parse(s.exitedAt) - Date.parse(s.enteredAt)) / 60000));
durations.push(mins);
}
durations.sort((a, b) => a - b);
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
// --- Subscriptions: status counts + currently-valid (window covers `to`).
const subs = db.select().from(subscriptions).all();
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
for (const s of subs) {
if (s.status === "active") subStats.active++;
else if (s.status === "suspended") subStats.suspended++;
else if (s.status === "revoked") subStats.revoked++;
const validNow =
s.status === "active" &&
(!s.validFrom || s.validFrom <= q.to) &&
(!s.validTo || s.validTo >= q.to);
if (validNow) {
subStats.currentlyValid++;
subStats.coveredCars += s.quantity ?? 1;
}
}
return {
from: q.from,
to: q.to,
bucket: q.bucket,
tz,
currency,
totals: {
...totals,
closedSessions: durations.length,
totalParkedMinutes,
avgParkedMinutes: durations.length ? Math.round(totalParkedMinutes / durations.length) : 0,
medianParkedMinutes: median(durations),
},
series,
entriesByHour,
subscriptions: subStats,
};
}
+80 -1
View File
@@ -29,6 +29,33 @@ interface ThemeBody {
theme: Theme; theme: Theme;
} }
// Self-service profile: a signed-in user edits their OWN display name + email. This is
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
// other account. "" clears a field (→ null). See wiki/entities/local-jwt-auth.md.
interface ProfileBody {
fullName?: string | null;
email?: string | null;
}
// Self-service password change: the user proves they hold the CURRENT password before
// setting a new one — unlike the admin reset (users.ts), which sets it outright. This is
// why it lives here and not behind a permission: it's account-self-care, not admin power.
interface PasswordBody {
currentPassword: string;
newPassword: string;
}
const MIN_PASSWORD = 8;
/** Trim a self-service profile string; "" (or whitespace) → null (clear the field).
* Returns undefined for an absent key so an update only touches what was sent. */
function cleanProfileField(v: string | null | undefined): string | null | undefined {
if (v === undefined) return undefined;
const trimmed = typeof v === "string" ? v.trim() : "";
return trimmed === "" ? null : trimmed;
}
/** 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. */
@@ -41,6 +68,7 @@ function sessionView(
language: string; language: string;
theme: string; theme: string;
fullName?: string | null; fullName?: string | null;
email?: string | null;
}, },
) { ) {
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();
@@ -54,6 +82,7 @@ function sessionView(
language: user.language, language: user.language,
theme: user.theme, theme: user.theme,
fullName: user.fullName ?? null, fullName: user.fullName ?? null,
email: user.email ?? null,
}; };
} }
@@ -69,7 +98,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Always run a bcrypt compare to avoid leaking which usernames exist (timing). // Always run a bcrypt compare to avoid leaking which usernames exist (timing).
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv"; const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
const ok = await bcrypt.compare(password, hash); const ok = await bcrypt.compare(password, hash);
if (!user || !ok) { // A soft-deleted user (in the recycle bin) cannot log in — treat as invalid, with no
// distinct error so a deleted account isn't enumerable.
if (!user || !ok || user.deletedAt) {
return reply.code(401).send({ error: "invalid credentials" }); return reply.code(401).send({ error: "invalid credentials" });
} }
@@ -139,4 +170,52 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
return { theme }; return { theme };
}, },
); );
// Edit MY own display name / email (any signed-in user; no permission needed — it only
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
app.put<{ Body: ProfileBody }>(
"/api/auth/profile",
{ preHandler: requireAuth },
async (req, reply) => {
const fullName = cleanProfileField(req.body?.fullName);
const email = cleanProfileField(req.body?.email);
const patch: Record<string, string | null> = {};
if (fullName !== undefined) patch.fullName = fullName;
if (email !== undefined) patch.email = email;
if (Object.keys(patch).length === 0) {
return reply.code(400).send({ error: "nothing to update" });
}
await db.update(users).set(patch).where(eq(users.id, req.user.sub)).run();
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
if (!row) return reply.code(401).send({ error: "session no longer valid" });
return sessionView(db, row);
},
);
// Change MY own password — must prove the CURRENT one first (defends against a walked-up,
// already-logged-in booth: a passerby can't silently re-key the account). New password
// >= MIN_PASSWORD. Distinct from the admin reset (users.ts), which needs no current pw.
app.put<{ Body: PasswordBody }>(
"/api/auth/password",
{ preHandler: requireAuth },
async (req, reply) => {
const currentPassword = req.body?.currentPassword ?? "";
const newPassword = req.body?.newPassword ?? "";
if (newPassword.length < MIN_PASSWORD) {
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
}
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
if (!row) {
clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" });
}
const ok = await bcrypt.compare(currentPassword, row.passwordHash);
if (!ok) {
return reply.code(403).send({ error: "current password is incorrect" });
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await db.update(users).set({ passwordHash }).where(eq(users.id, req.user.sub)).run();
return { ok: true };
},
);
} }
@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Fastify, { type FastifyInstance as RawFastify } from "fastify";
import { createTestDb } from "@parking/db/testing";
import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { hikvisionAlarmRoutes } from "./hikvision-alarm.js";
import type { AnprBridge } from "../anpr-entry.js";
import { seedUser, login } from "../test-helpers.js";
// Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle-
// detection POST from the camera's configured IP is accepted, summarized (eventType /
// target / plate pulled out of the XML), and recorded verbatim as a kind:"alarm"
// device_event — while a wrong source IP or a push-disabled device is refused.
const CAM_IP = "10.0.10.121";
const CAM_ID = "cam-1";
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
function seedHikCamera(cfg: Record<string, unknown> = {}) {
db.insert(devices).values({
id: CAM_ID,
category: "camera",
driverId: "hikvision",
config: { host: CAM_IP, alarmPushEnabled: true, ...cfg },
enabled: true,
}).run();
}
/** A representative Hikvision smart-event POST body (vehicle target). The real firmware
* payload may differ; the endpoint stores it verbatim regardless — this asserts the
* best-effort summary extraction over a plausible shape. */
const VEHICLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
<EventNotificationAlert version="2.0" xmlns="http://www.hikvision.com/ver20/XMLSchema">
<ipAddress>10.0.10.121</ipAddress>
<channelID>1</channelID>
<dateTime>2026-06-22T10:15:30+02:00</dateTime>
<eventType>fielddetection</eventType>
<eventState>active</eventState>
<DetectionRegionList>
<DetectionRegionEntry><detectionTarget>vehicle</detectionTarget></DetectionRegionEntry>
</DetectionRegionList>
</EventNotificationAlert>`;
function alarmEvents(): { detail: Record<string, unknown> }[] {
return db
.select()
.from(deviceEventsTable)
.where(and(eq(deviceEventsTable.deviceId, CAM_ID), eq(deviceEventsTable.kind, "alarm")))
.all() as { detail: Record<string, unknown> }[];
}
/** Every recorded push for a device — accepted (kind:"alarm") AND rejected
* (kind:"alarm-rejected"). */
function allRecorded(deviceId: string): { kind: string; detail: Record<string, unknown> }[] {
return db
.select()
.from(deviceEventsTable)
.where(and(eq(deviceEventsTable.deviceId, deviceId), inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"])))
.all() as { kind: string; detail: Record<string, unknown> }[];
}
describe("Hikvision Alarm Server push", () => {
it("accepts a vehicle event from the camera IP and records it with a parsed summary", async () => {
seedHikCamera();
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
const events = alarmEvents();
expect(events).toHaveLength(1);
const d = events[0]!.detail;
expect(d.source).toBe("hikvision-alarm-server");
expect(d.eventType).toBe("fielddetection");
expect(d.target).toBe("vehicle");
expect(d.ip).toBe(CAM_IP);
// The raw body is kept verbatim for inspection.
expect(String(d.rawHead)).toContain("EventNotificationAlert");
});
it("accepts the legacy string \"true\" for alarmPushEnabled (setup form quirk)", async () => {
// The setup checkbox historically saved a STRING "true" instead of a boolean; the
// guard must coerce it, not silently reject a feature the admin enabled.
seedHikCamera({ alarmPushEnabled: "true" });
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()).toHaveLength(1);
});
it("pulls a plate out of an ANPR-style payload when present", async () => {
seedHikCamera();
const anpr = `<EventNotificationAlert><eventType>ANPR</eventType>
<ANPR><plateNumber>AA123BB</plateNumber></ANPR></EventNotificationAlert>`;
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: anpr,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()[0]!.detail.plate).toBe("AA123BB");
});
it("accepts an unknown/JSON content-type as raw bytes (discovery-first)", async () => {
seedHikCamera();
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/octet-stream" },
payload: Buffer.from('{"eventType":"vehicleDetection"}'),
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()[0]!.detail.eventType).toBe("vehicleDetection");
});
it("accepts a push from ANY source IP when skipSourceIpCheck is set (WSL rewrites it)", async () => {
// WSL mirrored mode rewrites the inbound source to the host's own IP, so the camera's
// real IP never survives and a strict check rejects every push. With the opt-out, a
// push from the 'wrong' IP is accepted.
seedHikCamera({ skipSourceIpCheck: true });
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: "10.0.10.203", // the rewritten host IP, NOT the camera's
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()).toHaveLength(1);
expect(alarmEvents()[0]!.detail.target).toBe("vehicle");
});
it("rejects a push from a DIFFERENT source IP (404, nothing recorded)", async () => {
seedHikCamera();
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: "10.0.10.200", // not the camera
});
expect(res.statusCode).toBe(404);
// No ACCEPTED alarm...
expect(alarmEvents()).toHaveLength(0);
// ...but the rejection IS recorded (with the reason), so "nothing arrived" is never
// ambiguous — you can see it came in and why it was refused.
const recorded = allRecorded(CAM_ID);
expect(recorded).toHaveLength(1);
expect(recorded[0]!.kind).toBe("alarm-rejected");
expect(String(recorded[0]!.detail.reason)).toMatch(/source IP/i);
});
it("rejects when alarm push is disabled on the device", async () => {
seedHikCamera({ alarmPushEnabled: false });
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(404);
});
it("rejects an unknown device id", async () => {
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/nope/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(404);
expect(res.json().reason).toMatch(/unknown device/i);
});
it("GET /api/devices/hikvision/alarms lists accepted AND rejected pushes, newest first", async () => {
seedHikCamera();
// One accepted (right IP) + one rejected (wrong IP).
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: CAM_IP });
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: "10.0.10.200" });
const { username, password } = await seedUser(db, { username: "admin1", roleId: "admin" });
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms", headers: { cookie } });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.count).toBe(2);
// Both accepted and rejected appear, with the accepted/reason flags.
expect(body.alarms.some((a: { accepted: boolean }) => a.accepted === true)).toBe(true);
const rejected = body.alarms.find((a: { accepted: boolean }) => a.accepted === false);
expect(rejected.reason).toMatch(/source IP/i);
});
it("the alarms read endpoint is gated (device:read) — 401 without a session", async () => {
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms" });
expect(res.statusCode).toBe(401);
});
});
// The ANPR bridge is handed each vehicle detection (fire-and-forget). We register the
// routes on a bare instance with a SPY bridge to assert exactly when it's invoked —
// only on a vehicle target that isn't `inactive`. (The bridge's own logic is covered in
// anpr-entry.test.ts.)
describe("Hikvision Alarm Server → ANPR bridge wiring", () => {
let rawApp: RawFastify;
let rawDb: Db;
let rawClose: () => void;
let onVehicleDetected: ReturnType<typeof vi.fn>;
beforeEach(async () => {
const t = createTestDb();
rawDb = t.db;
rawClose = t.close;
onVehicleDetected = vi.fn(async () => {});
const bridge = { onVehicleDetected } as unknown as AnprBridge;
rawApp = Fastify();
await hikvisionAlarmRoutes(rawApp, rawDb, undefined, bridge);
await rawApp.ready();
rawDb.insert(devices).values({
id: CAM_ID,
category: "camera",
driverId: "hikvision",
config: { host: CAM_IP, alarmPushEnabled: true },
enabled: true,
}).run();
});
afterEach(async () => {
await rawApp.close();
rawClose();
});
async function post(payload: string) {
return rawApp.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload,
remoteAddress: CAM_IP,
});
}
it("hands a vehicle (active) detection to the bridge", async () => {
const res = await post(VEHICLE_XML);
expect(res.statusCode).toBe(200);
expect(onVehicleDetected).toHaveBeenCalledTimes(1);
expect(onVehicleDetected).toHaveBeenCalledWith(CAM_ID);
});
it("does NOT call the bridge for a human target", async () => {
const human = VEHICLE_XML.replace("vehicle", "human");
await post(human);
expect(onVehicleDetected).not.toHaveBeenCalled();
});
it("does NOT call the bridge on an `inactive` (leave) vehicle event", async () => {
const leave = VEHICLE_XML.replace("<eventState>active</eventState>", "<eventState>inactive</eventState>");
await post(leave);
expect(onVehicleDetected).not.toHaveBeenCalled();
});
});
+280
View File
@@ -0,0 +1,280 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { deviceEvents } from "../device-events.js";
import { requirePermission } from "../auth.js";
import { verifyDigest } from "../digest-auth.js";
import type { LaneStatus } from "../lane-status.js";
import type { AnprBridge } from "../anpr-entry.js";
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
// Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm
// Settings → Alarm Server) HTTP-POST an EventNotificationAlert to a URL we host every
// time the chosen target is detected. This is the same machine-call pattern as the
// Dingtian Input Link push (routes/devices.ts): source-IP guarded, NOT behind the SPA
// cookie/CSRF.
//
// DISCOVERY-FIRST. Hik's push format varies by model/firmware (event XML, or multipart
// with an attached JPEG, or — on some ANPR units — an <ANPR>/<plateNumber> block). So
// this endpoint is deliberately PERMISSIVE: it accepts ANY content-type as raw bytes,
// records the verbatim body as a `kind:"alarm"` device_event, and best-effort extracts a
// summary (eventType / target / plate). The goal of this first cut is to SEE exactly what
// a given camera sends — inspect via GET /api/events or the logs — before we wire it into
// the read bus / a snapshot trigger. It never opens a barrier (a plate read is advisory,
// never the sole reason; see wiki/concepts/append-only-event-chain.md).
//
// See wiki/entities/lpr-camera.md, wiki/concepts/device-input-flow.md.
interface HikDeviceConfig {
host?: string;
alarmPushEnabled?: boolean | string | number;
pushUser?: string;
pushPassword?: string;
/** Skip the source-IP guard for this device's pushes. The source IP is the primary
* LAN guard, but it's UNRELIABLE in some environments — notably WSL mirrored mode,
* which rewrites an inbound packet's source to the host's OWN address, so the camera's
* real IP never survives and a strict check rejects every push. When pushUser/
* pushPassword (Digest) are set, that auth is the real guard and source-IP adds little;
* this flag lets a deployment opt out. The signed ledger remains the anti-fraud truth. */
skipSourceIpCheck?: boolean | string | number;
}
/** Coerce a device-config flag to a boolean. The config is loosely-typed JSON from the
* setup form, which has historically stored a checkbox as the STRING "true" (a form-
* serialization quirk) — so accept true / "true" / 1 / "1" / "yes" / "on", reject the
* rest. Being lenient here means a stray "true" never silently disables a real feature. */
function isOn(v: unknown): boolean {
if (v === true) return true;
if (typeof v === "number") return v === 1;
if (typeof v === "string") return /^(1|true|yes|on)$/i.test(v.trim());
return false;
}
/** A best-effort summary pulled out of the raw push body (XML or JSON), for the device
* event detail + the log line. Absent fields just mean "not found in this firmware's
* payload" — the raw body is always stored so nothing is lost. */
interface AlarmSummary {
eventType?: string;
/** `active` (target entered the region) | `inactive` (target left). The edge that
* drives lane busy/free — see [[lpr-camera]] / hikvision-alarm.ts. */
eventState?: string;
target?: string;
plate?: string;
dateTime?: string;
channelId?: string;
}
function clientIp(req: FastifyRequest): string {
return req.ip.replace(/^::ffff:/, "");
}
/** First capture group of `re` in `s`, trimmed, or undefined. */
function pick(s: string, re: RegExp): string | undefined {
const m = re.exec(s);
return m?.[1]?.trim() || undefined;
}
/**
* Best-effort summary extraction. Hikvision event XML uses tags like <eventType>,
* <dateTime>, <channelID>; smart/ANPR events add target/plate tags whose exact names
* vary by firmware (<detectionTarget>, <targetType>, <plateNumber>, <licensePlate>).
* We probe several spellings; whatever doesn't match is simply absent. JSON bodies are
* scanned for the same keys.
*/
function summarize(body: string): AlarmSummary {
return {
eventType: pick(body, /<eventType>([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i),
eventState: pick(body, /<eventState>([^<]+)<\/eventState>/i) ?? pick(body, /"eventState"\s*:\s*"([^"]+)"/i),
target:
pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ??
pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/i),
plate:
pick(body, /<(?:plateNumber|licensePlate|plateNo)>([^<]+)<\//i) ??
pick(body, /"(?:plateNumber|licensePlate|plateNo)"\s*:\s*"([^"]+)"/i),
dateTime: pick(body, /<dateTime>([^<]+)<\/dateTime>/i),
channelId: pick(body, /<channelID>([^<]+)<\/channelID>/i) ?? pick(body, /<channelId>([^<]+)<\/channelId>/i),
};
}
export async function hikvisionAlarmRoutes(
app: FastifyInstance,
db: Db,
laneStatus?: LaneStatus,
anprBridge?: AnprBridge,
): Promise<void> {
// Accept ANY content-type as a raw Buffer (the camera may POST application/xml,
// multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415
// or empty these — we want the bytes verbatim. Scoped to THIS app instance via a
// wildcard parser; a 10 MB cap covers an event + an attached frame.
app.addContentTypeParser("*", { parseAs: "buffer", bodyLimit: 10 * 1024 * 1024 }, (_req, body, done) => {
done(null, body);
});
/** Record EVERY push (accepted or rejected) as a device_event so the read endpoint /
* DB always shows that SOMETHING arrived — the key fix: a rejected push used to log a
* warning and vanish, so "no event" was ambiguous (never sent? or sent + rejected?). */
function record(args: {
deviceId: string;
method: string;
accepted: boolean;
reason?: string;
ip: string;
contentType: string;
raw: Buffer;
summary: AlarmSummary;
}): void {
try {
db.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: args.deviceId,
category: "camera",
kind: args.accepted ? "alarm" : "alarm-rejected",
detail: {
source: "hikvision-alarm-server",
accepted: args.accepted,
method: args.method,
...(args.reason ? { reason: args.reason } : {}),
ip: args.ip,
contentType: args.contentType,
bytes: args.raw.length,
...args.summary,
// Readable head verbatim (the XML part); truncated to keep the row small.
rawHead: args.raw.toString("utf8").slice(0, 8000),
},
occurredAt: new Date().toISOString(),
})
.run();
} catch (err) {
app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`);
}
}
const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => {
const { deviceId } = req.params;
const method = req.method;
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
const cfg = row?.config as HikDeviceConfig | undefined;
const ip = clientIp(req);
const contentType = String(req.headers["content-type"] ?? "");
const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
const summary = summarize(raw.toString("utf8"));
// Log EVERY hit immediately (method + ip + size), before any guard — so even a probe
// that gets rejected is visible in the dev log the instant it arrives.
app.log.info(`[hik-alarm:${deviceId}] HIT ${method} from ${ip} (${contentType || "no-ct"} ${raw.length}B)`);
// Guard: must be a known hikvision device with alarm-push enabled, posting from its
// configured host IP. Source-IP is the primary guard on the LAN (like the Dingtian).
// On rejection we STILL record it (with the precise reason) so a push that reached us
// never silently disappears — that's what makes "is it coming?" answerable.
// The source-IP check is skipped when the device opts out (skipSourceIpCheck) — needed
// where the network rewrites the inbound source IP (e.g. WSL mirrored mode rewrites it
// to the host's own address), so a strict match can never pass. Digest auth (when set)
// and the signed ledger remain the real guards. See HikDeviceConfig.skipSourceIpCheck.
const skipIp = isOn(cfg?.skipSourceIpCheck);
let reason: string | null = null;
if (!row || !cfg) reason = "unknown device id";
else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`;
else if (!isOn(cfg.alarmPushEnabled)) reason = "alarm push not enabled on this device (tick it in Setup)";
else if (!cfg.host) reason = "device has no host IP configured";
else if (!skipIp && ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host} (set skipSourceIpCheck if the network rewrites it, e.g. WSL)`;
if (reason) {
app.log.warn(`[hik-alarm:${deviceId}] REJECTED ${method} from ${ip} (${contentType} ${raw.length}B): ${reason}`);
record({ deviceId, method, accepted: false, reason, ip, contentType, raw, summary });
return reply.code(404).send({ error: "not found", reason });
}
// Optional Digest auth — only when the admin configured push creds (some firmware
// can't authenticate the Alarm Server call; then we rely on source-IP alone).
if (cfg!.pushUser && cfg!.pushPassword) {
if (!verifyDigest(req, reply, { user: cfg!.pushUser, password: cfg!.pushPassword })) {
record({ deviceId, method, accepted: false, reason: "digest auth failed/challenge", ip, contentType, raw, summary });
return; // 401 challenge already sent
}
}
// Loud log so the operator can SEE the payload during testing.
app.log.info(
`[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` +
`event=${summary.eventType ?? "?"}/${summary.eventState ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
);
record({ deviceId, method, accepted: true, ip, contentType, raw, summary });
// Lane busy/free: a VEHICLE detection marks the camera's bound lane busy (advisory,
// for the booth barrier lights). Only on a vehicle target that's `active` — an
// `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a
// timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent.
const isVehicleActive =
(summary.target ?? "").toLowerCase() === "vehicle" &&
(summary.eventState ?? "active").toLowerCase() !== "inactive";
if (laneStatus && isVehicleActive) {
laneStatus.vehicleDetected(deviceId);
}
// ANPR BRIDGE: on a vehicle detection, if this camera opts into ANPR (config.anpr),
// pull a snapshot → read the plate → if it matches a SUBSCRIBER, emit a plate read
// onto the bus, which the existing gated SubscriptionFlow turns into an entry/exit +
// barrier open. Fire-and-forget — NEVER awaited on the 200 path (the camera must get
// a prompt ack or it retry-storms), and fail-soft inside the bridge. See anpr-entry.ts.
if (anprBridge && isVehicleActive) {
void anprBridge.onVehicleDetected(deviceId);
}
// Surface on the in-process bus as a generic breadcrumb so a live listener can show
// "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving
// entry/exit) is the deliberate next step once we know the real payload.
deviceEvents.emitInput({ driverId: "hikvision", deviceId, input: 0, edge: "on", at: new Date().toISOString(), source: "push" });
// 200 so the camera considers the alarm delivered and doesn't retry-storm.
return reply.code(200).send({ ok: true });
};
// Listen for EVERY method on the event path. The camera (and its "Test" button) may
// probe with GET/HEAD/OPTIONS/PUT, not just POST — and a method we don't register gets
// Fastify's generic 404, which the camera reads as "service available" while our
// handler never runs (so nothing is recorded). Registering all methods means ANYTHING
// that hits this URL reaches `handle` and is captured (the method is logged + stored),
// so we can finally SEE exactly what the camera sends. See wiki/entities/lpr-camera.md.
// (HEAD is auto-added by Fastify alongside GET — don't register it explicitly.)
for (const method of ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
app.route({ method, url: "/api/devices/hikvision/:deviceId/event", handler: handle });
}
// Read endpoint: the recent alarm pushes (accepted AND rejected), newest first — so you
// can SEE in the browser whether events are arriving and why any were refused, instead
// of grepping the dev log or querying SQLite. Gated device:read (admin device view).
app.get<{ Querystring: { limit?: string } }>(
"/api/devices/hikvision/alarms",
{ preHandler: requirePermission("device:read") },
async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
const rows = db
.select()
.from(deviceEventsTable)
.where(inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"]))
.orderBy(desc(deviceEventsTable.occurredAt))
.limit(limit)
.all();
const alarms = rows.map((r) => {
const d = (r.detail ?? {}) as Record<string, unknown>;
return {
at: r.occurredAt,
deviceId: r.deviceId,
accepted: d.accepted === true,
method: (d.method as string) ?? null,
reason: (d.reason as string) ?? null,
ip: (d.ip as string) ?? null,
contentType: (d.contentType as string) ?? null,
bytes: (d.bytes as number) ?? 0,
eventType: (d.eventType as string) ?? null,
eventState: (d.eventState as string) ?? null,
target: (d.target as string) ?? null,
plate: (d.plate as string) ?? null,
rawHead: (d.rawHead as string) ?? null,
};
});
return { count: alarms.length, alarms };
},
);
}
+29
View File
@@ -8,6 +8,7 @@ import {
type PayStation, type PayStation,
} from "../pay-station.js"; } from "../pay-station.js";
import type { ExitFlow } from "../exit-flow.js"; import type { ExitFlow } from "../exit-flow.js";
import type { VoidFlow } from "../void-flow.js";
import { NoShiftOpenError, type ShiftService } from "../shift-service.js"; import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
import { printPaymentReceipt } from "../booth-print.js"; import { printPaymentReceipt } from "../booth-print.js";
@@ -36,6 +37,10 @@ interface VoucherBody {
interface ReceiptBody { interface ReceiptBody {
identity: string; identity: string;
} }
interface VoidBody {
identity: string;
reason: string;
}
export async function payRoutes( export async function payRoutes(
app: FastifyInstance, app: FastifyInstance,
@@ -43,6 +48,7 @@ export async function payRoutes(
payStation: PayStation, payStation: PayStation,
exitFlow: ExitFlow, exitFlow: ExitFlow,
shift: ShiftService, shift: ShiftService,
voidFlow: VoidFlow,
): Promise<void> { ): Promise<void> {
// Reads (lookup, active sessions, quote) need session/payment read; the booth // Reads (lookup, active sessions, quote) need session/payment read; the booth
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A // money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
@@ -50,6 +56,7 @@ export async function payRoutes(
// sessions. Read-only callers (a viewer role) get the reads but not the actions. // sessions. Read-only callers (a viewer role) get the reads but not the actions.
const guard = requirePermission("payment:create"); const guard = requirePermission("payment:create");
const readGuard = requirePermission("session:read"); const readGuard = requirePermission("session:read");
const voidGuard = requirePermission("event:void");
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/ // Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
// re-open is processed, so every taking is attributed to a shift (one operator's // re-open is processed, so every taking is attributed to a shift (one operator's
@@ -125,6 +132,28 @@ export async function payRoutes(
}, },
); );
// Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event
// referencing the entry, with the operator + a REQUIRED reason — the entry itself is
// never edited/deleted (append-only). The session projection folds the void to CLOSED,
// so the voided car stops counting inside and can't be paid/exited. Opens NO barrier
// (the misprinted ticket's car never entered). Gated on event:void + an open shift
// (the booth accountability period). Refusals (subscription / already exited / already
// voided / already paid) → 409. See void-flow.ts, wiki/concepts/append-only-event-chain.md.
app.post<{ Body: VoidBody }>(
"/api/tickets/void",
{ preHandler: [voidGuard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
const reason = (req.body?.reason ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
if (!reason) return reply.code(400).send({ error: "a cancellation reason is required" });
const operator = req.user?.username ?? "unknown";
const res = await voidFlow.voidTicket({ identity, reason, operator });
if (!res.ok) return reply.code(409).send({ error: res.reason });
return reply.code(201).send(res);
},
);
// Quote: what does this session owe right now? (No side effect.) // Quote: what does this session owe right now? (No side effect.)
app.get<{ Querystring: QuoteQuery }>( app.get<{ Querystring: QuoteQuery }>(
"/api/pay/quote", "/api/pay/quote",
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { eq, users, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// Self-service profile (routes/auth.ts): /api/auth/profile + /api/auth/password. These act
// ONLY on the signed-in user, need NO `user:*` permission (any role), and the password change
// must prove the current password. Distinct from admin user-management (routes/users.ts).
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
describe("PUT /api/auth/profile (self-service)", () => {
it("a permission-less user can edit their OWN name + email", async () => {
// 'viewer' role with NO user:* permission — profile is not gated on it.
const { username, password } = await seedUser(db, {
username: "cashier", roleId: "viewer", permissions: [],
});
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: { fullName: "Mon Kukaleshi", email: "mon@example.com" },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.fullName).toBe("Mon Kukaleshi");
expect(body.email).toBe("mon@example.com");
// Persisted to the caller's own row.
const row = db.select().from(users).where(eq(users.username, "cashier")).get();
expect(row?.fullName).toBe("Mon Kukaleshi");
expect(row?.email).toBe("mon@example.com");
});
it('clears a field when sent ""', async () => {
const { username, password } = await seedUser(db, { username: "u2", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
// First set a name…
await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: { fullName: "Old Name" },
});
// …then clear it with whitespace (→ null).
const res = await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: { fullName: " " },
});
expect(res.statusCode).toBe(200);
expect(res.json().fullName).toBeNull();
});
it("rejects an empty patch (nothing to update)", async () => {
const { username, password } = await seedUser(db, { username: "u3", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: {},
});
expect(res.statusCode).toBe(400);
});
it("requires a session (401 without a token)", async () => {
const res = await app.inject({ method: "PUT", url: "/api/auth/profile", payload: { fullName: "x" } });
expect(res.statusCode).toBe(401);
});
});
describe("PUT /api/auth/password (self-service)", () => {
it("changes the password when the current one is correct, and the new one then logs in", async () => {
const { username, password } = await seedUser(db, { username: "p1", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/password",
headers: { cookie, "x-csrf-token": csrf },
payload: { currentPassword: password, newPassword: "brand-new-pw-123" },
});
expect(res.statusCode).toBe(200);
// Old password no longer works; new one does.
const oldTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
expect(oldTry.statusCode).toBe(401);
const newTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password: "brand-new-pw-123" } });
expect(newTry.statusCode).toBe(200);
});
it("refuses when the current password is wrong (403) and leaves the password unchanged", async () => {
const { username, password } = await seedUser(db, { username: "p2", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/password",
headers: { cookie, "x-csrf-token": csrf },
payload: { currentPassword: "not-it", newPassword: "brand-new-pw-123" },
});
expect(res.statusCode).toBe(403);
// Original password still works.
const still = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
expect(still.statusCode).toBe(200);
});
it("rejects a too-short new password (400)", async () => {
const { username, password } = await seedUser(db, { username: "p3", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/password",
headers: { cookie, "x-csrf-token": csrf },
payload: { currentPassword: password, newPassword: "short" },
});
expect(res.statusCode).toBe(400);
});
});
@@ -0,0 +1,114 @@
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";
// HTTP integration for soft delete + recycle bin: an admin DELETE soft-deletes (the user
// leaves the list, can't log in), the bin lists it, restore brings it back, and a deleted
// user can log in again. Drives the REAL app over a fresh in-memory DB via app.inject.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
/** Log in an admin and return the auth headers for mutations. */
async function asAdmin() {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
return { cookie, csrf };
}
describe("soft delete via the resource DELETE route", () => {
it("DELETE /api/users/:id soft-deletes: user leaves the list and can't log in, but is restorable", async () => {
const { cookie, csrf } = await asAdmin();
// Create a victim user to delete.
await seedUser(db, { username: "victim", password: "victim-pass-123", roleId: "admin" });
const victim = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
.users.find((u: { username: string; id: string }) => u.username === "victim");
expect(victim).toBeDefined();
// Delete (soft).
const del = await app.inject({
method: "DELETE", url: `/api/users/${victim.id}`,
headers: { cookie, "x-csrf-token": csrf },
});
expect(del.statusCode).toBeLessThan(300);
// Gone from the live list.
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json();
expect(list.users.some((u: { username: string }) => u.username === "victim")).toBe(false);
// Can't log in.
const relogin = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
expect(relogin.statusCode).toBe(401);
// Shows in the recycle bin.
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
expect(bin.items.some((i: { kind: string; label: string }) => i.kind === "user" && i.label === "victim")).toBe(true);
// Restore → reappears + can log in.
const restore = await app.inject({
method: "POST", url: `/api/recycle-bin/user/${victim.id}/restore`,
headers: { cookie, "x-csrf-token": csrf },
});
expect(restore.statusCode).toBeLessThan(300);
const relogin2 = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
expect(relogin2.statusCode).toBe(200);
});
it("purge permanently removes a soft-deleted user", async () => {
const { cookie, csrf } = await asAdmin();
await seedUser(db, { username: "gone", password: "gone-pass-1234", roleId: "admin" });
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
.users.find((u: { username: string }) => u.username === "gone").id;
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
const purge = await app.inject({
method: "DELETE", url: `/api/recycle-bin/user/${id}`,
headers: { cookie, "x-csrf-token": csrf },
});
expect(purge.statusCode).toBe(204);
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
expect(bin.items.some((i: { label: string }) => i.label === "gone")).toBe(false);
});
it("the recycle bin is gated — a user without recyclebin:read is 403", async () => {
const { username, password } = await seedUser(db, {
username: "plain", roleId: "plain", permissions: ["user:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } });
expect(res.statusCode).toBe(403);
});
it("recreating a user with a soft-deleted user's username gives a clear 409", async () => {
const { cookie, csrf } = await asAdmin();
await seedUser(db, { username: "dup", password: "dup-pass-12345", roleId: "admin" });
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
.users.find((u: { username: string }) => u.username === "dup").id;
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
const create = await app.inject({
method: "POST", url: "/api/users",
headers: { cookie, "x-csrf-token": csrf },
payload: { username: "dup", password: "new-pass-12345", roleId: "admin" },
});
expect(create.statusCode).toBe(409);
expect(create.json().error).toMatch(/recycle bin/i);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { requirePermission, bumpPermsCache } from "../auth.js";
import {
listRecycleBin,
purge,
restore,
restoreBlockedReason,
retentionDays,
RESOURCE_KINDS,
type ResourceKind,
} from "../recycle-bin.js";
// Recycle bin API — view / restore / purge soft-deleted master data. The actual
// soft-delete STAMP happens in each resource's own DELETE route (users/roles/
// subscriptions/plans/tariffs); this is the way back. Admin-grade (recyclebin:*).
// See recycle-bin.ts, wiki/concepts/soft-delete.md.
function isKind(s: string): s is ResourceKind {
return (RESOURCE_KINDS as string[]).includes(s);
}
export async function recycleBinRoutes(app: FastifyInstance, db: Db): Promise<void> {
// List everything in the bin (+ the retention window so the UI can warn how long
// items survive before auto-purge).
app.get(
"/api/recycle-bin",
{ preHandler: requirePermission("recyclebin:read") },
async () => ({ items: listRecycleBin(db), retentionDays: retentionDays() }),
);
// Restore a soft-deleted item (clear the stamps → it reappears in its catalog).
// Blocked with a 409 when a live row would collide (e.g. the username was reused).
app.post<{ Params: { kind: string; id: string } }>(
"/api/recycle-bin/:kind/:id/restore",
{ preHandler: requirePermission("recyclebin:update") },
async (req, reply) => {
const { kind, id } = req.params;
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
const blocked = restoreBlockedReason(db, kind, id);
if (blocked) return reply.code(409).send({ error: `cannot restore: ${blocked}` });
const ok = restore(db, kind, id);
if (!ok) return reply.code(404).send({ error: "no deleted item to restore" });
// A restored role/user changes the authz picture — drop the permission cache.
if (kind === "role" || kind === "user") bumpPermsCache();
app.log.info(`recycle-bin: restored ${kind} ${id}`);
return { kind, id, restored: true };
},
);
// Purge (permanently delete) a soft-deleted item + its children. Irreversible.
app.delete<{ Params: { kind: string; id: string } }>(
"/api/recycle-bin/:kind/:id",
{ preHandler: requirePermission("recyclebin:delete") },
async (req, reply) => {
const { kind, id } = req.params;
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
const ok = purge(db, kind, id);
if (!ok) return reply.code(404).send({ error: "no deleted item to purge" });
if (kind === "role" || kind === "user") bumpPermsCache();
app.log.warn(`recycle-bin: PURGED ${kind} ${id} (permanent)`);
return reply.code(204).send();
},
);
}
+65
View File
@@ -0,0 +1,65 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { requirePermission } from "../auth.js";
import { reportSummary, type Bucket } from "../reports.js";
// Admin reporting API. Read-only aggregation over the signed ledger (+ the sessions
// cache for durations); no writes, no new event types. Gated on `report:read` — the
// same permission the events feed/occupancy use. See reports.ts, wiki/concepts/reports.md.
const BUCKETS: Bucket[] = ["hour", "day", "month"];
/** Clamp a query into a valid [from, to) + bucket. Defaults: last 30 days, daily. */
function parseQuery(q: { from?: string; to?: string; bucket?: string }): {
from: string;
to: string;
bucket: Bucket;
} {
const now = Date.now();
const to = isFiniteIso(q.to) ? q.to! : new Date(now).toISOString();
const from = isFiniteIso(q.from) ? q.from! : new Date(now - 30 * 86_400_000).toISOString();
const bucket = BUCKETS.includes(q.bucket as Bucket) ? (q.bucket as Bucket) : "day";
// Guard the inversion (from after to) — swap rather than return an empty report.
return from <= to ? { from, to, bucket } : { from: to, to: from, bucket };
}
function isFiniteIso(s: string | undefined): boolean {
return !!s && Number.isFinite(Date.parse(s));
}
export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void> {
const guard = requirePermission("report:read");
// The whole dashboard in one call: totals, the time series, peak-hour histogram, and
// subscription stats — aggregated server-side so the SPA just renders. Bucketed in the
// site timezone. See reports.ts.
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
"/api/reports/summary",
{ preHandler: guard },
async (req) => reportSummary(db, parseQuery(req.query)),
);
// The same series as CSV (one row per bucket) for spreadsheet / accountant export.
// Amounts are in MAJOR units with 2 decimals here (a CSV is for humans/Excel), unlike
// the JSON which stays in minor units. text/csv with a download filename.
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
"/api/reports/summary.csv",
{ preHandler: guard },
async (req, reply) => {
const summary = reportSummary(db, parseQuery(req.query));
const lines = [
"bucket,entries,exits,payments,revenue",
...summary.series.map((p) =>
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
),
];
reply
.header("content-type", "text/csv; charset=utf-8")
.header(
"content-disposition",
`attachment; filename="parking-report-${summary.from.slice(0, 10)}_${summary.to.slice(0, 10)}.csv"`,
)
.send(lines.join("\n") + "\n");
},
);
}
+13 -9
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 { eq, rolePermissions, roles, users, type Db } from "@parking/db"; import { and, eq, isNull, rolePermissions, roles, 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";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js"; import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.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
// code-defined PERMISSIONS grid (resource:action), and users are assigned one // code-defined PERMISSIONS grid (resource:action), and users are assigned one
@@ -56,7 +57,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
.where(eq(rolePermissions.roleId, roleId)) .where(eq(rolePermissions.roleId, roleId))
.all() .all()
.map((r) => r.permission); .map((r) => r.permission);
const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length; const userCount = db.select().from(users).where(and(eq(users.roleId, roleId), isNull(users.deletedAt))).all().length;
// The admin role always reports the full grid (it's enforced in code). // The admin role always reports the full grid (it's enforced in code).
return { return {
id: role.id, id: role.id,
@@ -75,9 +76,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
} }
// The full permission grid (for the role-composer checkbox UI) + every role. // The full permission grid (for the role-composer checkbox UI) + every LIVE role.
// Soft-deleted roles live in the recycle bin, not here.
app.get("/api/roles", { preHandler: readGuard }, async () => { app.get("/api/roles", { preHandler: readGuard }, async () => {
const all = db.select().from(roles).all(); const all = db.select().from(roles).where(isNull(roles.deletedAt)).all();
return { return {
catalog: PERMISSIONS, catalog: PERMISSIONS,
roles: all.map((r) => roleView(r.id)).filter((r) => r != null), roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
@@ -143,23 +145,25 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
}, },
); );
// Delete a role. Refused if it's built-in or any user still holds it. // Delete a role — SOFT (recycle bin). Refused if built-in or any LIVE user still holds
// it. The row is stamped deleted (recoverable), not removed; its permission rows are
// KEPT so a restore brings the role back intact. Restore/purge from the recycle bin.
app.delete<{ Params: { id: string } }>( app.delete<{ Params: { id: string } }>(
"/api/roles/:id", "/api/roles/:id",
{ preHandler: deleteGuard }, { preHandler: deleteGuard },
async (req, reply) => { async (req, reply) => {
const id = req.params.id; const id = req.params.id;
const role = db.select().from(roles).where(eq(roles.id, id)).get(); const role = db.select().from(roles).where(and(eq(roles.id, id), isNull(roles.deletedAt))).get();
if (!role) return reply.code(404).send({ error: "role not found" }); if (!role) return reply.code(404).send({ error: "role not found" });
if (role.builtin === 1) { if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" }); return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
} }
const holders = db.select().from(users).where(eq(users.roleId, id)).all().length; // Only LIVE holders block deletion (a soft-deleted user's role assignment is moot).
const holders = db.select().from(users).where(and(eq(users.roleId, id), isNull(users.deletedAt))).all().length;
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)` });
} }
db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run(); softDelete(db, "role", id, req.user.sub);
db.delete(roles).where(eq(roles.id, id)).run();
bumpPermsCache(); bumpPermsCache();
return { ok: true }; return { ok: true };
}, },
+103
View File
@@ -0,0 +1,103 @@
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";
// HTTP integration: boot the REAL Fastify app over a fresh in-memory DB (no listen —
// app.inject drives it) and exercise the auth + RBAC guards end to end. The point is the
// security seam: no token → 401, wrong permission → 403, CSRF required on mutations, and
// a correctly-scoped user passes. (vitest.config sets JWT_SECRET/EVENT_SIGNING_KEY.)
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
describe("health + login", () => {
it("GET /health is open", async () => {
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok" });
});
it("login with bad credentials is rejected", async () => {
await seedUser(db, { username: "alice", password: "right-password" });
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "wrong" } });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
it("login with good credentials sets auth + csrf cookies", async () => {
await seedUser(db, { username: "alice", password: "right-password" });
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "right-password" } });
expect(res.statusCode).toBe(200);
const names = res.cookies.map((c) => c.name);
expect(names).toContain("parking_token");
expect(names).toContain("parking_csrf");
});
});
describe("auth guard — no token", () => {
it("GET /api/occupancy without a session is 401", async () => {
const res = await app.inject({ method: "GET", url: "/api/occupancy" });
expect(res.statusCode).toBe(401);
});
});
describe("RBAC permission gate", () => {
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
const { username, password } = await seedUser(db, {
username: "viewer", roleId: "viewer", permissions: ["site:read"],
});
const { cookie, csrf } = await login(app, username, password);
// GET allowed (site:read).
const get = await app.inject({ method: "GET", url: "/api/occupancy", headers: { cookie } });
expect(get.statusCode).toBe(200);
// PUT requires site:update — which this role lacks → 403 (with valid CSRF, so the
// 403 is the PERMISSION check, not CSRF).
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { capacity: 50 },
});
expect(put.statusCode).toBe(403);
});
it("an admin user passes the same PUT", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { capacity: 50 },
});
expect(put.statusCode).toBeLessThan(300);
});
});
describe("CSRF double-submit on mutations", () => {
it("a mutation with the auth cookie but NO csrf header is 403", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie } = await login(app, username, password);
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie }, // csrf header deliberately omitted
payload: { capacity: 50 },
});
expect(put.statusCode).toBe(403);
});
});
+74 -1
View File
@@ -4,16 +4,19 @@ import { eq, devices, setupState, type Db } from "@parking/db";
import { import {
hasPreconditions, hasPreconditions,
hasPushConfig, hasPushConfig,
isCamera,
isDiscoverable, isDiscoverable,
isHardenable, isHardenable,
registerBuiltinDrivers, registerBuiltinDrivers,
registry, registry,
setDeviceLogSink, setDeviceLogSink,
type CameraDevice,
type DeviceCategory, type DeviceCategory,
type DeviceConfig, type DeviceConfig,
} from "@parking/devices"; } from "@parking/devices";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"; import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
import type { VisionClient } from "../vision-client.js";
// First-run setup API. The admin reads the driver catalog and assigns devices // First-run setup API. The admin reads the driver catalog and assigns devices
// per lane. See wiki/concepts/first-run-setup.md. // per lane. See wiki/concepts/first-run-setup.md.
@@ -172,7 +175,11 @@ async function configureDevice(
return { config: fullConfig, warnings: hardenWarnings }; return { config: fullConfig, warnings: hardenWarnings };
} }
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function setupRoutes(
app: FastifyInstance,
db: Db,
vision?: VisionClient | null,
): Promise<void> {
registerBuiltinDrivers(); registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line)); setDeviceLogSink((line) => app.log.info(line));
@@ -261,6 +268,72 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
}, },
); );
// Test ANPR end-to-end on a camera config WITHOUT saving: capture a live snapshot
// off the camera and run it through the vision (ANPR) service, reporting whether a
// plate was extracted, the read, and how long it took. Lets the admin verify the
// camera→vision pipeline before committing the camera's `anpr` opt-in. Advisory +
// fail-soft, exactly like the runtime path (snapshot.ts): a vision failure is a
// reported "no plate", never a 500. See wiki/entities/opencv-anpr-service.md.
app.post<{ Body: TestBody }>(
"/api/setup/test-anpr",
{ preHandler: adminGuard },
async (req, reply) => {
const { driverId, config } = req.body;
const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
if (driver.category !== "camera") {
return reply.code(400).send({ error: `driver ${driverId} is not a camera` });
}
if (!vision?.enabled) {
// The vision service is off (VISION_ENABLED unset) — there's nothing to test
// against. Report it cleanly so the UI can say "enable vision first".
return reply.send({ ok: false, reason: "vision-disabled" });
}
let device;
try {
device = registry.create(driverId, config);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
if (!isCamera(device)) {
return reply.code(400).send({ error: `driver ${driverId} cannot capture snapshots` });
}
// 1) Grab a frame off the camera. A camera/network failure here is the failure
// we're testing for — report it, don't 500.
const startedAt = Date.now();
let shot: Awaited<ReturnType<CameraDevice["captureSnapshot"]>>;
try {
shot = await device.captureSnapshot({ direction: "entry" });
} catch (err) {
return reply.send({
ok: false,
reason: "snapshot-failed",
detail: (err as Error).message,
tookMs: Date.now() - startedAt,
});
}
// 2) Run the same advisory analyze the runtime path uses. `analyze` is fail-soft
// (null on any error/timeout) and applies the confidence floor.
const result = await vision.analyze(shot.bytes, shot.contentType);
const tookMs = Date.now() - startedAt;
if (!result || !result.plate) {
return reply.send({ ok: false, reason: "no-plate", tookMs });
}
return reply.send({
ok: true,
plate: result.plate.text.trim().toUpperCase(),
confidence: result.plate.confidence,
region: result.plate.region ?? null,
lowConfidence: result.lowConfidence,
modelVersion: result.modelVersion,
tookMs,
});
},
);
// Candidate backend IPs the device can push to, for a given device host. The // Candidate backend IPs the device can push to, for a given device host. The
// wizard pre-fills with the on-subnet one and lets the admin override (matters // wizard pre-fills with the on-subnet one and lets the admin override (matters
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md. // on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
+11
View File
@@ -32,6 +32,9 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not /** Reserve a spot in occupancy for each active subscriber's car(s), even when not
* parked — so transients see "full" sooner and the subscriber's spot is held. */ * parked — so transients see "full" sooner and the subscriber's spot is held. */
reserveSubscriberSpots?: boolean; reserveSubscriberSpots?: boolean;
/** 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. */
anprEntryEnabled?: boolean;
} }
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default /** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
@@ -41,6 +44,7 @@ type SiteConfig = {
exitVoucherDefault: boolean; exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null; subscriptionMonthlyPriceMinor: number | null;
reserveSubscriberSpots: boolean; reserveSubscriberSpots: boolean;
anprEntryEnabled: boolean;
} & Record<TextField, string | null>; } & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
@@ -49,6 +53,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
exitVoucherDefault: row?.exitVoucherDefault ?? false, exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null, subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false, reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
anprEntryEnabled: row?.anprEntryEnabled ?? true,
} 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;
@@ -106,6 +111,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
patch.reserveSubscriberSpots = body.reserveSubscriberSpots; patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
} }
if ("anprEntryEnabled" in body) {
if (typeof body.anprEntryEnabled !== "boolean") {
return reply.code(400).send({ error: "anprEntryEnabled must be a boolean" });
}
patch.anprEntryEnabled = body.anprEntryEnabled;
}
for (const f of TEXT_FIELDS) { for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]); if (f in body) patch[f] = normText(body[f]);
} }
+21 -5
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 { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db"; import { and, desc, eq, isNull, subscriptionPlans, subscriptions, type Db } from "@parking/db";
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared"; import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
import { siteTz } from "../subscription-window.js"; import { siteTz } from "../subscription-window.js";
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS // Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
@@ -75,7 +76,14 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
// per planId (latest active version with effectiveFrom ≤ now). Operators selling // per planId (latest active version with effectiveFrom ≤ now). Operators selling
// need the current list; the admin catalog screen asks for ?all=1. // need the current list; the admin catalog screen asks for ?all=1.
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => { app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all(); // Exclude soft-deleted plan versions — those live in the recycle bin. (A plan is
// versioned; a soft-delete stamps every version row of the planId.)
const rows = db
.select()
.from(subscriptionPlans)
.where(isNull(subscriptionPlans.deletedAt))
.orderBy(desc(subscriptionPlans.effectiveFrom))
.all();
if (req.query?.all) return { plans: rows }; if (req.query?.all) return { plans: rows };
const now = new Date().toISOString(); const now = new Date().toISOString();
// Newest-effective active version wins per planId. // Newest-effective active version wins per planId.
@@ -154,12 +162,19 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any // DELETE a plan entirely — allowed ONLY when NO subscription references it (any
// version). A referenced plan version MUST survive: a subscription's planVersionId is // version). A referenced plan version MUST survive: a subscription's planVersionId is
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count // needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
// when in use (the admin should retire instead). Removes all versions of the planId. // when in use (the admin should retire instead). SOFT delete (recycle bin): stamps all
// versions of the planId; a restore brings the plan back; purge does the real removal.
app.delete<{ Params: { planId: string } }>( app.delete<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId", "/api/subscription-plans/:planId",
{ preHandler: planGuard }, { preHandler: planGuard },
async (req, reply) => { async (req, reply) => {
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all(); // Only LIVE subscriptions block deletion (a soft-deleted subscriber's planId ref is
// itself in the bin; if it's restored later, the plan can be restored too).
const refs = db
.select()
.from(subscriptions)
.where(and(eq(subscriptions.planId, req.params.planId), isNull(subscriptions.deletedAt)))
.all();
if (refs.length > 0) { if (refs.length > 0) {
return reply.code(409).send({ return reply.code(409).send({
error: "plan is in use and cannot be deleted", error: "plan is in use and cannot be deleted",
@@ -167,7 +182,8 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
subscribers: refs.length, subscribers: refs.length,
}); });
} }
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run(); const ok = softDelete(db, "plan", req.params.planId, req.user.sub);
if (!ok) return reply.code(404).send({ error: "plan not found" });
return { planId: req.params.planId, deleted: true }; return { planId: req.params.planId, deleted: true };
}, },
); );
+12 -9
View File
@@ -1,9 +1,10 @@
import { randomBytes, randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, 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 type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
import { requirePermission, roleHasPermissions } from "../auth.js"; import { requirePermission, roleHasPermissions } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
import { invalidateHolder } from "../event-enrich.js"; import { invalidateHolder } from "../event-enrich.js";
import { printSubscriptionCard } from "../booth-print.js"; import { printSubscriptionCard } from "../booth-print.js";
import type { CredentialCapture } from "../credential-capture.js"; import type { CredentialCapture } from "../credential-capture.js";
@@ -226,9 +227,10 @@ export async function subscriptionRoutes(
return { plan, validFrom, validTo, quantity, quote }; return { plan, validFrom, validTo, quantity, quote };
} }
// List all subscriptions (with their credentials + plates). // List all LIVE subscriptions (with their credentials + plates). Soft-deleted ones
// live in the recycle bin, not here.
app.get("/api/subscriptions", { preHandler: readGuard }, async () => { app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
const rows = db.select().from(subscriptions).all(); const rows = db.select().from(subscriptions).where(isNull(subscriptions.deletedAt)).all();
return { subscriptions: rows.map((r) => loadAggregate(r.id)) }; return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
}); });
@@ -532,16 +534,17 @@ export async function subscriptionRoutes(
}, },
); );
// Hard delete a subscription + its child rows. (Past ledger events that reference it // Delete a subscription — SOFT (recycle bin). The row + its credential/plate children
// are untouched — the audit trail is append-only and independent of this row.) // are KEPT (stamped deleted) so a restore brings the subscriber back intact; it leaves
// the catalog and stops opening the barrier (the entry flow filters deleted). Past
// ledger events that reference it are untouched (append-only). Restore/purge from the
// recycle bin. (Distinct from /revoke, which BARS but keeps the subscriber visible.)
app.delete<{ Params: { id: string } }>( app.delete<{ Params: { id: string } }>(
"/api/subscriptions/:id", "/api/subscriptions/:id",
{ preHandler: deleteGuard }, { preHandler: deleteGuard },
async (req, reply) => { async (req, reply) => {
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run(); const ok = softDelete(db, "subscription", req.params.id, req.user.sub);
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" }); if (!ok) return reply.code(404).send({ error: "subscription not found" });
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
invalidateHolder(req.params.id); invalidateHolder(req.params.id);
return reply.code(204).send(); return reply.code(204).send();
}, },
+6 -3
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db"; import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { import {
computeFee, computeFee,
isTariffV2, isTariffV2,
@@ -48,9 +48,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
// Publishing a new version changes what customers are charged. // Publishing a new version changes what customers are charged.
const writeGuard = requirePermission("tariff:update"); const writeGuard = requirePermission("tariff:update");
// The single site tariff row, created on first read/publish. // The single site tariff row, created on first read/publish. A soft-deleted (recycle-
// bin) tariff is ignored here so a fresh one is created — the deleted one waits in the
// bin for restore/purge. (Tariffs have soft-delete support for completeness; today the
// site runs one tariff and there's no delete button — recovery is via the recycle bin.)
function ensureSiteTariff(): string { function ensureSiteTariff(): string {
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get(); const existing = db.select().from(tariffs).where(and(eq(tariffs.scope, "site"), isNull(tariffs.deletedAt))).get();
if (existing) return existing.id; if (existing) return existing.id;
const id = randomUUID(); const id = randomUUID();
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run(); db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
+22 -10
View File
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
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 { 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 { permissionsFor, requirePermission } from "../auth.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
// install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one // install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one
@@ -64,9 +65,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
const updateGuard = requirePermission("user:update"); const updateGuard = requirePermission("user:update");
const deleteGuard = requirePermission("user:delete"); const deleteGuard = requirePermission("user:delete");
/** Count users currently holding the protected admin role. */ /** Count LIVE users currently holding the protected admin role. A soft-deleted admin
* doesn't count — they can't log in — so the no-lockout check uses live admins only. */
function adminCount(): number { function adminCount(): number {
return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length; return db.select().from(users).where(and(eq(users.roleId, ADMIN_ROLE_ID), isNull(users.deletedAt))).all().length;
} }
/** True if removing/relocating `userId` from admin would leave zero admins. */ /** True if removing/relocating `userId` from admin would leave zero admins. */
@@ -112,9 +114,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
return false; return false;
} }
// List all users (no password hashes) + their role names for display. // List all LIVE users (no password hashes) + their role names for display. Soft-deleted
// users live in the recycle bin, not here.
app.get("/api/users", { preHandler: readGuard }, async () => { app.get("/api/users", { preHandler: readGuard }, async () => {
const rows = db.select().from(users).all(); const rows = db.select().from(users).where(isNull(users.deletedAt)).all();
const roleRows = db.select().from(roles).all(); const roleRows = db.select().from(roles).all();
const roleName = new Map(roleRows.map((r) => [r.id, r.name])); const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
return { return {
@@ -140,8 +143,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (exceedsCaller(req.user.roleId, roleId)) { if (exceedsCaller(req.user.roleId, roleId)) {
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" }); return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
} }
if (db.select().from(users).where(eq(users.username, username)).get()) { const clash = db.select().from(users).where(eq(users.username, username)).get();
return reply.code(409).send({ error: "username already exists" }); if (clash) {
// The username is UNIQUE across live AND soft-deleted rows. If a DELETED user holds
// it, point the admin at the recycle bin (restore or purge) rather than a bare 409.
return reply.code(409).send({
error: clash.deletedAt
? "username belongs to a deleted user — restore or purge it from the recycle bin first"
: "username already exists",
});
} }
const id = randomUUID(); const id = randomUUID();
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
@@ -221,13 +231,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
}, },
); );
// Delete a user. Refused if it's the last admin (no-lockout). // Delete a user — SOFT (recycle bin). Refused if it's the last admin (no-lockout).
// The row is stamped deleted (recoverable), not removed; it vanishes from the list and
// can't log in. Restore/purge from the recycle bin. See recycle-bin.ts.
app.delete<{ Params: { id: string } }>( app.delete<{ Params: { id: string } }>(
"/api/users/:id", "/api/users/:id",
{ preHandler: deleteGuard }, { preHandler: deleteGuard },
async (req, reply) => { async (req, reply) => {
const id = req.params.id; const id = req.params.id;
const target = db.select().from(users).where(eq(users.id, id)).get(); const target = db.select().from(users).where(and(eq(users.id, id), isNull(users.deletedAt))).get();
if (!target) { if (!target) {
return reply.code(404).send({ error: "user not found" }); return reply.code(404).send({ error: "user not found" });
} }
@@ -238,7 +250,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (isLastAdmin(id)) { if (isLastAdmin(id)) {
return reply.code(409).send({ error: "cannot delete the last admin" }); return reply.code(409).send({ error: "cannot delete the last admin" });
} }
db.delete(users).where(eq(users.id, id)).run(); softDelete(db, "user", id, req.user.sub);
return { ok: true }; return { ok: true };
}, },
); );
+17 -5
View File
@@ -2,9 +2,10 @@ import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db"; import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared"; import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js"; import { roleHasPermissions } from "../auth.js";
import { deviceEvents } from "../device-events.js"; import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
import { enrichEvent } from "../event-enrich.js"; import { enrichEvent } from "../event-enrich.js";
import type { DeviceMonitor } from "../device-monitor.js"; import type { DeviceMonitor } from "../device-monitor.js";
import type { LaneStatus } from "../lane-status.js";
import { getOccupancy } from "../occupancy.js"; import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives // Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
@@ -52,12 +53,18 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
} }
type OutMsg = type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown } | { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> } | { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { 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 };
export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> { export async function wsRoutes(
app: FastifyInstance,
db: Db,
deviceMonitor: DeviceMonitor,
laneStatus: LaneStatus,
): Promise<void> {
app.get( app.get(
"/api/ws", "/api/ws",
{ {
@@ -89,7 +96,7 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
// 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).
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() }); send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
// 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.
@@ -106,11 +113,16 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
const offDevice = deviceEvents.onDeviceStatus((event) => { const offDevice = deviceEvents.onDeviceStatus((event) => {
send({ kind: "device-status", event }); send({ kind: "device-status", event });
}); });
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
const offLane = deviceEvents.onLaneStatus((lanes) => {
send({ kind: "lane-status", lanes });
});
socket.on("close", () => { socket.on("close", () => {
offLedger(); offLedger();
offPrinter(); offPrinter();
offDevice(); offDevice();
offLane();
}); });
}, },
); );
+70 -9
View File
@@ -9,6 +9,7 @@ import { deviceEvents } from "./device-events.js";
import { EntryFlow } from "./entry-flow.js"; import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js"; import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js"; import { ExitFlow } from "./exit-flow.js";
import { VoidFlow } from "./void-flow.js";
import { PayStation } from "./pay-station.js"; import { PayStation } from "./pay-station.js";
import { SubscriptionFlow } from "./subscription-flow.js"; import { SubscriptionFlow } from "./subscription-flow.js";
import { ShiftService } from "./shift-service.js"; import { ShiftService } from "./shift-service.js";
@@ -24,7 +25,13 @@ import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js"; import { userRoutes } from "./routes/users.js";
import { roleRoutes } from "./routes/roles.js"; import { roleRoutes } from "./routes/roles.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
import { LaneStatus } from "./lane-status.js";
import { AnprBridge } from "./anpr-entry.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { reportRoutes } from "./routes/reports.js";
import { recycleBinRoutes } from "./routes/recycle-bin.js";
import { sweepExpired, retentionDays } from "./recycle-bin.js";
import { payRoutes } from "./routes/pay.js"; import { payRoutes } from "./routes/pay.js";
import { subscriptionRoutes } from "./routes/subscriptions.js"; import { subscriptionRoutes } from "./routes/subscriptions.js";
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js"; import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
@@ -37,6 +44,7 @@ import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js"; import { setupRoutes } from "./routes/setup.js";
import { deviceStatusRoutes } from "./routes/device-status.js"; import { deviceStatusRoutes } from "./routes/device-status.js";
import { wsRoutes } from "./routes/ws.js"; import { wsRoutes } from "./routes/ws.js";
import { registerSpa } from "./static-spa.js";
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify // The backend is Fastify (Node). Hardware drivers live as isolated Fastify
// plugins emitting onto a shared internal event bus; auth is fully local // plugins emitting onto a shared internal event bus; auth is fully local
@@ -93,17 +101,33 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
await userRoutes(app, db); await userRoutes(app, db);
await roleRoutes(app, db); await roleRoutes(app, db);
// 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
// snapshot→analyze probe on an ANPR-enabled camera. Opt-in (VISION_ENABLED) +
// fail-soft; advisory only. See wiki/entities/opencv-anpr-service.md.
const visionClient = new VisionClient(app.log);
if (visionClient.enabled) app.log.info("vision client enabled");
// Device-agnostic setup: the admin adds controllers (with their relays + entry // Device-agnostic setup: the admin adds controllers (with their relays + entry
// button) and binds readers/cameras to a controller relay at first-run. There is // button) and binds readers/cameras to a controller relay at first-run. There is
// 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); await setupRoutes(app, db, visionClient);
// 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
// the device's lane_devices config (written on assign). // the device's lane_devices config (written on assign).
await deviceRoutes(app, db); await deviceRoutes(app, db);
// Lane busy/free tracker: a camera's vehicle detection marks its bound lane busy
// (advisory barrier lights on the booth); auto-clears on a timeout. See lane-status.ts.
const laneStatus = new LaneStatus(db, app.log);
app.addHook("onClose", async () => laneStatus.stop());
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
// flows are constructed — because the ANPR bridge they carry depends on the
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and // Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
// pushes changes to the booth UI. setupRoutes() has already registered the // pushes changes to the booth UI. setupRoutes() has already registered the
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md. // built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
@@ -112,12 +136,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onReady", async () => printerMonitor.start()); app.addHook("onReady", async () => printerMonitor.start());
app.addHook("onClose", async () => printerMonitor.stop()); app.addHook("onClose", async () => printerMonitor.stop());
// Vision (ANPR) client — built early so the device monitor can include the vision
// service's health in the footer. Opt-in (VISION_ENABLED) + fail-soft; advisory only.
// See wiki/entities/opencv-anpr-service.md.
const visionClient = new VisionClient(app.log);
if (visionClient.enabled) app.log.info("vision client enabled");
// Unified device-status monitor: polls EVERY configured device (relays/readers/ // Unified device-status monitor: polls EVERY configured device (relays/readers/
// cameras via healthCheck, printers via rich readStatus) PLUS the vision service's // cameras via healthCheck, printers via rich readStatus) PLUS the vision service's
// /health, and feeds the booth's device-status footer over the WS. Read-only. // /health, and feeds the booth's device-status footer over the WS. Read-only.
@@ -140,9 +158,17 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
); );
await eventRoutes(app, db, eventLog); await eventRoutes(app, db, eventLog);
// Admin reporting: read-only charts/totals aggregated from the signed ledger
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
await reportRoutes(app, db);
// Recycle bin: view / restore / purge soft-deleted master data (users/roles/subs/
// plans/tariffs). Gated on recyclebin:*. See routes/recycle-bin.ts, recycle-bin.ts.
await recycleBinRoutes(app, db);
// Live booth feed: server-pushed ledger + occupancy + printer-status over a // Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts. // single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db, deviceMonitor); await wsRoutes(app, db, deviceMonitor, laneStatus);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts. // Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db); await snapshotRoutes(app, db);
@@ -174,6 +200,19 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
}); });
app.addHook("onClose", async () => unsubscribeRead()); app.addHook("onClose", async () => unsubscribeRead());
// ANPR bridge: a subscriber's plate, read off the lane camera's vehicle detection,
// admits them through the SAME gated SubscriptionFlow a QR/card scan uses (it emits a
// plate read onto the bus, which the dispatcher above turns into a gated entry/exit).
// Advisory + fail-soft + subscriber-only — never the sole reason a barrier opens. Needs
// the subscriptionFlow constructed just above. See anpr-entry.ts.
const anprBridge = new AnprBridge(db, visionClient, subscriptionFlow, app.log);
// Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on
// each detected target (vehicle). Source-IP guarded + optional Digest; records the raw
// payload as a `kind:"alarm"` device_event, drives lane busy/free, AND hands a vehicle
// detection to the ANPR bridge above. See routes/hikvision-alarm.ts.
await hikvisionAlarmRoutes(app, db, laneStatus, anprBridge);
// Credential capture ("enroll a card"): lets the operator present an RFID card to a // Credential capture ("enroll a card"): lets the operator present an RFID card to a
// CHOSEN reader to populate a subscription credential, without blocking the other // CHOSEN reader to populate a subscription credential, without blocking the other
// reader's live flow. Single-shot + TTL. See credential-capture.ts. // reader's live flow. Single-shot + TTL. See credential-capture.ts.
@@ -194,7 +233,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open // take payment → signed `payment` event. The booth pay/exit/voucher/re-open
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md. // endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
const payStation = new PayStation(db, eventLog, app.log); const payStation = new PayStation(db, eventLog, app.log);
await payRoutes(app, db, payStation, exitFlow, shiftService); // Ticket-void (cancel a wrongly-printed ticket): appends a signed `void` referencing the
// entry; the session projection folds it closed. See void-flow.ts.
const voidFlow = new VoidFlow(db, eventLog, app.log);
await payRoutes(app, db, payStation, exitFlow, shiftService, voidFlow);
// Tariff composer: admin publishes effective-dated, immutable rate-card versions // Tariff composer: admin publishes effective-dated, immutable rate-card versions
// the pay station prices against. See wiki/concepts/tariff.md. // the pay station prices against. See wiki/concepts/tariff.md.
@@ -226,6 +268,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
logService.prune(); // once at startup logService.prune(); // once at startup
app.addHook("onClose", async () => clearInterval(pruneTimer)); app.addHook("onClose", async () => clearInterval(pruneTimer));
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
const binTimer = setInterval(() => {
const purged = sweepExpired(db);
const total = Object.values(purged).reduce((a, b) => a + b, 0);
if (total > 0) app.log.info(`recycle-bin: auto-purged ${total} expired item(s) ${JSON.stringify(purged)}`);
}, 6 * 60 * 60 * 1000);
binTimer.unref();
if (retentionDays() > 0) sweepExpired(db); // once at startup
app.addHook("onClose", async () => clearInterval(binTimer));
const unsubscribeInput = deviceEvents.onInput((e) => { const unsubscribeInput = deviceEvents.onInput((e) => {
// Record every input edge as unsigned telemetry, keyed to the device that fired // Record every input edge as unsigned telemetry, keyed to the device that fired
// (provenance). No lane — the pool-of-spaces model has none. The entry flow // (provenance). No lane — the pool-of-spaces model has none. The entry flow
@@ -247,5 +301,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
}); });
app.addHook("onClose", async () => unsubscribeInput()); app.addHook("onClose", async () => unsubscribeInput());
// LAST: serve the built React SPA (apps/web/dist) when present — so one container
// serves the API + the operator UI (offline-first single appliance). No-op in dev (no
// build → the Vite dev server serves the UI). Registered after every API route and
// GET-only with /api + /health excluded, so it can never shadow the backend.
// See static-spa.ts + wiki/decisions/container-deployment.md.
await registerSpa(app);
return app; return app;
} }
+164
View File
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import {
ShiftService,
ShiftAlreadyOpenError,
NoOpenShiftError,
NoShiftOpenError,
InvalidCashMovementError,
} from "./shift-service.js";
import type { EventLog } from "./event-log.js";
import { makeLog, silentLogger } from "./test-helpers.js";
// The shift is an operator's accountability period — signed shift_open … shift_z_report,
// no mutable table. These tests pin: the site-wide single-open invariant, the takings
// SPLIT by source (subscription sales vs out-of-window charges vs transient tickets — the
// 2026-06-21 work), the drawer carry-forward, and that close signs a Z-report with the
// right figures.
let db: Db;
let close: () => void;
let log: EventLog;
let shift: ShiftService;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = makeLog(db);
shift = new ShiftService(db, log, silentLogger());
});
afterEach(() => close());
/** Append a signed payment with source-split flags, as the booth/pay paths do. */
async function payment(
amountMinor: number,
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
) {
await log.append({
type: "payment", source: "manual", identity: "T",
payload: {
sessionRef: "T", amountMinor, currency: "ALL", tender: opts.tender ?? "cash",
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
},
});
}
describe("single-open invariant", () => {
it("opens a shift and reports it as the current open one", async () => {
await shift.open("alice");
const cur = shift.currentOpenShift();
expect(cur?.identity).toBe("alice");
});
it("refuses a second open while one is already open (even another operator)", async () => {
await shift.open("alice");
await expect(shift.open("alice")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
});
it("allows a new shift after the prior one closes", async () => {
await shift.open("alice");
await shift.close("alice");
await expect(shift.open("bob")).resolves.toBeTruthy();
});
it("close without an open shift throws", async () => {
await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError);
});
it("requireOpenShift throws when none is open", () => {
expect(() => shift.requireOpenShift()).toThrow(NoShiftOpenError);
});
});
describe("takings split by source", () => {
it("separates subscription sales, out-of-window charges, and transient tickets", async () => {
await shift.open("alice");
await payment(50000, { subscriptionSale: true }); // monthly fee
await payment(20000, { subscriptionWindowCharge: true }); // out-of-window
await payment(10000); // transient ticket
await payment(30000, { tender: "card" }); // transient ticket, card
const r = shift.currentReport()!;
expect(r.subscriptionSalesMinor).toBe(50000);
expect(r.subscriptionWindowMinor).toBe(20000);
expect(r.subscriptionTotalMinor).toBe(70000);
expect(r.ticketTotalMinor).toBe(40000); // 10000 cash + 30000 card
// The split must reconcile to the cash+card grand total.
expect(r.cashTotalMinor + r.cardTotalMinor).toBe(
r.ticketTotalMinor + r.subscriptionTotalMinor,
);
expect(r.cashTotalMinor).toBe(80000); // 50000 + 20000 + 10000
expect(r.cardTotalMinor).toBe(30000);
});
});
describe("drawer carry-forward", () => {
it("cash payments enter the drawer; card does not", async () => {
await shift.open("alice");
await payment(10000, { tender: "cash" });
await payment(50000, { tender: "card" });
const r = shift.currentReport()!;
expect(r.cashTotalMinor).toBe(10000);
// Expected drawer = opening(0) + cash(10000) + added(0) − removed(0).
expect(r.expectedDrawerMinor).toBe(10000);
});
it("a closed shift's expected drawer becomes the next shift's opening float", async () => {
await shift.open("alice");
await payment(25000, { tender: "cash" });
const closed = await shift.close("alice");
expect(closed.expectedDrawerMinor).toBe(25000);
const next = await shift.open("bob");
expect(next.openingFloatMinor).toBe(25000); // inherited
});
it("cash_in / cash_out vouchers adjust the drawer", async () => {
await shift.open("alice");
await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" });
await shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: 30000, reason: "bank drop" });
const r = shift.currentReport()!;
expect(r.cashAddedMinor).toBe(100000);
expect(r.cashRemovedMinor).toBe(30000);
expect(r.expectedDrawerMinor).toBe(70000);
});
it("rejects a non-positive voucher amount", async () => {
await shift.open("alice");
await expect(
shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }),
).rejects.toBeInstanceOf(InvalidCashMovementError);
await expect(
shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }),
).rejects.toBeInstanceOf(InvalidCashMovementError);
});
});
describe("close signs a Z-report; listShifts reads it back", () => {
it("a closed shift appears in history with its split figures", async () => {
await shift.open("alice");
await payment(50000, { subscriptionSale: true });
await payment(10000); // ticket
await shift.close("alice");
const history = shift.listShifts();
expect(history).toHaveLength(1);
const s = history[0];
expect(s.operator).toBe("alice");
expect(s.subscriptionSalesMinor).toBe(50000);
expect(s.ticketTotalMinor).toBe(10000);
expect(s.cashTotalMinor).toBe(60000);
// The Z-report is a signed chain event.
expect(log.verifyChain()).toEqual({ ok: true });
});
it("filters history by operator", async () => {
await shift.open("alice"); await shift.close("alice");
await shift.open("bob"); await shift.close("bob");
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { SoftwareSigner, buildSigner, buildVerifier } from "./signer.js";
// The signer is half of the anti-fraud chain (the other half is event-log's hashing).
// These tests pin: a sign/verify round-trip, rejection of any tamper, constant-time
// length handling, and the keyId rotation contract that lets one chain span keys.
describe("SoftwareSigner", () => {
it("verifies its own signature (round-trip)", () => {
const s = new SoftwareSigner("a-test-secret-key");
const sig = s.sign("hello world");
expect(s.verify("hello world", sig)).toBe(true);
});
it("rejects a signature over different content (tamper-evidence)", () => {
const s = new SoftwareSigner("a-test-secret-key");
const sig = s.sign("amount=100");
// Flip the signed content — the whole point of signing the payload.
expect(s.verify("amount=9999", sig)).toBe(false);
});
it("rejects a signature made under a different key (forgery)", () => {
const real = new SoftwareSigner("the-real-host-key");
const forger = new SoftwareSigner("an-attacker-guess");
const forged = forger.sign("amount=100");
expect(real.verify("amount=100", forged)).toBe(false);
});
it("rejects a malformed / wrong-length signature without throwing", () => {
const s = new SoftwareSigner("a-test-secret-key");
// timingSafeEqual throws on length mismatch; verify() must guard it.
expect(() => s.verify("x", "deadbeef")).not.toThrow();
expect(s.verify("x", "deadbeef")).toBe(false);
expect(s.verify("x", "")).toBe(false);
});
it("is deterministic — same key + payload yields the same signature", () => {
const a = new SoftwareSigner("k").sign("p");
const b = new SoftwareSigner("k").sign("p");
expect(a).toBe(b);
});
it("defaults to the v2 keyId", () => {
expect(new SoftwareSigner("k").keyId).toBe("sw-hmac-v2");
});
});
describe("buildSigner", () => {
// vitest.config.ts sets EVENT_SIGNING_KEY + JWT_SECRET for the whole run.
it("prefers EVENT_SIGNING_KEY (keyId sw-hmac-v2)", () => {
const s = buildSigner();
expect(s.keyId).toBe("sw-hmac-v2");
const sig = s.sign("x");
expect(s.verify("x", sig)).toBe(true);
});
});
describe("buildVerifier (key rotation)", () => {
it("returns a working verifier for the configured v2 key", () => {
const v = buildVerifier("sw-hmac-v2");
expect(v).toBeDefined();
const signer = new SoftwareSigner(process.env.EVENT_SIGNING_KEY!, "sw-hmac-v2");
expect(v!.verify("x", signer.sign("x"))).toBe(true);
});
it("resolves the jwtfallback key when present", () => {
const v = buildVerifier("sw-hmac-jwtfallback");
expect(v).toBeDefined();
const signer = new SoftwareSigner(process.env.JWT_SECRET!, "sw-hmac-jwtfallback");
expect(v!.verify("x", signer.sign("x"))).toBe(true);
});
it("returns undefined for an unknown keyId (key gone, not a false tamper)", () => {
expect(buildVerifier("atecc608-slot0")).toBeUndefined();
expect(buildVerifier("nonsense")).toBeUndefined();
});
});
+3 -2
View File
@@ -141,8 +141,9 @@ async function recognizePlate(
} }
} }
/** Build a live camera adapter from a resolved devices row, or null. */ /** Build a live camera adapter from a resolved devices row, or null. Exported so the
function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null { * ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) return null; if (!driver) return null;
try { try {
+56
View File
@@ -0,0 +1,56 @@
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import fastifyStatic from "@fastify/static";
import type { FastifyInstance } from "fastify";
// Serve the built React SPA (apps/web/dist) from the Fastify server, so ONE container
// serves both the API and the operator UI — matching the offline-first single-appliance
// model (the booth has no separate web host). This is a NO-OP in dev (the Vite dev server
// serves the SPA on its own port and no dist exists), so it never changes local behavior.
//
// Registration order matters: this is registered LAST, after every API route, and its
// catch-all is GET-only and explicitly excludes /api, /health, and the WS path — so it
// can never shadow the backend. See wiki/decisions/container-deployment.md.
/** Where the built SPA lives. Override with WEB_DIST_DIR (the container sets it). Default
* resolves relative to this file's dist location: apps/server/dist → ../../web/dist, the
* layout the image lays down (/app/dist + /app/web/dist → ../web/dist from dist). */
function resolveWebDist(): string {
const fromEnv = process.env.WEB_DIST_DIR;
if (fromEnv) return resolve(fromEnv);
const here = dirname(fileURLToPath(import.meta.url));
// In the image the server runs from /app/dist and the SPA sits at /app/web/dist.
return resolve(here, "../web/dist");
}
/**
* Register SPA static serving if a build is present. Returns true when wired, false when
* skipped (dev / no build). Serves assets from the dist dir and falls back to index.html
* for any non-API GET so client-side routing (TanStack Router) works on deep links/reload.
*/
export async function registerSpa(app: FastifyInstance): Promise<boolean> {
const root = resolveWebDist();
const indexHtml = resolve(root, "index.html");
if (!existsSync(indexHtml)) {
app.log.info(`SPA static serving disabled (no build at ${root})`);
return false;
}
await app.register(fastifyStatic, { root, wildcard: false });
// SPA fallback: any GET that didn't match an API route or a real static file returns
// index.html (client routing). EXCLUDE the backend surfaces so a missing /api route
// still 404s as JSON rather than silently returning the HTML shell. WS upgrades and
// non-GET methods are never touched (this is a GET-only notFound handler path).
app.setNotFoundHandler((req, reply) => {
const url = req.raw.url ?? "/";
if (req.method !== "GET" || url.startsWith("/api") || url.startsWith("/health")) {
return reply.code(404).send({ error: "not found" });
}
return reply.sendFile("index.html");
});
app.log.info(`SPA static serving enabled from ${root}`);
return true;
}
+4 -1
View File
@@ -108,7 +108,10 @@ export class SubscriptionFlow {
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry). // that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry"; const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get(); const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") }; // A soft-deleted (recycle-bin) subscription must NOT open the barrier — treat it as
// gone. (Its credential rows are kept for restore, so the dispatcher can still match
// it; the gate is here.)
if (!sub || sub.deletedAt) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
// Validity: active + within the coverage window. // Validity: active + within the coverage window.
const now = new Date().toISOString(); const now = new Date().toISOString();
+108
View File
@@ -0,0 +1,108 @@
import { randomUUID } from "node:crypto";
import bcrypt from "bcrypt";
import { roles, rolePermissions, tariffs, tariffVersions, users, type Db } from "@parking/db";
import type { Permission, TariffStructure } from "@parking/shared";
import type { FastifyBaseLogger, FastifyInstance } from "fastify";
import { EventLog } from "./event-log.js";
import { SoftwareSigner, buildVerifier } from "./signer.js";
// Shared scaffolding for server tests (NOT a *.test file, so it is not collected as a
// suite and stays out of shipped dist via the tsconfig test-exclude). Builds the real
// EventLog over a fresh test DB, a silent logger, and a minimal active tariff so the
// pay/exit flows have something to price against.
const SECRET = "test-event-signing-key-0123456789";
/** Real EventLog (real signer + per-keyId verifier) over a test DB. */
export function makeLog(db: Db): EventLog {
return new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
}
/** A logger that swallows everything — flows log liberally; tests don't care. */
export function silentLogger(): FastifyBaseLogger {
const noop = () => {};
const l: Record<string, unknown> = {
info: noop, warn: noop, error: noop, debug: noop, fatal: noop, trace: noop,
silent: noop, level: "silent",
};
l.child = () => l;
return l as unknown as FastifyBaseLogger;
}
/** A simple flat-rate V1 tariff: free under the entry grace, then a fixed price per
* increment, with a walk-back exit grace. Returns the tariffVersionId + currency. */
export function seedTariff(
db: Db,
opts: { pricePerIncrementMinor?: number; incrementMin?: number; gracePeriodEntryMin?: number; gracePeriodExitMin?: number; currency?: string; effectiveFrom?: string } = {},
): { tariffVersionId: string; currency: string } {
const tariffId = randomUUID();
const versionId = randomUUID();
const currency = opts.currency ?? "ALL";
const structure: TariffStructure = {
gracePeriodEntryMin: opts.gracePeriodEntryMin ?? 10,
incrementMin: opts.incrementMin ?? 60,
blocks: [{ uptoMin: null, priceMinorPerIncrement: opts.pricePerIncrementMinor ?? 10000 }],
dailyCapMinor: null,
lostTicketMinor: 50000,
gracePeriodExitMin: opts.gracePeriodExitMin ?? 15,
overstay: "reprice",
};
db.insert(tariffs).values({ id: tariffId, scope: "site", name: "Test" }).run();
db.insert(tariffVersions).values({
id: versionId,
tariffId,
effectiveFrom: opts.effectiveFrom ?? "2000-01-01T00:00:00.000Z",
currency,
structure: structure as unknown as Record<string, unknown>,
}).run();
return { tariffVersionId: versionId, currency };
}
/** ISO string `minutes` ago from now (for entries that should already owe a fee). */
export function minutesAgo(minutes: number): string {
return new Date(Date.now() - minutes * 60_000).toISOString();
}
// --- HTTP integration scaffolding (route tests via app.inject) -----------------
/** Seed a user with a role. `admin` role grants every permission (ADMIN_PERMS);
* any other role gets exactly the `permissions` listed. Returns the credentials. */
export async function seedUser(
db: Db,
opts: { username?: string; password?: string; roleId?: string; permissions?: Permission[] } = {},
): Promise<{ username: string; password: string; roleId: string }> {
const username = opts.username ?? "tester";
const password = opts.password ?? "test-password-123";
const roleId = opts.roleId ?? "admin";
if (roleId !== "admin") {
db.insert(roles).values({ id: roleId, name: roleId, builtin: 0 }).onConflictDoNothing().run();
for (const p of opts.permissions ?? []) {
db.insert(rolePermissions).values({ roleId, permission: p }).onConflictDoNothing().run();
}
} else {
// The admin role row must exist for the FK; ADMIN_PERMS is resolved in code.
db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run();
}
db.insert(users).values({
id: randomUUID(),
username,
passwordHash: await bcrypt.hash(password, 10),
roleId,
}).run();
return { username, password, roleId };
}
/** Log in via the real auth route and return the cookie header + CSRF token to
* replay on subsequent requests (mutations need both the cookie and the header). */
export async function login(
app: FastifyInstance,
username: string,
password: string,
): Promise<{ cookie: string; csrf: string }> {
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
if (res.statusCode !== 200) throw new Error(`login failed: ${res.statusCode} ${res.body}`);
const setCookies = res.cookies;
const cookie = setCookies.map((c) => `${c.name}=${c.value}`).join("; ");
const csrf = setCookies.find((c) => c.name === "parking_csrf")?.value ?? "";
return { cookie, csrf };
}
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { VoidFlow } from "./void-flow.js";
import { PayStation } from "./pay-station.js";
import { occupancyCount } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import { makeLog, silentLogger, seedTariff } from "./test-helpers.js";
// Cancel (void) a wrongly-printed ticket: a SIGNED `void` event that references the entry
// and folds the session CLOSED. The entry itself is never edited/deleted (append-only).
let db: Db;
let close: () => void;
let log: EventLog;
let voidFlow: VoidFlow;
let pay: PayStation;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = makeLog(db);
voidFlow = new VoidFlow(db, log, silentLogger());
pay = new PayStation(db, log, silentLogger());
});
afterEach(() => close());
async function enter(identity: string, payload?: Record<string, unknown>) {
await log.append({ type: "vehicle_entry", direction: "entry", identity, payload: payload ?? null });
}
function voids(identity: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "void");
}
describe("VoidFlow.voidTicket", () => {
it("voids an open transient ticket: signs a void, closes the session, drops occupancy", async () => {
await enter("T1");
expect(occupancyCount(db)).toBe(1);
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(true);
const v = voids("T1");
expect(v).toHaveLength(1);
const pl = v[0]!.payload as Record<string, unknown>;
expect(pl.voidReason).toBe("misprint");
expect(pl.operator).toBe("alice");
expect(pl.voidedEntryRef).toBeDefined();
expect(pl.reasonCode).toBe("void.ticketCancelled");
// Folds: not inside, not an active session, no longer "open".
expect(occupancyCount(db)).toBe(1 - 1);
expect(pay.activeSessions().some((s) => s.identity === "T1")).toBe(false);
expect(pay.lookup("T1").open).toBe(false);
});
it("requires a reason", async () => {
await enter("T1");
const r = await voidFlow.voidTicket({ identity: "T1", reason: " ", operator: "alice" });
expect(r.ok).toBe(false);
expect(voids("T1")).toHaveLength(0);
});
it("refuses an unknown ticket", async () => {
const r = await voidFlow.voidTicket({ identity: "ghost", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/no such ticket/i);
});
it("refuses a second void (already cancelled)", async () => {
await enter("T1");
await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
const r = await voidFlow.voidTicket({ identity: "T1", reason: "again", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/already cancelled/i);
expect(voids("T1")).toHaveLength(1);
});
it("refuses an already-exited session", async () => {
await enter("T1");
await log.append({ type: "vehicle_exit", direction: "exit", identity: "T1" });
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/already exited/i);
});
it("refuses a PAID ticket (refund is a separate action)", async () => {
await enter("T1");
await log.append({ type: "payment", identity: "T1", payload: { sessionRef: "T1", amountMinor: 100, currency: "ALL" } });
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/already paid/i);
});
it("refuses a subscription occurrence (closed via its own flow)", async () => {
await enter("SUBSESS-x", { permit: true, permitId: "sub-1" });
const r = await voidFlow.voidTicket({ identity: "SUBSESS-x", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/subscription/i);
});
it("keeps the signed chain verifiable after a void", async () => {
seedTariff(db);
await enter("T1");
await voidFlow.voidTicket({ identity: "T1", reason: "test", operator: "alice" });
// The void is the newest signed row; the chain is intact (verifier is exercised by
// the event-log on append — a broken chain would have thrown).
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
const last = rows[rows.length - 1]!;
expect(last.type).toBe("void");
expect(last.prevHash).toBeTruthy();
expect(last.signature).toBeTruthy();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { eq, ledgerEvents, sessions, type Db } from "@parking/db";
import { reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
// Cancel a wrongly-printed transient ticket by appending a SIGNED `void` event that
// references the entry. The signed ledger is append-only and hash-chained — the
// vehicle_entry is NEVER edited or deleted; the void is a new appended row that the
// session projection folds to CLOSE the session (so a voided car stops counting inside
// and can't be paid/exited). Fully traceable: the operator + a required reason are signed
// into the void payload. A misprinted ticket's car never entered, so voiding opens NO
// barrier. See wiki/concepts/append-only-event-chain.md, parking-session.md.
export interface VoidResult {
readonly ok: boolean;
/** English reason on refusal (localized client-side via the reasonCode it mirrors). */
readonly reason?: string;
/** The void event's identity on success (= the entry identity). */
readonly identity?: string;
}
export class VoidFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
/** Serialize concurrent voids of the SAME ticket (double-click / double-scan). */
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/**
* Void (cancel) a transient ticket. Guards, then appends a signed `void`. Refuses:
* unknown ticket, a subscription occurrence (use the subscription flow), an already-
* exited or already-voided session, or a session that has a payment (a paid ticket is a
* refund situation — out of scope). `reason` is REQUIRED (the route enforces non-empty).
*/
async voidTicket(args: { identity: string; reason: string; operator: string }): Promise<VoidResult> {
const identity = args.identity.trim();
const reason = args.reason.trim();
if (!identity) return { ok: false, reason: "missing ticket id" };
if (!reason) return { ok: false, reason: "a cancellation reason is required" };
if (this.#inFlight.has(identity)) return { ok: false, reason: "cancel already in flight" };
this.#inFlight.add(identity);
try {
return await this.#run(identity, reason, args.operator);
} catch (err) {
this.#logger.error(`void-flow failed (${identity}): ${(err as Error).message}`);
return { ok: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(identity);
}
}
async #run(identity: string, reason: string, operator: string): Promise<VoidResult> {
const rows = this.#db
.select()
.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, reason: "no such ticket (no entry for this id)" };
// Subscriptions are closed via their own flow — ticket-void would double-mean permitId.
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return { ok: false, reason: "this is a subscription occurrence — cancel it via the subscription, not a ticket void" };
}
if (rows.some((r) => r.type === "vehicle_exit")) {
return { ok: false, reason: "session already exited — nothing to cancel" };
}
if (rows.some((r) => r.type === "void")) {
return { ok: false, reason: "ticket already cancelled" };
}
// A paid ticket is a refund, not a misprint cancel — out of scope.
if (rows.some((r) => r.type === "payment")) {
return { ok: false, reason: "ticket already paid — a refund is a separate action, not a cancellation" };
}
await this.#log.append({
type: "void",
identity,
// `sessionRef` + `voidedEntryRef` tie the void to the entry; `voidReason` + `operator`
// make it traceable. The reasonCode localizes; the free-text reason is the operator's note.
payload: {
...reasonPayload("void.ticketCancelled", { reason }),
sessionRef: identity,
voidedEntryRef: entry.id,
voidReason: reason,
operator,
},
});
// Best-effort close the projection cache (the ledger fold is the truth either way).
try {
this.#db
.update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "voided" })
.where(eq(sessions.id, identity))
.run();
} catch (err) {
this.#logger.error(`void session-cache close failed for ${identity}: ${(err as Error).message}`);
}
this.#logger.info(`ticket ${identity} cancelled by ${operator}: ${reason}`);
// NO barrier action — the misprinted ticket's car never entered.
return { ok: true, identity };
}
}
+2 -1
View File
@@ -9,5 +9,6 @@
{ "path": "../../packages/db" }, { "path": "../../packages/db" },
{ "path": "../../packages/devices" } { "path": "../../packages/devices" }
], ],
"include": ["src/**/*"] "include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
} }
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
// Server tests live next to the code under test (src/**/*.test.ts). They run against
// a fresh in-memory SQLite from @parking/db/testing — never the live parking.sqlite.
// A test signing key is set here so the SoftwareSigner/buildSigner path works without
// a real .env (the value is irrelevant — tests assert self-consistency, not secrecy).
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
env: {
EVENT_SIGNING_KEY: "test-event-signing-key-0123456789",
JWT_SECRET: "test-jwt-secret-0123456789abcdef",
// Silence the Fastify request logger — route tests assert 401/403 responses,
// whose error logs would otherwise flood the test output.
LOG_LEVEL: "silent",
},
},
});
+57
View File
@@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1.7
# Parking VISION image: the Python/uv ANPR microservice. Build CONTEXT is apps/vision
# (self-contained Python package; no monorepo deps). Ships WITH the `alpr` extra (real
# fast-alpr/onnxruntime stack) but the engine is env-selected: VISION_RECOGNIZER=stub
# (default, boots anywhere) or fast_alpr (prod). See wiki/decisions/container-deployment.md,
# wiki/decisions/vision-service-packaging.md.
# uv-provided Python 3.12 (matches apps/vision/.python-version).
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
# System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# ---- deps: resolve + install the venv from the lockfile (cache-friendly) ----
# Manifests first so the heavy `uv sync` layer caches across source edits.
COPY pyproject.toml uv.lock .python-version ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --extra alpr
# ---- project source ----
COPY vision_service/ ./vision_service/
COPY README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --extra alpr
# Non-root runtime user, created BEFORE the model pre-warm so the weights cache lands in
# this user's HOME (~/.cache) — the SAME path the runtime reads. (fast-alpr's
# open-image-models caches under $HOME/.cache/open-image-models keyed to HOME, ignoring
# HF_HOME/XDG_CACHE_HOME — so the pre-warm MUST run as the runtime user, not root.)
RUN useradd --system --create-home --uid 999 vision \
&& chown -R vision:vision /app
USER vision
# Pre-warm the fast-alpr model weights INTO the image (as the vision user → /home/vision/
# .cache) so the prod recognizer is OFFLINE-first: ALPR() downloads weights on first
# construction, which would otherwise need network on the appliance's first scan. Best-effort
# — if the build host has no network this is skipped and weights fetch lazily at runtime.
# NB: NO --mount=type=cache here — a BuildKit cache mount at ~/.cache is NOT committed to the
# image layer, so the downloaded weights would vanish. They must write to the real layer.
RUN uv run python -c "from fast_alpr import ALPR; ALPR()" \
|| echo "[build] model pre-warm skipped (no network) — weights fetch at runtime"
# Default to the stub recognizer (offline, no model load); override to fast_alpr in prod.
ENV VISION_RECOGNIZER=stub \
VISION_HOST=0.0.0.0 \
VISION_PORT=8089
EXPOSE 8089
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 ["uv", "run", "uvicorn", "vision_service.app:app", "--host", "0.0.0.0", "--port", "8089"]
+28
View File
@@ -0,0 +1,28 @@
"""Shared test fixtures.
The stub-mode smoke tests must be deterministic regardless of the developer's local
apps/vision/.env (which may set VISION_RECOGNIZER=fast_alpr for real-model work). An OS
environment variable takes precedence over the .env file in pydantic-settings, so we
force stub mode for the whole test session before the app's lifespan builds the
recognizer. Tests that exercise the real recognizer set their own override explicitly.
"""
from __future__ import annotations
import os
import pytest
@pytest.fixture(autouse=True)
def _force_stub_recognizer() -> None:
"""Pin the recognizer to the model-free stub for every test (overrides .env)."""
prev = os.environ.get("VISION_RECOGNIZER")
os.environ["VISION_RECOGNIZER"] = "stub"
try:
yield
finally:
if prev is None:
os.environ.pop("VISION_RECOGNIZER", None)
else:
os.environ["VISION_RECOGNIZER"] = prev
+7 -2
View File
@@ -8,7 +8,8 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
@@ -23,6 +24,7 @@
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"recharts": "^3.2.1",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
@@ -30,9 +32,12 @@
"@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-router-devtools": "^1.167.0",
"@types/react": "19.2.17", "@types/react": "19.2.17",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@testing-library/react": "^16.1.0",
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
"jsdom": "^25.0.1",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "8.0.16" "vite": "8.0.16",
"vitest": "^4.1.9"
} }
} }
+84
View File
@@ -4,6 +4,7 @@ import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
boothExit, boothExit,
can,
fetchSiteConfig, fetchSiteConfig,
lookupSession, lookupSession,
openShift, openShift,
@@ -11,8 +12,10 @@ import {
printReceipt, printReceipt,
printVoucher, printVoucher,
reopenBarrier, reopenBarrier,
voidTicket,
type SessionLookup, type SessionLookup,
} from "./api.js"; } from "./api.js";
import { rootRoute } from "./router.js";
import { qk } from "./lib/query.js"; import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js"; import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js"; import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
@@ -52,6 +55,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay // For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
// first, then the modal reveals "Open barrier". This flips true once paid. // first, then the modal reveals "Open barrier". This flips true once paid.
const [windowPaid, setWindowPaid] = useState(false); const [windowPaid, setWindowPaid] = useState(false);
// Cancel (void) a wrongly-printed ticket: a small reason prompt, then a signed void.
const { user } = rootRoute.useRouteContext();
const canVoid = can(user, "event:void");
const [voiding, setVoiding] = useState(false); // reason prompt revealed
const [voidReason, setVoidReason] = useState("");
const s: SessionLookup | undefined = session.data; const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle. // Checkbox default comes from config the first time it loads; operator can toggle.
@@ -127,6 +135,29 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
} }
} }
// A wrongly-printed ticket is cancellable only while it's a TRANSIENT, UNPAID, OPEN
// session (a subscription is closed via its own flow; a paid ticket is a refund). The
// server enforces all of this too; the UI just hides the action when it can't apply.
const canCancel = !!(canVoid && shiftReady && s?.found && s.open && !isSubscription && !alreadyPaid);
async function handleVoidTicket() {
const reason = voidReason.trim();
if (!reason) return;
setError(null);
setPhase("finishing");
try {
await voidTicket(identity, reason);
setResult(t("pay.ticketCancelled"));
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
async function handleReprintReceipt() { async function handleReprintReceipt() {
setReprinting(true); setReprinting(true);
setError(null); setError(null);
@@ -365,6 +396,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</label> </label>
)} )}
{/* Cancel-ticket reason prompt (revealed by the "Cancel ticket" button).
A few presets + free text; a reason is REQUIRED. Voiding appends a
signed `void` event — the entry is never edited. */}
{voiding && phase !== "done" && (
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
{t("pay.cancelTicketTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setVoidReason(t(`pay.cancelReason.${k}`))}
className={voidReason === t(`pay.cancelReason.${k}`) ? "btn btn-primary btn-sm" : "btn btn-sm"}
>
{t(`pay.cancelReason.${k}`)}
</button>
))}
</div>
<input
className="input mt-2 w-full"
value={voidReason}
onChange={(e) => setVoidReason(e.target.value)}
placeholder={t("pay.cancelReasonPlaceholder")}
/>
</div>
)}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>} {error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
{result && ( {result && (
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div> <div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
@@ -439,7 +500,29 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{t("pay.assistOpenReveal")} {t("pay.assistOpenReveal")}
</button> </button>
) )
) : voiding ? (
// Cancel-ticket confirm (reason prompt is shown above).
<button
type="button"
onClick={handleVoidTicket}
disabled={!voidReason.trim() || phase === "finishing"}
className="btn btn-danger btn-lg"
>
{phase === "finishing" ? t("pay.cancelling") : t("pay.confirmCancelTicket")}
</button>
) : ( ) : (
<>
{/* Cancel a wrongly-printed ticket (transient, unpaid, open only;
gated on event:void). Reveals the reason prompt above. */}
{canCancel && (
<button
type="button"
onClick={() => setVoiding(true)}
className="btn btn-ghost btn-sm text-term-red"
>
{t("pay.cancelTicket")}
</button>
)}
<button <button
type="button" type="button"
onClick={handlePayAndExit} onClick={handlePayAndExit}
@@ -460,6 +543,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
? t("pay.payAndVoucher") ? t("pay.payAndVoucher")
: t("pay.payAndOpen")} : t("pay.payAndOpen")}
</button> </button>
</>
)} )}
</> </>
)} )}
+53 -284
View File
@@ -1,19 +1,17 @@
import { useRef, useState, type ReactNode } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js"; import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
import { formatMoney } from "./lib/format.js";
import { qk } from "./lib/query.js"; import { qk } from "./lib/query.js";
import { useLiveStore } from "./lib/live-store.js"; import { useLiveStore } from "./lib/live-store.js";
import { useShift } from "./lib/use-shift.js"; import { useShift } from "./lib/use-shift.js";
import { useScanner } from "./lib/use-scanner.js";
import { Panel } from "./ui/Panel.js"; import { Panel } from "./ui/Panel.js";
import { StatusDot } from "./ui/StatusDot.js"; import { StatusDot } from "./ui/StatusDot.js";
import { BoothPayModal } from "./BoothPayModal.js"; import { BoothPayModal } from "./BoothPayModal.js";
import { ActiveSessions } from "./ActiveSessions.js"; import { ActiveSessions } from "./ActiveSessions.js";
import { Modal } from "./ui/Modal.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
import { renderReason } from "./lib/reason.js"; import { EventDetailModal, EventRow } from "./ui/event-detail.js";
// 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
@@ -21,21 +19,6 @@ import { renderReason } from "./lib/reason.js";
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable. // the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
/** Per-event-type display: i18n label key + accent colour for the ticker. */ /** Per-event-type display: i18n label key + accent colour for the ticker. */
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
// Live-feed filter category for an event type. Several ledger types collapse into a // Live-feed filter category for an event type. Several ledger types collapse into a
// few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the // few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the
// filter and only show under "all". // filter and only show under "all".
@@ -57,12 +40,6 @@ function feedCat(type: string): FeedCat | null {
} }
} }
function hhmmss(iso: string): string {
// Local time-of-day, terminal style. Defensive against a bad timestamp.
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
}
function OccupancyGauge({ occ }: { occ: Occupancy }) { function OccupancyGauge({ occ }: { occ: Occupancy }) {
const { t } = useTranslation(); const { t } = useTranslation();
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null; const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
@@ -98,263 +75,6 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
); );
} }
/** Translated classification badges derived from a payload's boolean flags. Unlike
* `reason` (an immutable English sentence baked into the signed ledger, shown
* verbatim), these are computed client-side so they CAN be localized. They give a
* glanceable "what kind of anomaly" tag without parsing the free-text reason. */
function eventBadges(p: LedgerEvent["payload"]): string[] {
if (!p) return [];
const keys: string[] = [];
if (p.entryRefused) keys.push("booth.badgeEntryRefused");
if (p.exitRefused) keys.push("booth.badgeExitRefused");
if (p.full) keys.push("booth.badgeLotFull");
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
if (p.permitRefused) keys.push("booth.badgeSubRefused");
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
// Subscriber entered outside their plan's allowed window → will owe a transient charge
// for the minutes actually parked out-of-window, priced + collected (gated) at exit.
// Flag it so the operator KNOWS now. (`windowOwedMinor` is the old fixed-amount stamp,
// kept so historic events still badge.)
if (p.outOfWindow === true || (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0))
keys.push("booth.badgeWindowCharge");
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
return keys;
}
/** The i18n key for a subscriber's access medium (`via`), or null. Lets the activity
* log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */
function viaKey(p: LedgerEvent["payload"]): string | null {
if (!p) return null;
if (p.via === "qr") return "booth.viaQr";
if (p.via === "card") return "booth.viaCard";
if (p.via === "plate") return "booth.viaPlate";
return null;
}
/** A short money summary for payment events (e.g. "350.00 ALL"). */
function paymentSummary(p: LedgerEvent["payload"]): string | null {
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
return formatMoney(p.amountMinor, p.currency);
}
/** What to SHOW for an event's actor. A subscription occurrence has an opaque
* `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`,
* so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */
function displayIdentity(e: LedgerEvent): string {
return e.subscriberLabel ?? e.identity ?? "—";
}
function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
const { t } = useTranslation();
const style = EVENT_STYLE[e.type];
const label = style ? t(style.labelKey) : e.type.toUpperCase();
const isAnomaly = e.type === "anomaly";
const p = e.payload;
// Localize the reason from the signed reasonCode (falls back to the English text on
// legacy events). Anomalies ALWAYS get a detail line so a red flag is never silent.
const reason = renderReason(p, t);
const amount = paymentSummary(p);
const badges = eventBadges(p);
const via = viaKey(p);
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
const showDetail = detail != null || badges.length > 0 || via != null;
// The whole row is a button → opens the event-detail modal (full payload + the
// session's entry/exit snapshots). A grid keeps the time/label/identity/index
// columns aligned across rows; the detail line lives in its own row, indented to
// start under the identity column so it never collides with the ticket code.
return (
<button
type="button"
onClick={() => onOpen(e)}
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
isAnomaly ? "bg-term-red/5" : ""
}`}
>
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-term-text">{displayIdentity(e)}</span>
{e.plate && (
<span
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
title={t("booth.plateTitle")}
>
{e.plate}
</span>
)}
</span>
<span className="text-term-muted">#{e.index}</span>
{showDetail && (
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
{badges.map((k) => (
<span
key={k}
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
>
{t(k)}
</span>
))}
{via && (
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
{t(via)}
</span>
)}
{detail && (
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
)}
</div>
)}
</button>
);
}
/** One label/value line in the event-detail modal. */
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
<span className="min-w-0 break-words text-term-text">{children}</span>
</div>
);
}
/** Full read-only detail for one ledger event: business fields + the human-readable
* reason + the session's entry/exit snapshots, then the signed-chain provenance
* (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable;
* this only DISPLAYS the signed record. */
function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
const { t } = useTranslation();
const style = EVENT_STYLE[e.type];
const label = style ? t(style.labelKey) : e.type.toUpperCase();
const p = e.payload;
const reason = renderReason(p, t);
const amount = paymentSummary(p);
const badges = eventBadges(p);
const isAnomaly = e.type === "anomaly";
// Pretty money for any minor-unit amount in the payload.
const money =
p && typeof p.amountMinor === "number" && typeof p.currency === "string"
? formatMoney(p.amountMinor, p.currency)
: null;
// Pull out the business fields worth a labelled row. Everything else (and the raw
// bytes) lives behind the audit disclosure — the operator sees a clean summary.
const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null;
const plate = typeof p?.plate === "string" ? p.plate : null;
const category = typeof p?.category === "string" ? p.category : null;
const operator = typeof p?.operator === "string" ? p.operator : null;
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
return (
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
<div className="flex flex-col gap-3">
{/* Headline: the type + localized reason, prominent for anomalies. */}
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
{(reason || money) && (
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
{reason ?? money}
</div>
)}
{!reason && !money && isAnomaly && (
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
)}
{badges.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{badges.map((k) => (
<span
key={k}
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
>
{t(k)}
</span>
))}
</div>
)}
</div>
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
<div>
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
{/* When we showed a subscriber NAME above, also expose the raw occurrence id
(the SUBSESS-… session key) for traceability against the ledger. */}
{e.subscriberLabel && e.identity && (
<DetailRow label={t("booth.edOccurrence")}>
<code className="text-[11px] text-term-muted">{e.identity}</code>
</DetailRow>
)}
{money && (
<DetailRow label={t("booth.edAmount")}>
<span className="text-term-cyan">{money}</span>
</DetailRow>
)}
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
{viaKey(p) && (
<DetailRow label={t("booth.edVia")}>
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
</DetailRow>
)}
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
{sessionRef && sessionRef !== e.identity && (
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
)}
{tariffVersionId && (
<DetailRow label={t("booth.edTariffVersion")}>
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
</DetailRow>
)}
</div>
{/* The entry/exit evidence images for this session's identity. */}
{e.identity && (
<div>
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
<SnapshotStrip identity={e.identity} />
</div>
)}
{/* Audit data — collapsed by default. The signed-chain provenance (signature,
key, prev-hash) and the raw payload are an auditor's concern, not the
operator's; tucking them behind a disclosure keeps the common view clean
while preserving the tamper-evidence trail on demand. */}
<details className="rounded-term border border-term-border bg-term-panel-2">
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
{t("booth.edAuditData")}
</summary>
<div className="border-t border-term-border px-3 pb-3 pt-1">
<DetailRow label={t("booth.edSignature")}>
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
</DetailRow>
<DetailRow label={t("booth.edKeyId")}>
<code className="text-[11px] text-term-muted">{e.keyId}</code>
</DetailRow>
<DetailRow label={t("booth.edPrevHash")}>
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
</DetailRow>
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
{t("booth.edRawPayload")}
</div>
{p && Object.keys(p).length > 0 ? (
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
{JSON.stringify(p, null, 2)}
</pre>
) : (
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
)}
</div>
</details>
</div>
</Modal>
);
}
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual /** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
* operator types it. Either way, submit opens the pay/exit modal for that id. The * operator types it. Either way, submit opens the pay/exit modal for that id. The
@@ -392,6 +112,44 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
); );
} }
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
* from camera detection). Advisory only; it gates nothing. */
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
return (
<div
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
}`}
title={label}
>
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="5" y1="21" x2="5" y2="9" />
<line x1="5" y1="10" x2="21" y2="6" />
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
</svg>
<div className="leading-tight">
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
{busy ? "●" : "○"}
</div>
</div>
</div>
);
}
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
function LaneIndicators() {
const { t } = useTranslation();
const lanes = useLiveStore((s) => s.lanes);
return (
<div className="flex items-center gap-2">
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
</div>
);
}
export function BoothScreen() { export function BoothScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's // The site-wide shift drives the log scope: the feed shows ONLY the open shift's
@@ -413,6 +171,11 @@ export function BoothScreen() {
// The ledger event open in the read-only detail modal (null = closed). // The ledger event open in the read-only detail modal (null = closed).
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null); const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
// A hardware scan opens the pay/exit modal regardless of focus (the operator needn't
// click the ticket field first). Paused while a modal is already up — a scan must not
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
// Live-feed filters: free-text search, event category, and direction/source. // Live-feed filters: free-text search, event category, and direction/source.
const [feedSearch, setFeedSearch] = useState(""); const [feedSearch, setFeedSearch] = useState("");
const [feedType, setFeedType] = useState<FeedCat | "">(""); const [feedType, setFeedType] = useState<FeedCat | "">("");
@@ -473,10 +236,16 @@ export function BoothScreen() {
return ( return (
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]"> <div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
{/* Ticket input spans both columns at the top — the operator's primary action. */} {/* Ticket input spans both columns at the top — the operator's primary action.
The lane barrier lights sit beside it (live vehicle-detection busy/free). */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Panel title={t("booth.processTicket")}> <Panel title={t("booth.processTicket")}>
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-[260px] flex-1">
<TicketInput onSubmit={setActiveTicket} /> <TicketInput onSubmit={setActiveTicket} />
</div>
<LaneIndicators />
</div>
</Panel> </Panel>
</div> </div>
+171
View File
@@ -0,0 +1,171 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { changeMyPassword, updateMyProfile, type SessionUser } from "./api.js";
// Self-service profile: the signed-in user edits their OWN display name + email and
// changes their OWN password (proving the current one). This is NOT the admin
// user-manager (UsersManager.tsx) — it never touches another account, username, or
// role, and needs no `user:*` permission. See routes/auth.ts (/api/auth/profile,
// /api/auth/password) and wiki/entities/local-jwt-auth.md.
const MIN_PASSWORD = 8;
export function Profile({
user,
setUser,
}: {
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
const { t } = useTranslation();
// --- Account (name / email) ---
const [fullName, setFullName] = useState(user.fullName ?? "");
const [email, setEmail] = useState(user.email ?? "");
const [accountMsg, setAccountMsg] = useState<string | null>(null);
const [savingAccount, setSavingAccount] = useState(false);
async function saveAccount() {
setAccountMsg(null);
setSavingAccount(true);
try {
const next = await updateMyProfile({ fullName, email });
// Keep the router-context user in sync so the header reflects the change.
setUser(next);
setFullName(next.fullName ?? "");
setEmail(next.email ?? "");
setAccountMsg(t("profile.profileSaved"));
} catch (e) {
setAccountMsg((e as Error).message);
} finally {
setSavingAccount(false);
}
}
// --- Password ---
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [pwMsg, setPwMsg] = useState<string | null>(null);
const [savingPw, setSavingPw] = useState(false);
async function changePassword() {
setPwMsg(null);
if (next.length < MIN_PASSWORD) {
setPwMsg(t("profile.passwordTooShort", { min: MIN_PASSWORD }));
return;
}
if (next !== confirm) {
setPwMsg(t("profile.passwordsDontMatch"));
return;
}
setSavingPw(true);
try {
await changeMyPassword(current, next);
setCurrent("");
setNext("");
setConfirm("");
setPwMsg(t("profile.passwordChanged"));
} catch (e) {
setPwMsg((e as Error).message);
} finally {
setSavingPw(false);
}
}
return (
<div className="mx-auto flex max-w-xl flex-col gap-6">
<h1 className="text-lg text-term-text">{t("profile.title")}</h1>
{/* Account: display name + email (username + role are read-only — admin-managed). */}
<section className="card flex flex-col gap-3 p-4">
<h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.accountSection")}
</h2>
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
<div>
<span className="block">{t("profile.username")}</span>
<span className="text-sm text-term-text">{user.username}</span>
</div>
<div>
<span className="block">{t("profile.role")}</span>
<span className="text-sm text-term-text">{user.roleName}</span>
</div>
</div>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.fullName")}
<input
className="input"
value={fullName}
placeholder={t("profile.fullNamePh")}
onChange={(e) => setFullName(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.email")}
<input
className="input"
type="email"
value={email}
placeholder={t("profile.emailPh")}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
{t("profile.saveProfile")}
</button>
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
</div>
</section>
{/* Password: requires the current one (server enforces). */}
<section className="card flex flex-col gap-3 p-4">
<h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.passwordSection")}
</h2>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.currentPassword")}
<input
className="input"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.newPassword")}
<input
className="input"
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.confirmPassword")}
<input
className="input"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</label>
<div className="flex items-center gap-3">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={changePassword}
disabled={savingPw || !current || !next || !confirm}
>
{t("profile.changePassword")}
</button>
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
</div>
</section>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ApiError,
can,
fetchRecycleBin,
purgeRecycleItem,
restoreRecycleItem,
type RecycleBinItem,
type RecycleKind,
type SessionUser,
} from "./api.js";
import { qk } from "./lib/query.js";
import { formatRelativeDateTime } from "./lib/format.js";
import { Modal } from "./ui/Modal.js";
// Recycle bin — the way back from an accidental delete. Lists everything soft-deleted
// across users/roles/subscriptions/plans/tariffs; an admin can Restore (back to its
// catalog) or Purge (permanent). Items auto-purge after the retention window. Gated by
// recyclebin:* (read to view, update to restore, delete to purge). See
// apps/server/src/recycle-bin.ts, wiki/concepts/soft-delete.md.
const KIND_KEY: Record<RecycleKind, string> = {
user: "recycleBin.kind.user",
role: "recycleBin.kind.role",
subscription: "recycleBin.kind.subscription",
plan: "recycleBin.kind.plan",
tariff: "recycleBin.kind.tariff",
};
export function RecycleBin({ user }: { user: SessionUser | null }) {
const { t } = useTranslation();
const qc = useQueryClient();
const binQ = useQuery({ queryKey: qk.recycleBin, queryFn: fetchRecycleBin });
const canRestore = can(user, "recyclebin:update");
const canPurge = can(user, "recyclebin:delete");
const [error, setError] = useState<string | null>(null);
const [purging, setPurging] = useState<RecycleBinItem | null>(null);
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
const invalidate = () => {
void qc.invalidateQueries({ queryKey: qk.recycleBin });
// A restore/purge can change any catalog — refresh the ones a restore touches.
for (const key of [["users"], ["roles"], ["subscriptions"], ["subscription-plans"], ["tariff"]]) {
void qc.invalidateQueries({ queryKey: key });
}
};
const restoreM = useMutation({
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => restoreRecycleItem(kind, id),
onSuccess: invalidate,
onError,
});
const purgeM = useMutation({
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => purgeRecycleItem(kind, id),
onSuccess: () => {
setPurging(null);
invalidate();
},
onError: (e) => {
setPurging(null);
onError(e);
},
});
const items = binQ.data?.items ?? [];
const retentionDays = binQ.data?.retentionDays ?? 0;
return (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center gap-3">
<h1 className="text-base font-bold uppercase tracking-widest text-term-amber">
{t("recycleBin.title")}
</h1>
{retentionDays > 0 && (
<span className="text-[12px] text-term-muted">
{t("recycleBin.retentionNote", { days: retentionDays })}
</span>
)}
</div>
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
{!binQ.isLoading && items.length === 0 ? (
<p className="rounded-term border border-term-border bg-term-panel p-6 text-center text-term-muted">
{t("recycleBin.empty")}
</p>
) : (
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
<th className="py-1.5 text-right">{t("recycleBin.col.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
<td className="py-1.5 pr-3">
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
{t(KIND_KEY[it.kind])}
</span>
</td>
<td className="py-1.5 pr-3 text-term-text">{it.label}</td>
<td className="py-1.5 pr-3 text-term-muted">
{formatRelativeDateTime(it.deletedAt, t)}
</td>
<td className="py-1.5 text-right">
{canRestore && (
<button
type="button"
className="btn btn-sm"
disabled={restoreM.isPending}
onClick={() => {
setError(null);
restoreM.mutate({ kind: it.kind, id: it.id });
}}
>
{t("recycleBin.restore")}
</button>
)}
{canPurge && (
<button
type="button"
className="btn btn-sm btn-ghost ml-1 text-term-red"
onClick={() => {
setError(null);
setPurging(it);
}}
>
{t("recycleBin.purge")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{purging && (
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
<p className="text-[13px] text-term-text">
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
</p>
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
{t("common.cancel")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
disabled={purgeM.isPending}
onClick={() => purgeM.mutate({ kind: purging.kind, id: purging.id })}
>
{t("recycleBin.purge")}
</button>
</div>
</Modal>
)}
</div>
);
}
+319
View File
@@ -0,0 +1,319 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQuery } from "@tanstack/react-query";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { fetchReport, reportCsvUrl, type ReportBucket, type ReportSummary } from "./api.js";
import { qk } from "./lib/query.js";
import { formatMinutes, formatMoney } from "./lib/format.js";
// Admin Reports — the at-a-glance dashboard over the signed ledger. All numbers come
// from the server already aggregated (ledger-first; see apps/server/src/reports.ts), so
// this file is pure presentation: date-range presets, KPI cards, and a handful of
// Recharts views (entry/exit, revenue cash/card, peak hours, revenue mix, subscriptions).
// Themed to the terminal palette. Gated by report:read at the route + server.
// Terminal palette (mirrors index.css --color-term-*). Recharts wants literal colors.
const C = {
green: "#2e8c4a", // entry / ok
red: "#e8412b", // exit / fault
amber: "#f2a516", // accent / cash
cyan: "#2563c8", // payment / card
muted: "#8a8a82",
border: "#2a2f38",
text: "#f2f2ee",
panel: "#14171c",
};
type PresetKey = "today" | "7d" | "30d" | "90d";
/** [from, to) ISO bounds + a sensible default bucket for a preset, computed in the
* browser's local time (the appliance IS the site, so local == site time). */
function presetRange(key: PresetKey): { from: string; to: string; bucket: ReportBucket } {
const now = new Date();
const to = now.toISOString();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (key === "today") return { from: startOfToday.toISOString(), to, bucket: "hour" };
const days = key === "7d" ? 7 : key === "30d" ? 30 : 90;
const from = new Date(now.getTime() - days * 86_400_000).toISOString();
return { from, to, bucket: days <= 30 ? "day" : "month" };
}
export function Reports() {
const { t } = useTranslation();
const [preset, setPreset] = useState<PresetKey>("30d");
const [bucketOverride, setBucketOverride] = useState<ReportBucket | null>(null);
const range = useMemo(() => presetRange(preset), [preset]);
const bucket = bucketOverride ?? range.bucket;
const { data, isLoading, isError, error } = useQuery({
queryKey: qk.report(range.from, range.to, bucket),
queryFn: () => fetchReport(range.from, range.to, bucket),
});
const presets: { key: PresetKey; label: string }[] = [
{ key: "today", label: t("reports.preset.today") },
{ key: "7d", label: t("reports.preset.7d") },
{ key: "30d", label: t("reports.preset.30d") },
{ key: "90d", label: t("reports.preset.90d") },
];
const buckets: ReportBucket[] = ["hour", "day", "month"];
return (
<div className="mx-auto max-w-6xl">
<div className="mb-3 flex flex-wrap items-center gap-2">
<h1 className="mr-2 text-base font-bold uppercase tracking-widest text-term-amber">
{t("reports.title")}
</h1>
<div className="flex gap-1">
{presets.map((p) => (
<button
key={p.key}
type="button"
className={`btn btn-sm ${preset === p.key ? "btn-primary" : "btn-ghost"}`}
onClick={() => {
setPreset(p.key);
setBucketOverride(null);
}}
>
{p.label}
</button>
))}
</div>
<div className="ml-2 flex items-center gap-1 text-[12px] text-term-muted">
<span>{t("reports.groupBy")}</span>
<select
className="select input-sm w-auto"
value={bucket}
onChange={(e) => setBucketOverride(e.target.value as ReportBucket)}
>
{buckets.map((b) => (
<option key={b} value={b}>
{t(`reports.bucket.${b}`)}
</option>
))}
</select>
</div>
<a
className="btn btn-sm btn-ghost ml-auto"
href={reportCsvUrl(range.from, range.to, bucket)}
download
>
{t("reports.exportCsv")}
</a>
</div>
{isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
{isError && (
<p className="text-term-red">
{t("reports.loadFailed", { error: (error as Error)?.message ?? "?" })}
</p>
)}
{data && <ReportBody data={data} t={t} />}
</div>
);
}
function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
const cur = data.currency ?? "ALL";
const money = (m: number) => formatMoney(m, cur);
const tot = data.totals;
// Recharts series: label + the metrics. Keep the server's lexically-sortable bucket
// labels; trim the date prefix off hour labels for a tighter axis.
const series = data.series.map((p) => ({
...p,
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
revenue: p.revenueMinor / 100,
}));
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
const mix = [
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
].filter((s) => s.value > 0);
return (
<div className="space-y-4">
{/* KPI cards. */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
<Kpi
label={t("reports.kpi.subscribers")}
value={String(data.subscriptions.currentlyValid)}
/>
</div>
{/* Entry / exit over time. */}
<Panel title={t("reports.chart.flow")}>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line
type="monotone"
dataKey="entries"
name={t("reports.kpi.entries")}
stroke={C.green}
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="exits"
name={t("reports.kpi.exits")}
stroke={C.red}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</Panel>
<div className="grid gap-4 lg:grid-cols-2">
{/* Revenue per bucket. */}
<Panel title={t("reports.chart.revenue", { currency: cur })}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} />
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
</BarChart>
</ResponsiveContainer>
</Panel>
{/* Revenue mix (ticket vs subscription vs window). */}
<Panel title={t("reports.chart.mix")}>
{mix.length === 0 ? (
<Empty t={t} />
) : (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={mix}
dataKey="value"
nameKey="name"
innerRadius={48}
outerRadius={80}
paddingAngle={2}
>
{mix.map((s) => (
<Cell key={s.name} fill={s.color} stroke={C.panel} />
))}
</Pie>
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Number(v))} />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
)}
</Panel>
{/* Peak hours (entries by hour-of-day). */}
<Panel title={t("reports.chart.peakHours")}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
</BarChart>
</ResponsiveContainer>
</Panel>
{/* Cash / card + duration + subscription breakdown (numbers). */}
<Panel title={t("reports.chart.breakdown")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[13px]">
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
<Row label={t("reports.mix.subSales")} value={money(tot.subscriptionSalesMinor)} />
<Row label={t("reports.mix.subWindow")} value={money(tot.subscriptionWindowMinor)} />
<Row label={t("reports.row.closed")} value={String(tot.closedSessions)} />
<Row label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
<Row label={t("reports.row.medianStay")} value={formatMinutes(tot.medianParkedMinutes)} />
<Row label={t("reports.row.subActive")} value={String(data.subscriptions.active)} />
<Row label={t("reports.row.subCars")} value={String(data.subscriptions.coveredCars)} />
</dl>
</Panel>
</div>
<p className="text-[11px] text-term-muted">
{t("reports.footnote", { tz: data.tz })}
</p>
</div>
);
}
const tooltipStyle = {
background: C.panel,
border: `1px solid ${C.border}`,
borderRadius: 6,
color: C.text,
fontSize: 12,
};
function Kpi({ label, value, accent }: { label: string; value: string; accent?: "green" | "red" | "amber" | "cyan" }) {
const color =
accent === "green"
? "text-term-green"
: accent === "red"
? "text-term-red"
: accent === "amber"
? "text-term-amber"
: accent === "cyan"
? "text-term-cyan"
: "text-term-text";
return (
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{label}</div>
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
</div>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-term border border-term-border bg-term-panel p-3">
<h2 className="mb-2 text-[11px] uppercase tracking-wider text-term-muted">{title}</h2>
{children}
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<>
<dt className="text-term-muted">{label}</dt>
<dd className="text-right tabular-nums text-term-text">{value}</dd>
</>
);
}
function Empty({ t }: { t: TFunction }) {
return <p className="py-12 text-center text-[12px] text-term-muted">{t("reports.noData")}</p>;
}
+97 -8
View File
@@ -7,8 +7,10 @@ import {
fetchBackendIps, fetchBackendIps,
fetchCatalog, fetchCatalog,
fetchState, fetchState,
testAnpr,
testDevice, testDevice,
unassignDevice, unassignDevice,
type AnprTestResult,
type Assignment, type Assignment,
type BackendIpCandidate, type BackendIpCandidate,
type Catalog, type Catalog,
@@ -329,11 +331,13 @@ function DeviceForm({
// Pre-fill scalar config fields from the existing assignment when editing. // Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.) // (relays/controllerId/relay are model fields handled by their own state below.)
const [config, setConfig] = useState<Record<string, string | number>>(() => { // Booleans are kept as real booleans (a checkbox field) — older saved configs may
// have stored a boolean as the string "true"/"false"; normalize those on load.
const [config, setConfig] = useState<Record<string, string | number | boolean>>(() => {
if (!editCfg) return {}; if (!editCfg) return {};
const out: Record<string, string | number> = {}; const out: Record<string, string | number | boolean> = {};
for (const [k, v] of Object.entries(editCfg)) { for (const [k, v] of Object.entries(editCfg)) {
if (typeof v === "string" || typeof v === "number") out[k] = v; if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") out[k] = v;
} }
return out; return out;
}); });
@@ -352,6 +356,10 @@ function DeviceForm({
const [tested, setTested] = useState<TestResult | null>(null); const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null); const [testError, setTestError] = useState<string | null>(null);
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
const [anprTesting, setAnprTesting] = useState(false);
const [anprError, setAnprError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null); const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
@@ -409,9 +417,16 @@ function DeviceForm({
} }
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */ /** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
function mergedScalarConfig(): Record<string, string | number> { function mergedScalarConfig(): Record<string, string | number | boolean> {
const out: Record<string, string | number> = {}; const out: Record<string, string | number | boolean> = {};
for (const f of selected?.configFields ?? []) { for (const f of selected?.configFields ?? []) {
// Boolean (checkbox) fields persist a REAL boolean — always (so toggling one OFF
// on an edit actually writes false), defaulting to the field default or false.
if (f.type === "boolean") {
const cur = config[f.key];
out[f.key] = typeof cur === "boolean" ? cur : Boolean(cur ?? f.default ?? false);
continue;
}
const v = config[f.key] ?? (f.default as string | number | undefined); const v = config[f.key] ?? (f.default as string | number | undefined);
if (v !== undefined && v !== "") out[f.key] = v; if (v !== undefined && v !== "") out[f.key] = v;
} }
@@ -442,6 +457,8 @@ function DeviceForm({
setTested(null); setTested(null);
setTestError(null); setTestError(null);
setSaveError(null); setSaveError(null);
setAnprResult(null);
setAnprError(null);
} }
async function test() { async function test() {
@@ -458,6 +475,23 @@ function DeviceForm({
} }
} }
// End-to-end ANPR probe: capture a frame off this camera and run the vision service
// on it, reporting plate + time (or the failure stage). Only meaningful for an
// ANPR-enabled camera; never blocks save.
async function testAnprNow() {
if (!selected) return;
setAnprTesting(true);
setAnprError(null);
setAnprResult(null);
try {
setAnprResult(await testAnpr(selected.id, mergedScalarConfig()));
} catch (e) {
setAnprError((e as Error).message);
} finally {
setAnprTesting(false);
}
}
async function save() { async function save() {
if (!selected) return; if (!selected) return;
// Bound devices must point at a controller relay (binding is optional in the // Bound devices must point at a controller relay (binding is optional in the
@@ -535,7 +569,27 @@ function DeviceForm({
</div> </div>
)} )}
{selected.configFields.map((f) => ( {selected.configFields.map((f) =>
f.type === "boolean" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not
// the string "true"). The label sits beside the box, with the help below.
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<input
type="checkbox"
className="mt-0.5"
checked={Boolean(config[f.key] ?? f.default ?? false)}
onChange={(e) => {
const v = e.target.checked;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
<span>
<span className="font-semibold text-term-text">{f.label}</span>
{f.help && <span className="hint mt-0.5 block">{f.help}</span>}
</span>
</label>
) : (
<div key={f.key} className="field my-2 max-w-sm"> <div key={f.key} className="field my-2 max-w-sm">
<label className="label"> <label className="label">
{f.label} {f.label}
@@ -561,7 +615,7 @@ function DeviceForm({
<input <input
className="input" className="input"
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"} type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""} value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
placeholder={f.help} placeholder={f.help}
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
@@ -571,7 +625,8 @@ function DeviceForm({
/> />
)} )}
</div> </div>
))} ),
)}
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */} {/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
{isController && <RelayEditor relays={relays} onChange={setRelays} />} {isController && <RelayEditor relays={relays} onChange={setRelays} />}
@@ -641,6 +696,40 @@ function DeviceForm({
</div> </div>
)} )}
{/* CAMERA + ANPR on: a bottom-of-modal end-to-end probe — capture a frame and
run the vision service on it, reporting the plate read + how long it took. */}
{isCamera && anpr && (
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={testAnprNow} disabled={anprTesting}>
{anprTesting ? t("setup.anprTesting") : t("setup.testAnpr")}
</button>
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
{anprError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
{anprResult &&
(anprResult.ok ? (
<div className="mt-2 text-[12px] text-term-green">
{t("setup.anprOk", {
plate: anprResult.plate,
confidence: Math.round(anprResult.confidence * 100),
ms: anprResult.tookMs,
})}
{anprResult.lowConfidence && (
<span className="ml-1 text-term-amber">{t("setup.anprLowConfidence")}</span>
)}
</div>
) : (
<div className="mt-2 text-[12px] text-term-amber">
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
{anprResult.tookMs != null && (
<span className="text-term-muted"> ({t("setup.anprTookMs", { ms: anprResult.tookMs })})</span>
)}
</div>
))}
</div>
)}
{backendIps && backendIps.length > 0 && ( {backendIps && backendIps.length > 0 && (
<div className="mt-3"> <div className="mt-3">
<div className="field max-w-md"> <div className="field max-w-md">
+22 -45
View File
@@ -15,6 +15,7 @@ import {
} from "./api.js"; } from "./api.js";
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js"; import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { Modal } from "./ui/Modal.js"; import { Modal } from "./ui/Modal.js";
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
import type { LedgerEvent } from "@parking/shared"; import type { LedgerEvent } from "@parking/shared";
// Shift hub — a two-pane master/detail. LEFT: the open/CURRENT shift (when any) plus // Shift hub — a two-pane master/detail. LEFT: the open/CURRENT shift (when any) plus
@@ -28,22 +29,6 @@ function money(minor: number, currency: string | null): string {
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2); return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
} }
// Event styling for the activity log (mirrors the booth live feed).
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
type Preset = "yesterday" | "week" | "month" | "custom" | "all"; type Preset = "yesterday" | "week" | "month" | "custom" | "all";
/** A preset → an inclusive [from, to] date window (yyyy-mm-dd) over the shift START. */ /** A preset → an inclusive [from, to] date window (yyyy-mm-dd) over the shift START. */
@@ -142,9 +127,12 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"]; const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"];
// Fill the viewport like the booth: a fixed title + filters, then a two-pane area
// that takes the remaining height — the shift LIST and the activity LOG each scroll
// on their own rather than the whole page growing.
return ( return (
<div> <div className="flex h-full min-h-0 flex-col">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex shrink-0 flex-wrap items-center justify-between gap-2">
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber"> <h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
{isAdmin ? t("shifts.title") : t("shifts.myTitle")} {isAdmin ? t("shifts.title") : t("shifts.myTitle")}
</h1> </h1>
@@ -155,7 +143,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
</div> </div>
{/* Filters: timeframe presets (everyone) + operator (admin only). */} {/* Filters: timeframe presets (everyone) + operator (admin only). */}
<div className="card mb-3 flex flex-wrap items-end gap-3 p-3"> <div className="card mb-3 flex shrink-0 flex-wrap items-end gap-3 p-3">
<div className="field"> <div className="field">
<span className="label">{t("shifts.timeframe")}</span> <span className="label">{t("shifts.timeframe")}</span>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -187,12 +175,13 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
</div> </div>
{q.isError && ( {q.isError && (
<div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div> <div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
)} )}
{/* Two-pane: shift list (left) + selected shift's activity log (right). */} {/* Two-pane: shift list (left) + selected shift's activity log (right). Both
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]"> panes scroll independently and fill the remaining height (like the booth). */}
<div className="flex flex-col gap-1.5"> <div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
{!q.isLoading && list.length === 0 && ( {!q.isLoading && list.length === 0 && (
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p> <p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
)} )}
@@ -201,7 +190,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
))} ))}
</div> </div>
<div className="rounded-term border border-term-border"> <div className="min-h-0 overflow-hidden rounded-term border border-term-border">
{selected ? ( {selected ? (
<ShiftActivityLog <ShiftActivityLog
shift={selected} shift={selected}
@@ -294,6 +283,9 @@ function ShiftActivityLog({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null); const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
// (full signed payload + snapshots + chain provenance).
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded. // The current shift's log runs entry→now (no upper bound); a closed shift is bounded.
const q = useQuery({ const q = useQuery({
@@ -304,9 +296,10 @@ function ShiftActivityLog({
const events = q.data?.events ?? []; const events = q.data?.events ?? [];
const cur = shift.currency; const cur = shift.currency;
// Fill the pane: a fixed header + a scrollable activity list (matches the booth feed).
return ( return (
<div> <div className="flex h-full min-h-0 flex-col">
<div className="border-b border-term-border bg-term-panel-2 px-3 py-2"> <div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]"> <div className="flex flex-wrap items-center justify-between gap-2 text-[12px]">
<span className="flex items-center gap-2 font-semibold text-term-text"> <span className="flex items-center gap-2 font-semibold text-term-text">
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>} {isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
@@ -337,14 +330,15 @@ function ShiftActivityLog({
</div> </div>
</div> </div>
<div className="max-h-[62vh] overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto px-1">
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>} {q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>} {!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>}
{events.map((e) => ( {events.map((e) => (
<ActivityRow key={e.id} e={e} /> <EventRow key={e.id} e={e} onOpen={setDetailEvent} />
))} ))}
</div> </div>
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />} {modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />} {modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />} {modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
@@ -516,23 +510,6 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
); );
} }
function ActivityRow({ e }: { e: LedgerEvent }) {
const { t } = useTranslation();
const style = EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
const time = new Date(e.occurredAt).toLocaleTimeString();
const p = e.payload ?? {};
const amount = typeof p.amountMinor === "number" && p.amountMinor !== 0 ? money(p.amountMinor, (p.currency as string) ?? null) : null;
const actor = (e.subscriberLabel as string | undefined) ?? e.identity ?? (p.sessionRef as string | undefined) ?? "";
return (
<div className="flex items-center gap-2 border-t border-term-border/60 px-3 py-1.5 text-[12px] first:border-t-0">
<span className="w-16 shrink-0 tabular-nums text-term-muted">{time}</span>
<span className={`w-20 shrink-0 font-semibold uppercase ${style.color}`}>{style.labelKey ? t(style.labelKey) : e.type}</span>
<span className="min-w-0 flex-1 truncate text-term-text" title={actor}>{actor}</span>
{amount && <span className="shrink-0 tabular-nums text-term-cyan">{amount}</span>}
</div>
);
}
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return ( return (
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}> <div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
+15
View File
@@ -26,6 +26,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [meta, setMeta] = useState<Record<string, string>>({}); const [meta, setMeta] = useState<Record<string, string>>({});
const [exitVoucherDefault, setExitVoucherDefault] = useState(false); const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
const [reserveSubs, setReserveSubs] = useState(false); const [reserveSubs, setReserveSubs] = useState(false);
const [anprEntry, setAnprEntry] = useState(true);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
function reload() { function reload() {
@@ -38,6 +39,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
setCapInput(c.capacity == null ? "" : String(c.capacity)); setCapInput(c.capacity == null ? "" : String(c.capacity));
setExitVoucherDefault(c.exitVoucherDefault); setExitVoucherDefault(c.exitVoucherDefault);
setReserveSubs(c.reserveSubscriberSpots); setReserveSubs(c.reserveSubscriberSpots);
setAnprEntry(c.anprEntryEnabled);
const m: Record<string, string> = {}; const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]); for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m); setMeta(m);
@@ -52,6 +54,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
capacity: raw === "" ? null : Math.round(Number(raw)), capacity: raw === "" ? null : Math.round(Number(raw)),
exitVoucherDefault, exitVoucherDefault,
reserveSubscriberSpots: reserveSubs, reserveSubscriberSpots: reserveSubs,
anprEntryEnabled: anprEntry,
}; };
// Send each metadata field; "" → null is applied server-side. // Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? ""; for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
@@ -112,6 +115,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.reserveSubsHint")}</span> <span className="hint block">{t("site.reserveSubsHint")}</span>
</span> </span>
</label> </label>
<label className="flex items-start gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="mt-0.5 accent-term-amber"
checked={anprEntry}
onChange={(e) => setAnprEntry(e.target.checked)}
/>
<span>
{t("site.anprEntry")}
<span className="hint block">{t("site.anprEntryHint")}</span>
</span>
</label>
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted"> <div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")} {t("site.parkDetails")}
</div> </div>
+155
View File
@@ -75,6 +75,8 @@ export interface SessionUser {
theme: Theme; theme: Theme;
/** Optional display name (profile metadata); null if unset. */ /** Optional display name (profile metadata); null if unset. */
fullName: string | null; fullName: string | null;
/** Optional contact email (profile metadata); null if unset. */
email: string | null;
} }
/** Does this session grant the permission? Central authz check for the SPA. */ /** Does this session grant the permission? Central authz check for the SPA. */
@@ -103,6 +105,29 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) }); return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
} }
/** Edit MY own profile (display name / email). Returns the refreshed session.
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
export function updateMyProfile(patch: {
fullName?: string | null;
email?: string | null;
}): Promise<SessionUser> {
return apiFetch<SessionUser>("/api/auth/profile", {
method: "PUT",
body: JSON.stringify(patch),
});
}
/** Change MY own password — proves the current one first (server enforces). */
export function changeMyPassword(
currentPassword: string,
newPassword: string,
): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/password", {
method: "PUT",
body: JSON.stringify({ currentPassword, newPassword }),
});
}
/** Returns the current user, or null if not authenticated. */ /** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> { export async function fetchMe(): Promise<SessionUser | null> {
try { try {
@@ -280,6 +305,127 @@ export function testDevice(driverId: string, config: DeviceConfig): Promise<Test
}); });
} }
/** Result of an end-to-end ANPR probe on a camera: snapshot → vision analyze. */
export type AnprTestResult =
| {
ok: true;
plate: string;
confidence: number;
region: string | null;
lowConfidence: boolean;
modelVersion: string;
tookMs: number;
}
| {
ok: false;
/** vision-disabled | snapshot-failed | no-plate */
reason: string;
detail?: string;
tookMs?: number;
};
/** Take a live snapshot off the camera and run ANPR on it — without saving. Reports
* whether a plate was extracted, the read, and how long it took. */
export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTestResult> {
return apiFetch<AnprTestResult>("/api/setup/test-anpr", {
method: "POST",
body: JSON.stringify({ driverId, config }),
});
}
// --- Admin reports -------------------------------------------------------
export type ReportBucket = "hour" | "day" | "month";
export interface ReportSeriesPoint {
bucket: string;
entries: number;
exits: number;
revenueMinor: number;
payments: number;
}
export interface ReportTotals {
entries: number;
exits: number;
payments: number;
revenueMinor: number;
cashMinor: number;
cardMinor: number;
ticketMinor: number;
subscriptionSalesMinor: number;
subscriptionWindowMinor: number;
closedSessions: number;
totalParkedMinutes: number;
avgParkedMinutes: number;
medianParkedMinutes: number;
}
export interface ReportSubscriptionStats {
active: number;
suspended: number;
revoked: number;
currentlyValid: number;
coveredCars: number;
}
export interface ReportSummary {
from: string;
to: string;
bucket: ReportBucket;
tz: string;
currency: string | null;
totals: ReportTotals;
series: ReportSeriesPoint[];
entriesByHour: number[];
subscriptions: ReportSubscriptionStats;
}
/** The whole admin dashboard (totals + series + peak-hours + subscriptions) for a range. */
export function fetchReport(from: string, to: string, bucket: ReportBucket): Promise<ReportSummary> {
const qs = new URLSearchParams({ from, to, bucket }).toString();
return apiFetch<ReportSummary>(`/api/reports/summary?${qs}`);
}
/** URL for the CSV export of the per-bucket series (opened/downloaded directly; the
* auth cookie rides along same-origin). */
export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): string {
const qs = new URLSearchParams({ from, to, bucket }).toString();
return apiUrl(`/api/reports/summary.csv?${qs}`);
}
// --- Recycle bin (soft-deleted master data) ------------------------------
export type RecycleKind = "user" | "role" | "subscription" | "plan" | "tariff";
export interface RecycleBinItem {
kind: RecycleKind;
id: string;
label: string;
deletedAt: string;
deletedBy: string | null;
}
export interface RecycleBin {
items: RecycleBinItem[];
retentionDays: number;
}
/** Everything currently in the recycle bin + the retention window (days). */
export function fetchRecycleBin(): Promise<RecycleBin> {
return apiFetch<RecycleBin>("/api/recycle-bin");
}
/** Restore a soft-deleted item (back to its catalog). 409 if a live row would collide. */
export function restoreRecycleItem(kind: RecycleKind, id: string): Promise<{ restored: boolean }> {
return apiFetch<{ restored: boolean }>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}/restore`, {
method: "POST",
});
}
/** Permanently purge a soft-deleted item. Irreversible. */
export function purgeRecycleItem(kind: RecycleKind, id: string): Promise<void> {
return apiFetch<void>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export interface BackendIpCandidate { export interface BackendIpCandidate {
ip: string; ip: string;
iface: string; iface: string;
@@ -822,6 +968,8 @@ export interface SiteConfig {
subscriptionMonthlyPriceMinor: number | null; subscriptionMonthlyPriceMinor: number | null;
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */ /** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
reserveSubscriberSpots: boolean; reserveSubscriberSpots: boolean;
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */
anprEntryEnabled: boolean;
parkName: string | null; parkName: string | null;
operatorName: string | null; operatorName: string | null;
/** NIUS — Albanian tax/identification number. */ /** NIUS — Albanian tax/identification number. */
@@ -962,6 +1110,13 @@ export function paySession(
}); });
} }
/** Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event with
* the operator + a required reason; the entry itself is never edited (append-only).
* Refuses a subscription / already-exited / already-voided / paid ticket (409). */
export function voidTicket(identity: string, reason: string): Promise<{ ok: boolean; identity?: string }> {
return apiFetch("/api/tickets/void", { method: "POST", body: JSON.stringify({ identity, reason }) });
}
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't /** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
* open (payment stands; operator opens manually). */ * open (payment stands; operator opens manually). */
export type BoothExitResult = { ok: true; opened: boolean; reason?: string }; export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { formatMoney, formatDuration, formatTime, formatRelativeDateTime, type TFn } from "./format.js";
// The booth's display formatters. Money is integer MINOR units (never a float, matching
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
// log/history rows. These are the numbers an operator reads off the screen.
describe("formatMoney", () => {
it("renders minor units as a major-unit currency string", () => {
// 20000 minor = 200.00; the exact glyph/locale varies, but the number must show.
expect(formatMoney(20000, "ALL")).toContain("200");
});
it("falls back to '<n> <code>' for a malformed currency code", () => {
// Intl requires a 3-letter ISO code; a malformed one throws RangeError → fallback.
// (Note: an unknown-but-well-formed code like "ZZZ" does NOT throw — Intl renders it.)
expect(formatMoney(12345, "X")).toBe("123.45 X");
});
});
describe("formatDuration", () => {
const base = "2026-06-21T10:00:00.000Z";
it("shows minutes under an hour", () => {
expect(formatDuration(base, "2026-06-21T10:47:00.000Z")).toBe("47m");
});
it("shows hours and minutes past an hour", () => {
expect(formatDuration(base, "2026-06-21T12:14:00.000Z")).toBe("2h 14m");
});
it("renders 0m for a sub-minute span", () => {
expect(formatDuration(base, "2026-06-21T10:00:30.000Z")).toBe("0m");
});
it("returns an em dash for a negative or invalid span", () => {
expect(formatDuration("2026-06-21T10:00:00.000Z", "2026-06-21T09:00:00.000Z")).toBe("—");
expect(formatDuration("bad", "also-bad")).toBe("—");
});
});
describe("formatTime", () => {
it("returns an em dash for null/invalid", () => {
expect(formatTime(null)).toBe("—");
expect(formatTime("not-a-date")).toBe("—");
});
it("renders HH:MM:SS local time", () => {
expect(formatTime("2026-06-21T10:48:25.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
});
describe("formatRelativeDateTime", () => {
// A tiny fake t(): today/yesterday words + the month-name array.
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
const t = ((key: string, opts?: { returnObjects: true }) => {
if (key === "common.today") return "Sot";
if (key === "common.yesterday") return "Dje";
if (key === "common.months" && opts?.returnObjects) return months;
return key;
}) as TFn;
it("labels today with the localized word + HH:MM", () => {
const now = new Date();
now.setHours(10, 48, 0, 0);
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
});
it("labels yesterday with the localized word", () => {
const y = new Date();
y.setDate(y.getDate() - 1);
y.setHours(17, 33, 0, 0);
expect(formatRelativeDateTime(y.toISOString(), t)).toMatch(/^Dje \d{2}:\d{2}$/);
});
it("uses the catalog month name for an older date (no Intl dependence)", () => {
// A fixed older date in the same year as 'now' would risk year drift; use an
// explicit past date and just assert a catalog month name appears.
const out = formatRelativeDateTime("2020-03-05T08:15:00.000Z", t);
expect(out).toContain("Mars");
expect(out).toContain("2020"); // different year → year shown
});
it("returns an em dash for null/invalid", () => {
expect(formatRelativeDateTime(null, t)).toBe("—");
expect(formatRelativeDateTime("nope", t)).toBe("—");
});
});
+8
View File
@@ -22,6 +22,14 @@ export function formatDuration(fromIso: string, toIso: string): string {
return h > 0 ? `${h}h ${m}m` : `${m}m`; return h > 0 ? `${h}h ${m}m` : `${m}m`;
} }
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
export function formatMinutes(mins: number): string {
if (!Number.isFinite(mins) || mins < 0) return "—";
const m = Math.round(mins);
const h = Math.floor(m / 60);
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
}
/** Local time-of-day HH:MM:SS from an ISO string. */ /** Local time-of-day HH:MM:SS from an ISO string. */
export function formatTime(iso: string | null): string { export function formatTime(iso: string | null): string {
if (!iso) return "—"; if (!iso) return "—";
+103
View File
@@ -55,7 +55,30 @@ export const en: Catalog = {
users: "Users", users: "Users",
roles: "Roles", roles: "Roles",
shifts: "Shifts", shifts: "Shifts",
reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs", logs: "Logs",
profile: "Profile",
},
profile: {
title: "My profile",
accountSection: "Account",
fullName: "Full name",
fullNamePh: "First and last name",
email: "Email",
emailPh: "you@example.com",
username: "Username",
role: "Role",
saveProfile: "Save profile",
profileSaved: "Profile saved.",
passwordSection: "Change password",
currentPassword: "Current password",
newPassword: "New password",
confirmPassword: "Confirm password",
changePassword: "Change password",
passwordChanged: "Password changed.",
passwordsDontMatch: "Passwords don't match.",
passwordTooShort: "Password must be at least {{min}} characters.",
}, },
status: { status: {
live: "LIVE", live: "LIVE",
@@ -93,6 +116,8 @@ export const en: Catalog = {
booth: { booth: {
processTicket: "Process ticket", processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…", scanPlaceholder: "Scan or type ticket number…",
laneEntry: "Entry",
laneExit: "Exit",
open: "Open", open: "Open",
occupancy: "Occupancy", occupancy: "Occupancy",
occUnavailable: "occupancy unavailable", occUnavailable: "occupancy unavailable",
@@ -152,6 +177,7 @@ export const en: Catalog = {
evtCashIn: "PAY-IN", evtCashIn: "PAY-IN",
evtCashOut: "PAY-OUT", evtCashOut: "PAY-OUT",
evtAnomaly: "ANOMALY", evtAnomaly: "ANOMALY",
evtRefused: "REFUSED",
// live-feed event detail line + classification badges (computed from payload) // live-feed event detail line + classification badges (computed from payload)
evtNoReason: "no reason recorded", evtNoReason: "no reason recorded",
badgeEntryRefused: "entry refused", badgeEntryRefused: "entry refused",
@@ -220,6 +246,7 @@ export const en: Catalog = {
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)", "sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)", "sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth", "sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
}, },
tariff: { tariff: {
title: "Tariff", title: "Tariff",
@@ -344,6 +371,16 @@ export const en: Catalog = {
anpr: "Plate recognition (ANPR)", anpr: "Plate recognition (ANPR)",
anprHint: anprHint:
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.", "Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.",
testAnpr: "Test ANPR",
anprTesting: "Testing ANPR…",
testAnprHint:
"Takes a live snapshot from this camera and tries to read a plate, reporting the result and the time it took. Point a plate at the camera first.",
anprOk: "✓ Read plate {{plate}} — {{confidence}}% confidence, {{ms}} ms",
anprLowConfidence: "(low confidence — advisory only)",
anprTookMs: "{{ms}} ms",
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
"anprFail.no-plate": "No plate found in the snapshot.",
whichBarrier: "Which barrier does this device serve?", whichBarrier: "Which barrier does this device serve?",
controller: "Controller", controller: "Controller",
choose: "Choose…", choose: "Choose…",
@@ -518,6 +555,8 @@ export const en: Catalog = {
printExitHint: "(booth far from exit → customer self-exits with a voucher)", printExitHint: "(booth far from exit → customer self-exits with a voucher)",
reserveSubs: "Reserve subscriber spots", reserveSubs: "Reserve subscriber spots",
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).", reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
anprEntry: "Auto-open for subscriber plates (ANPR)",
anprEntryHint: "When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.",
parkDetails: "Park details (optional — shown on tickets/receipts)", parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save", save: "Save",
saved: "Saved.", saved: "Saved.",
@@ -667,6 +706,58 @@ export const en: Catalog = {
cashRemoved: "Cash removed", cashRemoved: "Cash removed",
loadFailed: "Failed to load shifts.", loadFailed: "Failed to load shifts.",
}, },
reports: {
title: "Reports",
groupBy: "Group by",
exportCsv: "Export CSV",
loadFailed: "Couldn't load the report: {{error}}",
noData: "No data in this range.",
footnote: "Counts and money are summed from the signed event log. Times shown in {{tz}}.",
preset: { today: "Today", "7d": "7 days", "30d": "30 days", "90d": "90 days" },
bucket: { hour: "Hour", day: "Day", month: "Month" },
kpi: {
entries: "Entries",
exits: "Exits",
revenue: "Revenue",
payments: "Payments",
avgStay: "Avg stay",
subscribers: "Subscribers",
},
chart: {
flow: "Entries & exits over time",
revenue: "Revenue ({{currency}})",
mix: "Revenue mix",
peakHours: "Entries by hour of day",
breakdown: "Breakdown",
},
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
row: {
cash: "Cash",
card: "Card",
closed: "Closed sessions",
medianStay: "Median stay",
subActive: "Active subscriptions",
subCars: "Cars covered",
},
},
recycleBin: {
title: "Recycle bin",
retentionNote: "Deleted items are kept for {{days}} days, then permanently removed.",
empty: "Nothing deleted. Items you delete appear here, recoverable until they expire.",
col: { type: "Type", item: "Item", deleted: "Deleted", actions: "" },
kind: {
user: "User",
role: "Role",
subscription: "Subscription",
plan: "Plan",
tariff: "Tariff",
},
restore: "Restore",
purge: "Purge",
purgeConfirmTitle: "Purge permanently?",
purgeConfirmBody: "Permanently delete “{{label}}”? It cannot be restored after this.",
purgeIrreversible: "This is irreversible.",
},
logs: { logs: {
title: "System logs", title: "System logs",
refresh: "Refresh", refresh: "Refresh",
@@ -731,6 +822,18 @@ export const en: Catalog = {
receiptReprinted: "Receipt reprinted on {{printer}}.", receiptReprinted: "Receipt reprinted on {{printer}}.",
reprintReceipt: "Reprint receipt", reprintReceipt: "Reprint receipt",
reprinting: "printing…", reprinting: "printing…",
cancelTicket: "Cancel ticket",
cancelTicketTitle: "Cancel this ticket",
cancelTicketHint: "Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.",
cancelReason: {
misprint: "Misprint",
test: "Test",
wrongVehicle: "Wrong vehicle",
},
cancelReasonPlaceholder: "Reason for cancelling (required)…",
confirmCancelTicket: "Confirm cancellation",
cancelling: "cancelling…",
ticketCancelled: "Ticket cancelled.",
noSnapshots: "no snapshots", noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…", loadingSnapshots: "loading snapshots…",
snapEntry: "entry", snapEntry: "entry",
+104 -1
View File
@@ -57,7 +57,30 @@ export const sq = {
users: "Përdoruesit", users: "Përdoruesit",
roles: "Rolet", roles: "Rolet",
shifts: "Turnet", shifts: "Turnet",
reports: "Raportet",
recycleBin: "Koshi",
logs: "Loget", logs: "Loget",
profile: "Profili",
},
profile: {
title: "Profili im",
accountSection: "Llogaria",
fullName: "Emri i plotë",
fullNamePh: "Emri dhe mbiemri",
email: "Email",
emailPh: "ti@shembull.com",
username: "Përdoruesi",
role: "Roli",
saveProfile: "Ruaj profilin",
profileSaved: "Profili u ruajt.",
passwordSection: "Ndrysho fjalëkalimin",
currentPassword: "Fjalëkalimi aktual",
newPassword: "Fjalëkalimi i ri",
confirmPassword: "Konfirmo fjalëkalimin",
changePassword: "Ndrysho fjalëkalimin",
passwordChanged: "Fjalëkalimi u ndryshua.",
passwordsDontMatch: "Fjalëkalimet nuk përputhen.",
passwordTooShort: "Fjalëkalimi duhet të jetë të paktën {{min}} karaktere.",
}, },
status: { status: {
live: "LIVE", live: "LIVE",
@@ -95,6 +118,8 @@ export const sq = {
booth: { booth: {
processTicket: "Proceso biletën", processTicket: "Proceso biletën",
scanPlaceholder: "Skano ose shkruaj numrin e biletës…", scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
laneEntry: "Hyrje",
laneExit: "Dalje",
open: "Hap", open: "Hap",
occupancy: "Prania", occupancy: "Prania",
occUnavailable: "zënia e padisponueshme", occUnavailable: "zënia e padisponueshme",
@@ -156,6 +181,7 @@ export const sq = {
evtCashIn: "ARKËTIM", evtCashIn: "ARKËTIM",
evtCashOut: "PAGESË", evtCashOut: "PAGESË",
evtAnomaly: "ANOMALI", evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR",
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload) // rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
evtNoReason: "pa arsye të regjistruar", evtNoReason: "pa arsye të regjistruar",
badgeEntryRefused: "hyrje e refuzuar", badgeEntryRefused: "hyrje e refuzuar",
@@ -223,6 +249,7 @@ export const sq = {
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)", "sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)", "sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë", "sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
}, },
tariff: { tariff: {
title: "Tarifa", title: "Tarifa",
@@ -354,6 +381,16 @@ export const sq = {
anpr: "Njohja e targave (ANPR)", anpr: "Njohja e targave (ANPR)",
anprHint: anprHint:
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.", "Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.",
testAnpr: "Testo ANPR",
anprTesting: "Duke testuar ANPR…",
testAnprHint:
"Merr një pamje të drejtpërdrejtë nga kjo kamerë dhe përpiqet të lexojë një targë, duke raportuar rezultatin dhe kohën e nevojshme. Vendos një targë para kamerës më parë.",
anprOk: "✓ Targa u lexua {{plate}} — {{confidence}}% besueshmëri, {{ms}} ms",
anprLowConfidence: "(besueshmëri e ulët — vetëm këshillues)",
anprTookMs: "{{ms}} ms",
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
// Binding picker. // Binding picker.
whichBarrier: "Cilën barrierë shërben kjo pajisje?", whichBarrier: "Cilën barrierë shërben kjo pajisje?",
controller: "Kontrolluesi", controller: "Kontrolluesi",
@@ -529,6 +566,8 @@ export const sq = {
printExitHint: "(klienti skanon biletën në dalje)", printExitHint: "(klienti skanon biletën në dalje)",
reserveSubs: "Rezervo vendet e abonentëve", reserveSubs: "Rezervo vendet e abonentëve",
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).", reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
anprEntry: "Hapje automatike për targat e abonentëve (ANPR)",
anprEntryHint: "Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)", parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj", save: "Ruaj",
saved: "U ruajt.", saved: "U ruajt.",
@@ -669,7 +708,7 @@ export const sq = {
preset_week: "Javën e fundit", preset_week: "Javën e fundit",
preset_month: "Muajin e fundit", preset_month: "Muajin e fundit",
preset_all: "Të gjitha", preset_all: "Të gjitha",
preset_custom: "E zgjedhur", preset_custom: "Zgjidh periudhë",
selectAShift: "Zgjidh një turn për të parë aktivitetin e tij.", selectAShift: "Zgjidh një turn për të parë aktivitetin e tij.",
noActivity: "Asnjë aktivitet në këtë turn.", noActivity: "Asnjë aktivitet në këtë turn.",
current: "aktual", current: "aktual",
@@ -681,6 +720,58 @@ export const sq = {
cashRemoved: "Para të hequra", cashRemoved: "Para të hequra",
loadFailed: "Ngarkimi i turneve dështoi.", loadFailed: "Ngarkimi i turneve dështoi.",
}, },
reports: {
title: "Raportet",
groupBy: "Grupo sipas",
exportCsv: "Eksporto CSV",
loadFailed: "Raporti nuk u ngarkua dot: {{error}}",
noData: "Nuk ka të dhëna në këtë interval.",
footnote: "Numërimet dhe paratë mblidhen nga regjistri i nënshkruar. Oraret në {{tz}}.",
preset: { today: "Sot", "7d": "7 ditë", "30d": "30 ditë", "90d": "90 ditë" },
bucket: { hour: "Orë", day: "Ditë", month: "Muaj" },
kpi: {
entries: "Hyrje",
exits: "Dalje",
revenue: "Të ardhura",
payments: "Pagesa",
avgStay: "Qëndrim mes.",
subscribers: "Abonentë",
},
chart: {
flow: "Hyrjet & daljet me kalimin e kohës",
revenue: "Të ardhurat ({{currency}})",
mix: "Përbërja e të ardhurave",
peakHours: "Hyrjet sipas orës së ditës",
breakdown: "Ndarja",
},
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
row: {
cash: "Para në dorë",
card: "Kartë",
closed: "Sesione të mbyllura",
medianStay: "Qëndrim mesatar (median)",
subActive: "Abonime aktive",
subCars: "Makina të mbuluara",
},
},
recycleBin: {
title: "Koshi",
retentionNote: "Artikujt e fshirë mbahen për {{days}} ditë, pastaj hiqen përgjithmonë.",
empty: "Asgjë e fshirë. Artikujt që fshini shfaqen këtu, të rikuperueshëm derisa të skadojnë.",
col: { type: "Lloji", item: "Artikulli", deleted: "Fshirë", actions: "" },
kind: {
user: "Përdorues",
role: "Rol",
subscription: "Abonim",
plan: "Plan",
tariff: "Tarifë",
},
restore: "Rikthe",
purge: "Fshi përfundimisht",
purgeConfirmTitle: "Të fshihet përfundimisht?",
purgeConfirmBody: "Të fshihet përgjithmonë “{{label}}”? Nuk mund të rikthehet pas kësaj.",
purgeIrreversible: "Ky veprim është i pakthyeshëm.",
},
logs: { logs: {
title: "Loget e sistemit", title: "Loget e sistemit",
refresh: "Rifresko", refresh: "Rifresko",
@@ -745,6 +836,18 @@ export const sq = {
receiptReprinted: "Fatura u riprintua në {{printer}}.", receiptReprinted: "Fatura u riprintua në {{printer}}.",
reprintReceipt: "Riprinto faturën", reprintReceipt: "Riprinto faturën",
reprinting: "duke printuar…", reprinting: "duke printuar…",
cancelTicket: "Anulo biletën",
cancelTicketTitle: "Anulo këtë biletë",
cancelTicketHint: "Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.",
cancelReason: {
misprint: "Printim i gabuar",
test: "Test",
wrongVehicle: "Automjet i gabuar",
},
cancelReasonPlaceholder: "Arsyeja e anulimit (e detyrueshme)…",
confirmCancelTicket: "Konfirmo anulimin",
cancelling: "duke anuluar…",
ticketCancelled: "Bileta u anulua.",
// snapshots // snapshots
noSnapshots: "asnjë foto", noSnapshots: "asnjë foto",
loadingSnapshots: "duke ngarkuar fotot…", loadingSnapshots: "duke ngarkuar fotot…",
+13 -1
View File
@@ -10,6 +10,12 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
/** Connection state of the booth WebSocket, for a status indicator in the UI. */ /** Connection state of the booth WebSocket, for a status indicator in the UI. */
export type WsStatus = "connecting" | "open" | "closed"; export type WsStatus = "connecting" | "open" | "closed";
/** Per-lane busy/free from camera vehicle detection (advisory barrier lights). */
export interface LaneStatus {
entry: boolean; // true = busy
exit: boolean; // true = busy
}
/** Cap the in-memory live feed so a long-running booth session can't grow it /** Cap the in-memory live feed so a long-running booth session can't grow it
* unbounded — the full history is always available via the /api/events query. */ * unbounded — the full history is always available via the /api/events query. */
const MAX_FEED = 200; const MAX_FEED = 200;
@@ -23,6 +29,8 @@ interface LiveState {
/** Live device status keyed by device id (for the footer): set from the WS /** Live device status keyed by device id (for the footer): set from the WS
* hello snapshot, then upserted per device on each device-status push. */ * hello snapshot, then upserted per device on each device-status push. */
devices: Record<string, DeviceStatus>; devices: Record<string, DeviceStatus>;
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
lanes: LaneStatus | null;
setStatus: (s: WsStatus) => void; setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void; setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void; pushEvent: (e: LedgerEvent) => void;
@@ -30,6 +38,8 @@ interface LiveState {
setDevices: (list: DeviceStatus[]) => void; setDevices: (list: DeviceStatus[]) => void;
/** Upsert one device's status (a device-status push). */ /** Upsert one device's status (a device-status push). */
upsertDevice: (d: DeviceStatus) => void; upsertDevice: (d: DeviceStatus) => void;
/** Set lane busy/free (WS hello + each lane-status push). */
setLanes: (l: LaneStatus) => void;
reset: () => void; reset: () => void;
} }
@@ -45,6 +55,7 @@ export const useLiveStore = create<LiveState>((set) => ({
occupancy: null, occupancy: null,
feed: [], feed: [],
devices: {}, devices: {},
lanes: null,
setStatus: (status) => set({ status }), setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }), setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) => pushEvent: (e) =>
@@ -54,5 +65,6 @@ export const useLiveStore = create<LiveState>((set) => ({
})), })),
setDevices: (list) => set({ devices: byId(list) }), setDevices: (list) => set({ devices: byId(list) }),
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })), upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }), setLanes: (lanes) => set({ lanes }),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
})); }));
+3
View File
@@ -27,4 +27,7 @@ export const qk = {
siteConfig: ["site-config"] as const, siteConfig: ["site-config"] as const,
shift: ["shift"] as const, shift: ["shift"] as const,
deviceStatus: ["device-status"] as const, deviceStatus: ["device-status"] as const,
report: (from: string, to: string, bucket: string) =>
["report", from, to, bucket] as const,
recycleBin: ["recycle-bin"] as const,
} as const; } as const;
+8 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js"; import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js"; import { qk } from "./query.js";
import { useLiveStore } from "./live-store.js"; import { useLiveStore, type LaneStatus } from "./live-store.js";
import { wsUrl } from "./origin.js"; import { wsUrl } from "./origin.js";
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into // Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
@@ -14,15 +14,16 @@ import { wsUrl } from "./origin.js";
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */ /** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage = type WsMessage =
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] } | { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy } | { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown } | { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus }; | { kind: "device-status"; event: DeviceStatus }
| { kind: "lane-status"; lanes: LaneStatus };
export function useLiveFeed(): void { export function useLiveFeed(): void {
const qc = useQueryClient(); const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore(); const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode // Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount. // double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null); const sockRef = useRef<WebSocket | null>(null);
@@ -54,8 +55,11 @@ export function useLiveFeed(): void {
setOccupancy(msg.occupancy); setOccupancy(msg.occupancy);
// Initial device-status snapshot for the footer. // Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices); if (Array.isArray(msg.devices)) setDevices(msg.devices);
if (msg.lanes) setLanes(msg.lanes);
} else if (msg.kind === "device-status") { } else if (msg.kind === "device-status") {
upsertDevice(msg.event); upsertDevice(msg.event);
} else if (msg.kind === "lane-status") {
setLanes(msg.lanes);
} else if (msg.kind === "ledger") { } else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy); setOccupancy(msg.occupancy);
pushEvent(msg.event); pushEvent(msg.event);
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useScanner } from "./use-scanner.js";
// The global hardware-scanner hook: a fast keystroke burst ended by Enter fires onScan,
// regardless of focus, WITHOUT hijacking human typing or editable fields, and pauses
// while a modal is open. This pins the 2026-06-21 focus-independent scan behaviour
// (otherwise only verifiable in Playwright).
afterEach(() => vi.restoreAllMocks());
/** Dispatch a keydown on the document with a controllable timeStamp (the hook measures
* inter-key gaps off e.timeStamp). jsdom sets timeStamp to 0, so we override it. */
function key(char: string, timeStamp: number, target: EventTarget = document.body) {
const e = new KeyboardEvent("keydown", { key: char, bubbles: true, cancelable: true });
Object.defineProperty(e, "timeStamp", { value: timeStamp });
Object.defineProperty(e, "target", { value: target });
document.dispatchEvent(e);
}
/** Type a code as a fast burst (5ms apart) ending in Enter, from a start time. */
function scan(code: string, start = 1000, gap = 5) {
let t = start;
for (const ch of code) { key(ch, t); t += gap; }
key("Enter", t);
return t;
}
describe("useScanner", () => {
it("fires onScan with the code on a fast burst + Enter (focus on body)", () => {
const onScan = vi.fn();
renderHook(() => useScanner({ onScan }));
scan("12345678901");
expect(onScan).toHaveBeenCalledTimes(1);
expect(onScan).toHaveBeenCalledWith("12345678901");
});
it("ignores slow, human-paced typing (gap > 50ms resets the buffer)", () => {
const onScan = vi.fn();
renderHook(() => useScanner({ onScan }));
// 120ms between keys — a person, not a scanner. Each gap resets the buffer, so by
// Enter only the last char remains (< MIN_LENGTH) → no scan.
scan("123", 1000, 120);
expect(onScan).not.toHaveBeenCalled();
});
it("does not fire while paused (a modal is open)", () => {
const onScan = vi.fn();
renderHook(() => useScanner({ onScan, paused: true }));
scan("12345678901");
expect(onScan).not.toHaveBeenCalled();
});
it("ignores keystrokes into an editable field (manual typing unaffected)", () => {
const onScan = vi.fn();
renderHook(() => useScanner({ onScan }));
const input = document.createElement("input");
document.body.appendChild(input);
scanInto("12345678901", input);
expect(onScan).not.toHaveBeenCalled();
input.remove();
});
it("ignores a lone Enter / too-short burst", () => {
const onScan = vi.fn();
renderHook(() => useScanner({ onScan }));
key("Enter", 1000);
expect(onScan).not.toHaveBeenCalled();
scan("ab"); // length 2 < MIN_LENGTH 3
expect(onScan).not.toHaveBeenCalled();
});
});
/** Burst with the event target set to an editable element. */
function scanInto(code: string, target: EventTarget, start = 1000, gap = 5) {
let t = start;
for (const ch of code) { key(ch, t, target); t += gap; }
key("Enter", t, target);
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useRef } from "react";
// Global hardware-scanner capture (HID "keyboard wedge"). A barcode/QR scanner types
// the code as a fast keystroke burst followed by Enter — like a keyboard, but far
// faster than a human. This hook listens at the DOCUMENT level so a scan fires the
// callback no matter what's focused (or if nothing is), unlike a single <input> that
// only catches scans while it holds focus. See wiki/concepts/booth-console.md.
//
// It does NOT hijack manual typing: keystrokes into an <input>/<textarea>/editable
// element are left to that field (the booth's ticket input still works by hand). The
// burst heuristic — chars arriving faster than a human could type, ended by Enter —
// is what distinguishes a scan from a person pressing keys with nothing focused.
/** Tuning. A scanner emits keystrokes ~1–20ms apart; a human is ≥80–100ms. */
const MAX_INTERKEY_MS = 50; // a gap longer than this resets the buffer (not one scan)
const MIN_LENGTH = 3; // ignore stray single Enter presses / very short bursts
interface ScannerOptions {
/** Called with the scanned code (trimmed) when a burst completes with Enter. */
onScan: (code: string) => void;
/** When true, scans are ignored (e.g. a modal is already open — don't interrupt an
* in-progress payment). The listener stays attached; it just no-ops. */
paused?: boolean;
}
/** Capture hardware-scanner input globally. The callback fires on the Enter that ends a
* fast keystroke burst, regardless of focus. Editable-field keystrokes are ignored so
* manual typing is unaffected. */
export function useScanner({ onScan, paused = false }: ScannerOptions): void {
// Keep the latest callback + paused flag in refs so the effect's listener never goes
// stale and we don't re-attach on every render.
const onScanRef = useRef(onScan);
const pausedRef = useRef(paused);
onScanRef.current = onScan;
pausedRef.current = paused;
useEffect(() => {
let buffer = "";
let lastTime = 0;
function isEditableTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
}
function onKeyDown(e: KeyboardEvent) {
// Let the focused field (and its form) handle its own keystrokes — the manual
// ticket input submits via its form's onSubmit; we only cover the un-focused case.
if (isEditableTarget(e.target)) return;
const now = e.timeStamp || performance.now();
const gap = now - lastTime;
lastTime = now;
if (e.key === "Enter") {
const code = buffer.trim();
buffer = "";
// Only a fast-burst code of reasonable length counts as a scan; a lone Enter or
// a slowly-assembled string (a person mashing keys) is ignored.
if (code.length >= MIN_LENGTH && !pausedRef.current) {
e.preventDefault();
onScanRef.current(code);
}
return;
}
// A gap too long means a new (human-paced) sequence — start the buffer over.
if (gap > MAX_INTERKEY_MS) buffer = "";
// Accumulate printable single characters (scanner codes: digits + SUB-/SUBSESS-…).
if (e.key.length === 1) buffer += e.key;
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
}
+136 -42
View File
@@ -6,7 +6,7 @@ import {
Outlet, Outlet,
redirect, redirect,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { useState } from "react"; import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser, Theme } from "./api.js"; import type { Lang, Permission, SessionUser, Theme } from "./api.js";
@@ -30,6 +30,11 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js"; import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js"; import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.js"; import { LogsViewer } from "./LogsViewer.js";
import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
// initial bundle and only downloads when an admin opens /setup/reports.
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
// Code-based TanStack Router (no file-based codegen — the app is small enough that // Code-based TanStack Router (no file-based codegen — the app is small enough that
// an explicit tree is clearer). The router context carries the signed-in user and // an explicit tree is clearer). The router context carries the signed-in user and
@@ -41,7 +46,9 @@ export interface RouterContext {
setUser: (u: SessionUser | null) => void; setUser: (u: SessionUser | null) => void;
} }
const rootRoute = createRootRouteWithContext<RouterContext>()({ // Exported so a deep component (e.g. the booth pay modal) can read the signed-in user
// from route context without prop-threading through every layer.
export const rootRoute = createRootRouteWithContext<RouterContext>()({
component: RootLayout, component: RootLayout,
}); });
@@ -82,13 +89,10 @@ function SetupLayout() {
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border"> <nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />} {show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />} {show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
{show("tariff:read") && <SetupTab to="/setup/tariff-lab" label={t("nav.tariffLab")} />}
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
{show("subscription:plan") && <SetupTab to="/setup/plans" label={t("nav.plans")} />}
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />} {show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />} {show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />} {show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
{show("shift:read") && <SetupTab to="/setup/shifts" label={t("nav.shifts")} />} {show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />} {show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
</nav> </nav>
<Outlet /> <Outlet />
@@ -96,6 +100,26 @@ function SetupLayout() {
); );
} }
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
* with tabs for the subscriber catalog, the plan catalog, and the tariff lab. Each
* tab is a gated child route; an operator with only subscription:read sees just the
* first tab. */
function SubscriptionsLayout() {
const { user } = rootRoute.useRouteContext();
const { t } = useTranslation();
const show = (perm: Permission) => can(user, perm);
return (
<div className="">
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
{show("tariff:read") && <SetupTab to="/subscriptions/tariff-lab" label={t("nav.tariffLab")} />}
</nav>
<Outlet />
</div>
);
}
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login) /** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
* and applies it immediately. Updates the router-context user so App re-syncs. */ * and applies it immediately. Updates the router-context user so App re-syncs. */
function LanguageToggle({ function LanguageToggle({
@@ -352,16 +376,23 @@ function RootLayout() {
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span> <span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
<nav className="flex items-center gap-1"> <nav className="flex items-center gap-1">
<NavLink to="/booth" label={t("nav.booth")} /> <NavLink to="/booth" label={t("nav.booth")} />
<NavLink to="/shift" label={t("nav.shift")} /> <NavLink to="/shifts" label={t("nav.shifts")} />
{/* One Setup entry — its tabs hold devices/tariff/subscriptions/site/users/ {/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
roles/shifts. Shown if the user can reach ANY of those screens (an Shown if the user can reach ANY of its tabs. */}
operator with only shift:read still gets in, landing on Shifts). */} {(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
<NavLink to="/subscriptions" label={t("nav.subscriptions")} />
)}
{/* Reports — a standalone admin section (own header entry, route /reports). */}
{show("report:read") && <NavLink to="/reports" label={t("nav.reports")} />}
{/* One Setup entry — its tabs hold devices/tariff/site/users/roles/logs.
Shown if the user can reach ANY of those screens (an operator with only
shift:read still gets in, landing on Shifts). */}
{(show("site:update") || {(show("site:update") ||
show("tariff:read") || show("tariff:read") ||
show("subscription:read") ||
show("site:read") || show("site:read") ||
show("user:read") || show("user:read") ||
show("role:read") || show("role:read") ||
show("recyclebin:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />} show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
@@ -369,9 +400,15 @@ function RootLayout() {
{user && <LanguageToggle user={user} setUser={setUser} />} {user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />} {user && <ThemeToggle user={user} setUser={setUser} />}
<StatusDot /> <StatusDot />
<span className="text-[11px] text-term-muted"> {user && (
{user?.username} · {user?.roleName} <Link
</span> to="/profile"
title={t("nav.profile")}
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber"
>
{user.username} · {user.roleName}
</Link>
)}
<button <button
type="button" type="button"
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
@@ -407,15 +444,22 @@ const boothRoute = createRoute({
component: BoothScreen, component: BoothScreen,
}); });
// Back-compat: the config screens used to be top-level routes. They now live under // Back-compat redirects for paths that moved. Most config screens live under /setup;
// /setup as tabs — redirect the old paths so existing bookmarks/links don't 404. // Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
// bookmarks/links don't 404. (No "/subscriptions" entry: that's now a REAL route.)
const legacyRedirects = ( const legacyRedirects = (
[ [
["/tariff", "/setup/tariff"], ["/tariff", "/setup/tariff"],
["/subscriptions", "/setup/subscriptions"],
["/site", "/setup/site"], ["/site", "/setup/site"],
["/users", "/setup/users"], ["/users", "/setup/users"],
["/roles", "/setup/roles"], ["/roles", "/setup/roles"],
["/shift", "/shifts"],
["/setup/subscriptions", "/subscriptions"],
["/setup/plans", "/subscriptions/plans"],
["/setup/tariff-lab", "/subscriptions/tariff-lab"],
["/setup/shifts", "/shifts"],
["/setup/reports", "/reports"],
] as const ] as const
).map(([from, to]) => ).map(([from, to]) =>
createRoute({ createRoute({
@@ -427,9 +471,25 @@ const legacyRedirects = (
}), }),
); );
// Admin reports/charts — a top-level section (own header nav entry), NOT a Setup tab.
// Gated by report:read. Lazy component (Recharts) in a Suspense so it stays out of the
// booth's initial bundle.
const reportsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/reports",
beforeLoad: ({ context }) => requirePerm("report:read")(context),
component: function ReportsRoute() {
return (
<Suspense fallback={<div className="p-3 text-term-muted">…</div>}>
<Reports />
</Suspense>
);
},
});
const shiftRoute = createRoute({ const shiftRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/shift", path: "/shifts",
component: function ShiftRoute() { component: function ShiftRoute() {
const { user } = rootRoute.useRouteContext(); const { user } = rootRoute.useRouteContext();
// The shift hub: list (current/open shift on top + history) + per-shift activity log. // The shift hub: list (current/open shift on top + history) + per-shift activity log.
@@ -457,15 +517,16 @@ function requirePerm(perm: Permission) {
// The Setup tabs in display order, each with the permission its screen needs. Used // The Setup tabs in display order, each with the permission its screen needs. Used
// to land a user on the FIRST tab they may see when they open /setup without // to land a user on the FIRST tab they may see when they open /setup without
// `site:update` (e.g. an operator who only has shift:read → goes to /setup/shifts). // `site:update` (e.g. an operator who only has shift:read → goes to the standalone
// /shifts hub, which is no longer a Setup tab).
const SETUP_TABS: { to: string; perm: Permission }[] = [ const SETUP_TABS: { to: string; perm: Permission }[] = [
{ to: "/setup", perm: "site:update" }, { to: "/setup", perm: "site:update" },
{ to: "/setup/tariff", perm: "tariff:read" }, { to: "/setup/tariff", perm: "tariff:read" },
{ to: "/setup/subscriptions", perm: "subscription:read" },
{ to: "/setup/site", perm: "site:read" }, { to: "/setup/site", perm: "site:read" },
{ to: "/setup/users", perm: "user:read" }, { to: "/setup/users", perm: "user:read" },
{ to: "/setup/roles", perm: "role:read" }, { to: "/setup/roles", perm: "role:read" },
{ to: "/setup/shifts", perm: "shift:read" }, { to: "/setup/recycle-bin", perm: "recyclebin:read" },
{ to: "/shifts", perm: "shift:read" },
{ to: "/setup/logs", perm: "log:read" }, { to: "/setup/logs", perm: "log:read" },
]; ];
@@ -496,27 +557,42 @@ const tariffRoute = createRoute({
beforeLoad: ({ context }) => requirePerm("tariff:read")(context), beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffComposer />, component: () => <TariffComposer />,
}); });
const tariffLabRoute = createRoute({
getParentRoute: () => setupRoute, // --- /subscriptions — a standalone top-level section with its own tabs. The catalog
path: "tariff-lab", // (index), the plan catalog, and the tariff lab live here, not under /setup. ---
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffLab />,
});
const subscriptionsRoute = createRoute({ const subscriptionsRoute = createRoute({
getParentRoute: () => setupRoute, getParentRoute: () => rootRoute,
path: "subscriptions", path: "/subscriptions",
beforeLoad: ({ context }) => requirePerm("subscription:read")(context), component: SubscriptionsLayout,
});
// Index tab = the subscriber catalog at /subscriptions exactly. A user lacking
// subscription:read is redirected to the first sub-tab they CAN see (or the booth).
const subscriptionsIndexRoute = createRoute({
getParentRoute: () => subscriptionsRoute,
path: "/",
beforeLoad: ({ context }) => {
if (can(context.user, "subscription:read")) return;
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
if (can(context.user, "tariff:read")) throw redirect({ to: "/subscriptions/tariff-lab" });
throw redirect({ to: "/booth" });
},
component: function SubscriptionsRoute() { component: function SubscriptionsRoute() {
const { user } = rootRoute.useRouteContext(); const { user } = rootRoute.useRouteContext();
return <SubscriptionManager user={user} />; return <SubscriptionManager user={user} />;
}, },
}); });
const subscriptionPlansRoute = createRoute({ const subscriptionPlansRoute = createRoute({
getParentRoute: () => setupRoute, getParentRoute: () => subscriptionsRoute,
path: "plans", path: "plans",
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context), beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
component: () => <SubscriptionPlansManager />, component: () => <SubscriptionPlansManager />,
}); });
const tariffLabRoute = createRoute({
getParentRoute: () => subscriptionsRoute,
path: "tariff-lab",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffLab />,
});
const siteRoute = createRoute({ const siteRoute = createRoute({
getParentRoute: () => setupRoute, getParentRoute: () => setupRoute,
path: "site", path: "site",
@@ -544,15 +620,17 @@ const rolesRoute = createRoute({
return <RolesManager user={user} />; return <RolesManager user={user} />;
}, },
}); });
// Shift history. Gated by shift:read (operators have it) — the SERVER scopes the // (Shift history lives at the standalone /shifts route — see shiftRoute. It was
// data: operators see only their own; shift:cash holders see all + can filter. // removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
const shiftsHistoryRoute = createRoute({
// Recycle bin — restore/purge soft-deleted master data. Gated by recyclebin:read.
const recycleBinRoute = createRoute({
getParentRoute: () => setupRoute, getParentRoute: () => setupRoute,
path: "shifts", path: "recycle-bin",
beforeLoad: ({ context }) => requirePerm("shift:read")(context), beforeLoad: ({ context }) => requirePerm("recyclebin:read")(context),
component: function ShiftsHistoryRoute() { component: function RecycleBinRoute() {
const { user } = rootRoute.useRouteContext(); const { user } = rootRoute.useRouteContext();
return <ShiftsHistory user={user} />; return <RecycleBin user={user} />;
}, },
}); });
@@ -564,21 +642,37 @@ const logsRoute = createRoute({
component: LogsViewer, component: LogsViewer,
}); });
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
const profileRoute = createRoute({
getParentRoute: () => rootRoute,
path: "profile",
component: function ProfileRoute() {
const { user, setUser } = rootRoute.useRouteContext();
if (!user) return null;
return <Profile user={user} setUser={setUser} />;
},
});
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
boothRoute, boothRoute,
...legacyRedirects, ...legacyRedirects,
profileRoute,
shiftRoute, shiftRoute,
reportsRoute,
subscriptionsRoute.addChildren([
subscriptionsIndexRoute,
subscriptionPlansRoute,
tariffLabRoute,
]),
setupRoute.addChildren([ setupRoute.addChildren([
setupDevicesRoute, setupDevicesRoute,
tariffRoute, tariffRoute,
tariffLabRoute,
subscriptionsRoute,
subscriptionPlansRoute,
siteRoute, siteRoute,
usersRoute, usersRoute,
rolesRoute, rolesRoute,
shiftsHistoryRoute, recycleBinRoute,
logsRoute, logsRoute,
]), ]),
]); ]);
+306
View File
@@ -0,0 +1,306 @@
import { useTranslation } from "react-i18next";
import { type ReactNode } from "react";
import { type LedgerEvent } from "../api.js";
import { formatMoney } from "../lib/format.js";
import { renderReason } from "../lib/reason.js";
import { Modal } from "./Modal.js";
import { SnapshotStrip } from "./SnapshotStrip.js";
// Shared ledger-event presentation: the colour/label map, the clickable feed ROW, and
// the read-only DETAIL modal (full signed payload + snapshots + chain provenance). Used
// by the booth live feed AND the shift activity log so both render — and open — events
// identically. See wiki/concepts/append-only-event-chain.md.
export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
/**
* A refused-ACTION event is a benign WARNING, not a red-flag anomaly. The ledger type is
* `anomaly` for both (immutable history), but a refused exit / refused subscription /
* refused entry (e.g. a double card-scan, an at-capacity subscriber, an already-closed
* session) is an EXPECTED outcome — not fraud. We classify it from the payload flags the
* flows already sign (`exitRefused` / `entryRefused` / `permitRefused`) and show it as an
* amber "REFUZUAR / REFUSED" warning, reserving red "ANOMALI" for genuine anomalies
* (barrier-open failure, opened-without-ticket, …). Display-only — no ledger change.
*/
export function isRefusedWarning(e: LedgerEvent): boolean {
if (e.type !== "anomaly") return false;
const p = e.payload;
return !!(p && (p.exitRefused || p.entryRefused || p.permitRefused));
}
/** The label key + colour to render for an event, applying the refused-warning split. */
export function eventStyleFor(e: LedgerEvent): { labelKey: string; color: string } {
if (isRefusedWarning(e)) return { labelKey: "booth.evtRefused", color: "text-term-amber" };
return EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
}
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
function hhmmss(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
}
/** Red-flag classification badges computed from the signed payload. */
export function eventBadges(p: LedgerEvent["payload"]): string[] {
if (!p) return [];
const keys: string[] = [];
if (p.entryRefused) keys.push("booth.badgeEntryRefused");
if (p.exitRefused) keys.push("booth.badgeExitRefused");
if (p.full) keys.push("booth.badgeLotFull");
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
if (p.permitRefused) keys.push("booth.badgeSubRefused");
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
// Subscriber entered outside their plan's allowed window → will owe a transient charge
// for the minutes actually parked out-of-window, priced + collected (gated) at exit.
// (`windowOwedMinor` is the old fixed-amount stamp, kept so historic events still badge.)
if (p.outOfWindow === true || (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0))
keys.push("booth.badgeWindowCharge");
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
return keys;
}
/** The i18n key for a subscriber's access medium (`via`), or null. */
export function viaKey(p: LedgerEvent["payload"]): string | null {
if (!p) return null;
if (p.via === "qr") return "booth.viaQr";
if (p.via === "card") return "booth.viaCard";
if (p.via === "plate") return "booth.viaPlate";
return null;
}
/** A short money summary for payment events (e.g. "350.00 ALL"). */
export function paymentSummary(p: LedgerEvent["payload"]): string | null {
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
return formatMoney(p.amountMinor, p.currency);
}
/** What to SHOW for an event's actor. A subscription occurrence has an opaque
* `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`,
* so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */
export function displayIdentity(e: LedgerEvent): string {
return e.subscriberLabel ?? e.identity ?? "—";
}
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps
* the time/label/identity/index columns aligned across rows; the detail line lives in
* its own row, indented under the identity column. */
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
const { t } = useTranslation();
const style = eventStyleFor(e);
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
// A refused-action event is a benign WARNING (amber), distinct from a genuine red
// anomaly. Only true anomalies get the red row tint + the "no reason" fallback.
const refusedWarning = isRefusedWarning(e);
const isAnomaly = e.type === "anomaly" && !refusedWarning;
const p = e.payload;
const reason = renderReason(p, t);
const amount = paymentSummary(p);
const badges = eventBadges(p);
const via = viaKey(p);
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
const showDetail = detail != null || badges.length > 0 || via != null;
return (
<button
type="button"
onClick={() => onOpen(e)}
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
}`}
>
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-term-text">{displayIdentity(e)}</span>
{e.plate && (
<span
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
title={t("booth.plateTitle")}
>
{e.plate}
</span>
)}
</span>
<span className="text-term-muted">#{e.index}</span>
{showDetail && (
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
{badges.map((k) => (
<span
key={k}
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
>
{t(k)}
</span>
))}
{via && (
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
{t(via)}
</span>
)}
{detail && (
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
)}
</div>
)}
</button>
);
}
/** One label/value line in the event-detail modal. */
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
<span className="min-w-0 break-words text-term-text">{children}</span>
</div>
);
}
/** Full read-only detail for one ledger event: business fields + the human-readable
* reason + the session's entry/exit snapshots, then the signed-chain provenance
* (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable;
* this only DISPLAYS the signed record. */
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
const { t } = useTranslation();
const style = eventStyleFor(e);
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
const p = e.payload;
const reason = renderReason(p, t);
const badges = eventBadges(p);
const isAnomaly = e.type === "anomaly" && !isRefusedWarning(e);
// Pretty money for any minor-unit amount in the payload.
const money =
p && typeof p.amountMinor === "number" && typeof p.currency === "string"
? formatMoney(p.amountMinor, p.currency)
: null;
// Pull out the business fields worth a labelled row. Everything else (and the raw
// bytes) lives behind the audit disclosure — the operator sees a clean summary.
const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null;
const plate = typeof p?.plate === "string" ? p.plate : null;
const category = typeof p?.category === "string" ? p.category : null;
const operator = typeof p?.operator === "string" ? p.operator : null;
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
return (
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
<div className="flex flex-col gap-3">
{/* Headline: the type + localized reason, prominent for anomalies. */}
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
{(reason || money) && (
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
{reason ?? money}
</div>
)}
{!reason && !money && isAnomaly && (
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
)}
{badges.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{badges.map((k) => (
<span
key={k}
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
>
{t(k)}
</span>
))}
</div>
)}
</div>
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
<div>
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
{/* When we showed a subscriber NAME above, also expose the raw occurrence id
(the SUBSESS-… session key) for traceability against the ledger. */}
{e.subscriberLabel && e.identity && (
<DetailRow label={t("booth.edOccurrence")}>
<code className="text-[11px] text-term-muted">{e.identity}</code>
</DetailRow>
)}
{money && (
<DetailRow label={t("booth.edAmount")}>
<span className="text-term-cyan">{money}</span>
</DetailRow>
)}
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
{viaKey(p) && (
<DetailRow label={t("booth.edVia")}>
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
</DetailRow>
)}
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
{sessionRef && sessionRef !== e.identity && (
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
)}
{tariffVersionId && (
<DetailRow label={t("booth.edTariffVersion")}>
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
</DetailRow>
)}
</div>
{/* The entry/exit evidence images for this session's identity. */}
{e.identity && (
<div>
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
<SnapshotStrip identity={e.identity} />
</div>
)}
{/* Audit data — collapsed by default. The signed-chain provenance (signature,
key, prev-hash) and the raw payload are an auditor's concern, not the
operator's; tucking them behind a disclosure keeps the common view clean
while preserving the tamper-evidence trail on demand. */}
<details className="rounded-term border border-term-border bg-term-panel-2">
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
{t("booth.edAuditData")}
</summary>
<div className="border-t border-term-border px-3 pb-3 pt-1">
<DetailRow label={t("booth.edSignature")}>
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
</DetailRow>
<DetailRow label={t("booth.edKeyId")}>
<code className="text-[11px] text-term-muted">{e.keyId}</code>
</DetailRow>
<DetailRow label={t("booth.edPrevHash")}>
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
</DetailRow>
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
{t("booth.edRawPayload")}
</div>
{p && Object.keys(p).length > 0 ? (
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
{JSON.stringify(p, null, 2)}
</pre>
) : (
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
)}
</div>
</details>
</div>
</Modal>
);
}
+6
View File
@@ -9,6 +9,12 @@ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: { server: {
port: 5173, port: 5173,
// Bind all interfaces so the dev SPA is reachable from other LAN devices
// (phone over wifi, etc.) at http://<host-lan-ip>:5173 — not just localhost.
// NB: loading from a non-localhost origin means the booth WebSocket (/api/ws)
// sends Origin: http://<host-lan-ip>:5173, which the backend's WS_ALLOWED_ORIGINS
// must include or the live feed is rejected. See apps/server/.env(.example).
host: "0.0.0.0",
proxy: { proxy: {
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1 // Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
// first and stall — the backend binds IPv4. Avoids slow/hung requests, // first and stall — the backend binds IPv4. Avoids slow/hung requests,
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
// Web unit tests: pure formatters (no DOM) + the global hardware-scanner hook (needs a
// document, so jsdom). Kept minimal — the booth/live-feed/modal flows are still verified
// manually (Playwright); this pins the testable pure logic + the focus-independent scan.
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"],
},
});
+29
View File
@@ -0,0 +1,29 @@
# DEV override: build the images locally from the Dockerfiles, expose both ports, run the
# stub recognizer (no model load), and verbose logging. Use with the base file:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
services:
server:
build:
context: .
dockerfile: apps/server/Dockerfile
environment:
LOG_LEVEL: debug
# Dev convenience: seed an admin on first boot (set ADMIN_PASS to enable).
SEED_ADMIN: ${SEED_ADMIN:-0}
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASS: ${ADMIN_PASS:-}
# 32+ chars and must NOT contain dev-only/insecure/change-me (auth.ts rejects those).
# This is a fixed LOCAL-DEV value only; prod injects a real `openssl rand -hex 32`.
JWT_SECRET: ${JWT_SECRET:-localdevsecret0123456789abcdef0123}
ports:
- "3000:3000"
vision:
build:
context: apps/vision
dockerfile: Dockerfile
environment:
VISION_RECOGNIZER: stub
ports:
- "8089:8089"
+59
View File
@@ -0,0 +1,59 @@
# PROD override: pull pinned registry images (no local build), restart always, real
# recognizer, and a CADDY reverse proxy in front so operators reach the booth on a clean
# port-80 URL (no :3000) — and a path to real TLS later. Server + vision stay INTERNAL
# (only Caddy publishes a port). Use with the base file and pin TAG to the branch you deploy:
# REGISTRY=git.infra.msai.al/mca/parking_solution TAG=main \
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# See wiki/decisions/container-deployment.md.
services:
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively).
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the
# site's real hostname later enables automatic HTTPS. The booth is reached at
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image).
proxy:
image: caddy:2-alpine
restart: always
ports:
- "80:80"
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- server
networks:
- parking
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
server:
restart: always
# No published port — only the proxy reaches the server, over the private network.
expose:
- "3000"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
vision:
restart: always
# The real ANPR engine. The image baked the model weights at build (offline-first).
environment:
VISION_RECOGNIZER: fast_alpr
# No published ports — vision is reached only by the server over the private network.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
caddy-data:
caddy-config:
+53
View File
@@ -0,0 +1,53 @@
# Base stack: the parking SERVER (API + SPA) + the VISION (ANPR) service. Branch-aware via
# ${REGISTRY}/${TAG} — a deploy on `dev` pulls :dev, on `main` pulls :main. Use an env
# override file for the environment: docker-compose.dev.yml (build locally, stub recognizer)
# or docker-compose.prod.yml (pull pinned images, fast_alpr). See
# wiki/decisions/container-deployment.md.
#
# local dev : docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
# prod : REGISTRY=… TAG=main docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
services:
server:
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-server:${TAG:-dev}
restart: unless-stopped
environment:
DATABASE_URL: /data/parking.sqlite
# Reach the vision service over the private compose network by service name.
VISION_URL: http://vision:8089
VISION_ENABLED: ${VISION_ENABLED:-1}
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
# Dedicated ledger-signing key. Falls back to JWT_SECRET (with a warning) if empty;
# set a distinct one in prod. See apps/server/.env.example + local-jwt-auth.
EVENT_SIGNING_KEY: ${EVENT_SIGNING_KEY:-}
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
COOKIE_SECURE: ${COOKIE_SECURE:-0}
# The booth WS live feed checks the browser Origin — must list the address operators
# actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected.
WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-}
volumes:
- parking-data:/data
depends_on:
vision:
condition: service_started
networks:
- parking
vision:
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-vision:${TAG:-dev}
restart: unless-stopped
environment:
# Engine: stub (no models) by default; prod override sets fast_alpr.
VISION_RECOGNIZER: ${VISION_RECOGNIZER:-stub}
networks:
- parking
volumes:
parking-data:
networks:
parking:
driver: bridge
+19
View File
@@ -0,0 +1,19 @@
-- Soft delete (recycle bin) for accidental hard-deletes of master data. Adds a nullable
-- `deleted_at` (ISO-8601; null = live) + `deleted_by` (the admin user id) to the mutable
-- master-data tables. A DELETE now stamps these instead of removing the row; restore
-- clears them; an admin purge (or the retention sweep) does the real DELETE. The signed
-- append-only ledger is NOT touched — it has no delete path and is out of scope here.
--
-- All additive ALTER ADD COLUMN — backward-compatible (existing rows: deleted_at null =
-- live). SQLite ADD COLUMN is in-place. Subscription PLANS are versioned (many rows per
-- plan_id); a soft-delete stamps every version row of that plan_id together.
ALTER TABLE `users` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `users` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `roles` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `roles` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `subscription_plans` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `subscription_plans` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `tariffs` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `tariffs` ADD `deleted_by` text;
@@ -0,0 +1,5 @@
-- Site master switch for the ANPR subscriber-entry bridge (anpr-entry.ts). Additive
-- ALTER ADD COLUMN — backward-compatible. Default 1 (ON) so existing installs keep the
-- now-live auto-open-for-subscriber-plates behaviour after upgrade. The toggle gates ONLY
-- the barrier-driving bridge; advisory snapshot-ANPR + lane busy/free are unaffected.
ALTER TABLE `site_config` ADD `anpr_entry_enabled` integer DEFAULT 1 NOT NULL;
+14
View File
@@ -85,6 +85,20 @@
"when": 1781885400000, "when": 1781885400000,
"tag": "0011_subscription_plan_v2", "tag": "0011_subscription_plan_v2",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1781885500000,
"tag": "0012_soft_delete",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1781885600000,
"tag": "0013_anpr_entry_toggle",
"breakpoints": true
} }
] ]
} }
+6 -1
View File
@@ -11,6 +11,10 @@
"./schema": { "./schema": {
"types": "./dist/schema.d.ts", "types": "./dist/schema.d.ts",
"default": "./dist/schema.js" "default": "./dist/schema.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
} }
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -21,7 +25,8 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate" "db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate",
"db:migrate:runtime": "node scripts/migrate-runtime.mjs"
}, },
"dependencies": { "dependencies": {
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
// Apply Drizzle migrations against the DATABASE_URL sqlite file using the runtime
// migrator (drizzle-orm/better-sqlite3/migrator) — NOT drizzle-kit. This lets the
// container run migrations on boot with only runtime deps installed (drizzle-kit is a
// devDep, pruned out of the production image). Same migration set + folder the test
// helper uses (packages/db/src/testing.ts), so the schema matches production exactly.
//
// Usage: DATABASE_URL=/data/parking.sqlite node scripts/migrate-runtime.mjs
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { mkdirSync } from "node:fs";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
const url = process.env.DATABASE_URL;
if (!url) {
console.error("[migrate] DATABASE_URL is required");
process.exit(1);
}
// Migrations folder ships beside this package (packages/db/drizzle); from scripts/ that's ../drizzle.
const migrationsFolder = resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle");
// Ensure the DB's parent dir exists (a fresh mounted volume may be empty).
try {
mkdirSync(dirname(resolve(url)), { recursive: true });
} catch {
/* dir already exists (or url has no dir) — fine */
}
const sqlite = new Database(url);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
const db = drizzle(sqlite);
console.log(`[migrate] applying migrations from ${migrationsFolder} → ${url}`);
migrate(db, { migrationsFolder });
sqlite.close();
console.log("[migrate] done");
+1 -1
View File
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
export * from "./schema.js"; export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on // Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package). // drizzle-orm directly (it's an implementation detail of this package).
export { eq, and, desc, gte, lte, sql } from "drizzle-orm"; export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
/** /**
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers * Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+36
View File
@@ -29,6 +29,11 @@ export const roles = sqliteTable("roles", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
// Soft delete (recycle bin): ISO instant the row was deleted, null = live; the admin
// user id who deleted it. A DELETE stamps these; restore clears them; purge/retention
// does the real row removal. See wiki/concepts/soft-delete.md.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
}); });
/** The role→permission grid. One row per granted `resource:action` permission. /** The role→permission grid. One row per granted `resource:action` permission.
@@ -78,6 +83,11 @@ export const users = sqliteTable("users", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. NB: `username` stays UNIQUE across
// live AND deleted rows, so creating a new user reusing a deleted user's name is
// blocked until that row is restored or purged (the route returns a clear 409).
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
}); });
// --- The signed business ledger (formerly `events`) ---------------------- // --- The signed business ledger (formerly `events`) ----------------------
@@ -226,6 +236,16 @@ export const siteConfig = sqliteTable("site_config", {
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" }) reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
/** Site master switch for the ANPR subscriber-entry BRIDGE (anpr-entry.ts): when ON
* (default), a subscriber's plate read off a lane camera's vehicle detection opens the
* barrier through the normal gated subscription flow. When OFF, the bridge emits no read
* (subscribers fall back to their card/QR). This gates ONLY the barrier-driving bridge —
* advisory snapshot-ANPR recording and lane busy/free are unaffected. Read LIVE per event
* so toggling takes effect with no restart. Default ON because the feature is already
* live. Stored 0/1. See wiki/concepts/lane-presence-and-anpr-entry.md. */
anprEntryEnabled: integer("anpr_entry_enabled", { mode: "boolean" })
.notNull()
.default(true),
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a /** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into * tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
* each published tariff version's structure.tz so the windows are frozen/immutable * each published tariff version's structure.tz so the windows are frozen/immutable
@@ -258,6 +278,10 @@ export const tariffs = sqliteTable("tariffs", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. Stamps the rate-card row; its
// immutable tariff_versions are kept (referenced for repricing) and ride along.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
}); });
export const tariffVersions = sqliteTable("tariff_versions", { export const tariffVersions = sqliteTable("tariff_versions", {
@@ -314,6 +338,12 @@ export const subscriptionPlans = sqliteTable("subscription_plans", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. A plan is VERSIONED (many rows per
// plan_id); a soft-delete stamps every version row of the plan_id together, and the bin
// shows/restores the plan as one item. Distinct from `active=0` (retire = unsellable
// but kept in the catalog); deletedAt removes it from the catalog entirely.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
}); });
export const subscriptions = sqliteTable("subscriptions", { export const subscriptions = sqliteTable("subscriptions", {
@@ -348,6 +378,12 @@ export const subscriptions = sqliteTable("subscriptions", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. Distinct from `status: "revoked"`
// (a domain state that BARS the subscriber but keeps it visible); deletedAt removes it
// from the catalog entirely, recoverable from the bin. Child credential/plate rows are
// kept and restored with it.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
}); });
// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier. // A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
+32
View File
@@ -0,0 +1,32 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import * as schema from "./schema.js";
import type { Db } from "./index.js";
// Test-only helper: a fresh, fully-migrated SQLite database with NO live-DB risk.
// Every server/integration test spins one of these so suites are isolated and
// deterministic — never the real parking.sqlite. Not exported from the package
// root (`@parking/db`); import it from `@parking/db/testing` in test code only.
// The migrations live next to this package's compiled output. From dist/testing.js
// that's ../drizzle; resolve it off import.meta.url so it works regardless of the
// caller's cwd (tests run from apps/server, packages/devices, etc.).
const MIGRATIONS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle");
/**
* Open an in-memory SQLite (or a temp file if `url` is given), apply every Drizzle
* migration in order, and return a typed Drizzle handle plus the raw better-sqlite3
* connection (so a test can assert raw rows or `.close()` it). The schema matches
* production exactly because it's the SAME migration set, not a hand-rolled DDL.
*/
export function createTestDb(url = ":memory:"): { db: Db; sqlite: Database.Database; close: () => void } {
const sqlite = new Database(url);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
const db = drizzle(sqlite, { schema }) as Db;
migrate(db, { migrationsFolder: MIGRATIONS_DIR });
return { db, sqlite, close: () => sqlite.close() };
}
+4 -2
View File
@@ -15,13 +15,15 @@
"build": "tsc -b", "build": "tsc -b",
"dev": "tsc -b --watch", "dev": "tsc -b --watch",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@parking/shared": "workspace:*" "@parking/shared": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "25.9.3", "@types/node": "25.9.3",
"typescript": "6.0.3" "typescript": "6.0.3",
"vitest": "^4.1.9"
} }
} }
+44 -2
View File
@@ -95,13 +95,55 @@ const channelField: ConfigField = {
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField]; const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
// Alarm Server) HTTP-POSTs an EventNotificationAlert to a URL we host every time the
// chosen target is detected — same shape as the Dingtian Input Link push. When enabled,
// the admin points the camera's Alarm Server at /api/devices/hikvision/:deviceId/event
// and we record what it sends. See routes/hikvision-alarm.ts, wiki/entities/lpr-camera.md.
const alarmPushFields: ConfigField[] = [
{
key: "alarmPushEnabled",
label: "Alarm Server push (Event → vehicle)",
type: "boolean",
required: false,
default: false,
help: "The camera POSTs each detected event to us (set its Alarm Settings → Alarm Server to this backend). No polling.",
},
{
key: "pushUser",
label: "Alarm push username (optional)",
type: "string",
required: false,
help: "Only if the camera's Alarm Server is set to authenticate (HTTP Digest). Leave blank to accept by source-IP only.",
},
{
key: "pushPassword",
label: "Alarm push password (optional)",
type: "secret",
required: false,
help: "Paired with the username above for Digest auth on the push. Leave blank for source-IP-only.",
},
{
key: "skipSourceIpCheck",
label: "Don't verify push source IP",
type: "boolean",
required: false,
default: false,
help: "Accept pushes regardless of the source IP. Needed when the network rewrites the inbound source address (e.g. WSL mirrored mode reports the host's own IP, not the camera's), which would otherwise reject every push. Leave OFF on a normal LAN.",
},
];
export const hikvisionDriver: CameraDriver = { export const hikvisionDriver: CameraDriver = {
id: "hikvision", id: "hikvision",
category: "camera", category: "camera",
label: "Hikvision camera", label: "Hikvision camera",
description: "Hikvision snapshot via ISAPI (HTTP Digest).", description: "Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
transports: ["tcp-ip"], transports: ["tcp-ip"],
configFields: cameraConfigFields, // The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
// so it may need the backend push IP at assign time (like the Dingtian).
pushesToBackend: true,
configFields: [...cameraConfigFields, ...alarmPushFields],
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201. // ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
create: (c) => create: (c) =>
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`), new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import {
renderTicket,
renderReceipt,
renderWindowChargeNotice,
renderSubscriptionCard,
stamp,
} from "./printer-escpos.js";
// The ESC/POS renderers are pure (data → Buffer). These tests pin the byte-level
// invariants that caused real misprints: the CP852 codepage select, the Albanian/
// punctuation character mapping (no stray "?"), and the Code128 module width — a
// ~20-char id at width 3 overflows the 80mm head and the firmware silently aborts the
// barcode, so the out-of-window slip MUST use width 2.
// Command-byte markers (see printer-escpos.ts).
const SELECT_CP852 = Buffer.from([0x1b, 0x74, 0x12]); // ESC t 18
const CODE128_PREFIX = [0x1d, 0x6b, 0x49]; // GS k 73 (function B, Code128)
const GS_W = (w: number) => [0x1d, 0x77, w]; // GS w n — module width
const QR_PRINT = [0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]; // fn 181
function indexOfSeq(buf: Buffer, seq: number[]): number {
return buf.indexOf(Buffer.from(seq));
}
function hasSeq(buf: Buffer, seq: number[]): boolean {
return indexOfSeq(buf, seq) >= 0;
}
describe("renderTicket", () => {
const out = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
it("selects the CP852 codepage in the preamble", () => {
expect(out.includes(SELECT_CP852)).toBe(true);
});
it("emits a Code128 barcode of the ticket id", () => {
expect(hasSeq(out, CODE128_PREFIX)).toBe(true);
// The id appears as both barcode payload (prefixed {B) and large text.
expect(out.includes(Buffer.from("12345678901", "ascii"))).toBe(true);
});
it("uses module width 3 for a short (11-char) ticket id", () => {
expect(hasSeq(out, GS_W(3))).toBe(true);
});
});
describe("renderWindowChargeNotice — the scannable out-of-window slip", () => {
const out = renderWindowChargeNotice({
occurrenceId: "SUBSESS-abcdef0123456789",
holderName: "Taras Bulba",
at: "2026-06-21T13:21:00.000Z",
edge: "entry",
windowOpensMin: 20 * 60, // 20:00
});
it("uses module width 2 so the ~20-char occurrence id fits the 80mm head", () => {
// This is the fix for the silent no-print: width 3 would overflow ~576 dots.
expect(hasSeq(out, GS_W(2))).toBe(true);
expect(hasSeq(out, GS_W(3))).toBe(false);
});
it("emits BOTH a Code128 and a QR of the occurrence id (scan two ways)", () => {
expect(hasSeq(out, CODE128_PREFIX)).toBe(true);
expect(hasSeq(out, QR_PRINT)).toBe(true);
expect(out.includes(Buffer.from("SUBSESS-abcdef0123456789", "ascii"))).toBe(true);
});
it("does not emit a literal '?' for the warning sign or em dash (CP852 fallback)", () => {
// The title is "PARKIM - JASHTË ORARIT" (ASCII dash) and the pending notice uses
// "!" not ⚠. The Ë must map to its CP852 byte 0xD3, never 0x3f.
expect(out.includes(0xd3)).toBe(true); // Ë → CP852 0xD3
});
});
describe("CP852 character mapping (the misprint fixes)", () => {
it("maps ë to its CP852 byte, not '?'", () => {
// A receipt's "Kohëzgjatja" / "Mënyra" lines carry ë.
const out = renderReceipt({
ticketId: "12345678901",
header: { parkName: "Parking Ë" },
enteredAt: "2026-06-21T08:00:00.000Z",
paidAt: "2026-06-21T10:00:00.000Z",
amountMinor: 20000,
currency: "ALL",
tender: "cash",
voucher: false,
} as Parameters<typeof renderReceipt>[0]);
expect(out.includes(0x89)).toBe(true); // ë → CP852 0x89
});
it("transliterates an em dash to ASCII '-' (no '?') in the validity line", () => {
// No validFrom/validTo → the card uses an em dash placeholder "—" which must
// degrade to '-'. Count of '?' (0x3f) stays 0 across the buffer.
const out = renderSubscriptionCard({
code: "SUB-1",
header: { parkName: "P" },
holderName: "Test",
validFrom: null,
validTo: null,
} as Parameters<typeof renderSubscriptionCard>[0]);
// The em dash is replaced by '-' (0x2d); there must be no '?' fallback byte.
expect(out.includes(0x3f)).toBe(false);
});
});
describe("stamp (Albanian date format)", () => {
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
// Local-time dependent, so assert the structure + the Albanian month name.
const s = stamp("2026-06-21T10:48:25.000Z");
expect(s).toMatch(/Qershor 2026 \d{2}:\d{2}:\d{2}$/);
});
it("passes through an invalid date unchanged", () => {
expect(stamp("not-a-date")).toBe("not-a-date");
});
});
+4
View File
@@ -173,6 +173,10 @@ export interface CameraDevice extends Device {
captureSnapshot(ctx: SnapshotContext): Promise<Snapshot>; captureSnapshot(ctx: SnapshotContext): Promise<Snapshot>;
} }
export function isCamera(device: Device): device is Device & CameraDevice {
return typeof (device as Partial<CameraDevice>).captureSnapshot === "function";
}
export interface SnapshotContext { export interface SnapshotContext {
readonly direction: "entry" | "exit"; readonly direction: "entry" | "exit";
} }
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import {
orderForRole,
printWithFailover,
NoPrinterAvailableError,
type PrinterInstance,
} from "./printer-routing.js";
import type { PrinterDevice } from "./interfaces.js";
// Printer routing is pure selection over (config, health): which printer prints a job,
// best-first, with failover. The key business rules: the booth printer is a FALLBACK for
// entry tickets but a receipt NEVER prints on the outside dispenser; rank then id break
// ties deterministically; printWithFailover walks the order and surfaces all failures.
function inst(id: string, role: PrinterInstance["role"], failoverRank = 0, device?: PrinterDevice): PrinterInstance {
return { id, role, failoverRank, device: device ?? ({} as PrinterDevice) };
}
describe("orderForRole", () => {
it("entry-dispenser job: dispensers first, booth-receipt as fallback", () => {
const printers = [inst("booth", "booth-receipt"), inst("disp", "entry-dispenser")];
expect(orderForRole(printers, "entry-dispenser").map((p) => p.id)).toEqual(["disp", "booth"]);
});
it("booth-receipt job: NEVER falls back to the outside dispenser", () => {
const printers = [inst("disp", "entry-dispenser"), inst("booth", "booth-receipt")];
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
});
it("breaks ties by failoverRank (higher first), then id", () => {
const printers = [
inst("b", "entry-dispenser", 1),
inst("a", "entry-dispenser", 1),
inst("c", "entry-dispenser", 5),
];
expect(orderForRole(printers, "entry-dispenser").map((p) => p.id)).toEqual(["c", "a", "b"]);
});
it("excludes printers of no relevant role", () => {
const printers = [inst("booth", "booth-receipt")];
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
// For a receipt job, an entry dispenser is excluded entirely.
expect(orderForRole([inst("disp", "entry-dispenser")], "booth-receipt")).toEqual([]);
});
});
describe("printWithFailover", () => {
function device(behavior: "ok" | "fail"): PrinterDevice {
return {
printTicket: vi.fn(behavior === "ok" ? async () => {} : async () => { throw new Error("offline"); }),
} as unknown as PrinterDevice;
}
it("prints on the first healthy candidate and returns its id", async () => {
const printers = [inst("disp", "entry-dispenser", 0, device("ok")), inst("booth", "booth-receipt", 0, device("ok"))];
const job = vi.fn(async (d: PrinterDevice) => d.printTicket({} as never));
const used = await printWithFailover(printers, "entry-dispenser", job);
expect(used).toBe("disp");
expect(job).toHaveBeenCalledTimes(1);
});
it("fails over to the booth printer when the dispenser throws", async () => {
const printers = [inst("disp", "entry-dispenser", 0, device("fail")), inst("booth", "booth-receipt", 0, device("ok"))];
const used = await printWithFailover(printers, "entry-dispenser", (d) => d.printTicket({} as never));
expect(used).toBe("booth");
});
it("throws NoPrinterAvailableError listing every failed attempt", async () => {
const printers = [inst("disp", "entry-dispenser", 0, device("fail")), inst("booth", "booth-receipt", 0, device("fail"))];
await expect(printWithFailover(printers, "entry-dispenser", (d) => d.printTicket({} as never)))
.rejects.toBeInstanceOf(NoPrinterAvailableError);
});
it("throws when no printer is configured for the role", async () => {
await expect(printWithFailover([], "entry-dispenser", async () => {}))
.rejects.toBeInstanceOf(NoPrinterAvailableError);
});
});
+2 -1
View File
@@ -7,5 +7,6 @@
"types": ["node"] "types": ["node"]
}, },
"references": [{ "path": "../shared" }], "references": [{ "path": "../shared" }],
"include": ["src/**/*"] "include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
} }
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
// Device tests are pure byte-stream assertions over the ESC/POS renderers + the
// printer-routing logic — no sockets, no hardware. Run from src (not dist).
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});
+7
View File
@@ -27,6 +27,7 @@ export const RESOURCES = [
"event", // the signed ledger feed + void "event", // the signed ledger feed + void
"report", // events feed, occupancy, future reports "report", // events feed, occupancy, future reports
"log", // application/diagnostic logs (app_logs) — view + retention "log", // application/diagnostic logs (app_logs) — view + retention
"recyclebin", // soft-deleted master data: view / restore / purge
] as const; ] as const;
export type Resource = (typeof RESOURCES)[number]; export type Resource = (typeof RESOURCES)[number];
@@ -56,6 +57,9 @@ export const PERMISSIONS: readonly Permission[] = [
"event:read", "event:void", "event:read", "event:void",
"report:read", "report:read",
"log:read", "log:read",
// Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These
// are admin-grade — a restore can revive a privileged user/role, a purge is permanent.
"recyclebin:read", "recyclebin:update", "recyclebin:delete",
] as const; ] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL /** The protected built-in role: non-deletable, non-editable, always = ALL
@@ -343,6 +347,8 @@ export const REASON_CODES = [
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and // a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate). // hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
"sub.refused.unpaidWindow", "sub.refused.unpaidWindow",
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
"void.ticketCancelled",
] as const; ] as const;
export type ReasonCode = (typeof REASON_CODES)[number]; export type ReasonCode = (typeof REASON_CODES)[number];
@@ -371,6 +377,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)", "sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)", "sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth", "sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
"void.ticketCancelled": "ticket cancelled — {reason}",
}; };
/** /**

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