0b7eb28dfab0faedbd240addcfe1b71890fa35a2
90 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1de209be48 |
fix(shifts): operator filter — select over real operators, no more focus loss
The admin operator filter was a free-text input that broke three ways at once: its visibility hangs off the query response (scope === "all") and its value is part of the query key, so every keystroke started a new query, data went undefined for the round-trip, and the input UNMOUNTED mid-keystroke (lost focus, list blanking that read as a page reload). Filtering also silently failed — the server matches the operator by exact username, so partial text matched nothing. - keepPreviousData on the shifts query: previous data (and scope) stays live during refetch, so filter controls never unmount and the list never blanks on preset/filter changes. - The filter is now a <select> of operators that HAVE shifts: the server returns the distinct list (signed z-reports + the open shift's holder) on GET /api/shifts, admin scope only — operators still can't see other names. Exact match by construction. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
fd9885e9ec |
feat(tariff-lab): DB-backed draft tariffs + named published versions
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:
- tariff_drafts table (migration 0021): MUTABLE by design — the one
exception to "editing publishes a version"; a draft prices nothing and
signs nothing. Drafts are validated + tz-stamped on save exactly like a
publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
tariff:update); publishing a draft goes through the normal immutable
POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
(click any to price against it); main pane cut to pure entry/exit
(ticket loader, payment, category inputs dropped); the composer form is
extracted to TariffEditorForm.tsx and reused in a modal (new drafts
prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
publish — carried from the lab draft, or typed in the composer's new
optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
landed in
|
||
|
|
43c1f45e29 |
feat(reader): channel tagging (clone defense) + structural filter for phantom scans
Two reader-hardening changes born from the park-buzi phantom-scan investigation
(empty pre-opening site, exit reader pushing sun-decoded garbage codes).
1. CHANNEL TAGGING — closes the printed-card-clone hole. The DT-008 push is
channel-blind (one opaque cardid from either engine) and SubscriptionFlow
matched by value only, so printing an RF card's UID (often written on the
card face, e.g. 86A158) as a barcode cloned the card. Now:
- Vendor tool sets output prefixes (QRCode "Q:", Card "K:"; server env
overrides READER_QR_PREFIX / READER_CARD_PREFIX).
- routes/qr-reader.ts strips the prefix and tags the read's confirmed
channel (DeviceReadEvent.channel optical|rf; kind qr|card). Enrollment
capture stores the BARE value. READ log lines carry ch=… (permanent
phantom attribution).
- SubscriptionFlow.match requires channel agreement: an optical decode may
not claim an rf credential (and vice versa) — refused + signed
sub.refused.channelMismatch anomaly (a clone attempt is a fraud signal).
- Unprefixed reads keep the legacy untagged shape and match as before, so
enforcement only bites where prefixes are deployed. Deploy server FIRST,
then set prefixes in the vendor tool.
2. STRUCTURAL FILTER — phantom decodes out of the signed feed (operator-
requested, reverses the earlier "record every probe" position — red
"who is exiting?" rows for NOBODY train the operator to ignore the feed).
read-dispatch.ts drops a no-match reader value that cannot possibly be a
credential we issue (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
confirmed-RF, not a plate) to UNSIGNED device_events telemetry
(unrecognizedRead:true). Deliberately WIDE plausibility: forged ticket
shapes, unknown physical cards, unknown SUB- codes all still sign the
normal refusal anomaly; enrolled credentials match before the filter and
can never be hidden. Works for legacy unprefixed reads too — the feed
cleans up on deploy, before any vendor-tool change.
Wiki: dingtian-dt008-reader.md records the clone hole + fix, the filter (as a
recorded position reversal), and the two device-side settings now part of the
credential contract (output prefixes + Card Input format, moving 6H→8H at the
next vendor-tool session; both live ON the device — re-apply after any
factory reset/swap).
Tests: qr-reader-channel.test.ts (prefix split, route tagging, bare-value
capture), subscription-channel.test.ts (channel agreement matrix + anomaly),
read-dispatch-filter.test.ts (filter boundary: phantoms dropped, probes kept,
enrolled never hidden). Suite 278 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
6505a4a73b |
feat(entry): admin bypass of the presence gate for faulty radar/camera
The entry button (physical press AND the operator-issued mint) requires
radar/loop presence + camera detection to confirm a real vehicle. When one
of those devices is faulty, the gate blocks legitimate transient entry. Let
the ADMIN drop a specific signal as a requirement until support fixes the
hardware — the admin is not the adversary, but weakening an anti-fraud gate
stays attributed and auditable:
- Granular: bypass radar and camera independently (Setup → controller
section). A dead camera drops only the camera check; a dead radar only
radar. Both off = normal gate; both on = press-to-print.
- Signed: a DEDICATED endpoint (PUT /api/site-config/presence-bypass,
site:update) appends a signed config_change {setting, value, prev,
operator} per actually-changed signal — new ledger type. No-op toggles
sign nothing; disabling signs too. Kept out of the generic site PUT.
- Flagged: every vehicle_entry issued (and every refusal anomaly) while
bypassed carries presenceBypassed:[...] in its signed payload.
- Persists until turned off; amber warning in Setup while active. The
booth entry light treats a bypassed signal as satisfied (server
re-checks authoritatively). Physical-button path falls through to the
cooldown backstop when radar is bypassed.
- Migration 0020: two boolean site_config columns (default off).
Fixes a latent bug surfaced by the tests: firstRelayByDirection returned no
presenceInput, so issueForOperator's radar gate always read "presence loop
unavailable" — operator-issue never actually gated on radar. The resolver
now attaches the presence input serving the relay (mirrors relayForButton).
10 new tests: 5 gate combinations (each bypass drops only its signal +
records it), 5 route tests (RBAC, signed transitions, no-op, validation).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
306d136a08 |
feat(setup): operator-tested relay pulse, signed into the ledger
Add a per-relay "Test" control on each saved controller in /setup so an admin can prove barrier wiring without a vehicle. POST /api/setup/test-relay pulses a barrier relay — but because a physical open with no matching signed command is the fraud signal, the route SIGNS a barrier_open_command (reason setup.relayTest, source manual, attributed to the acting admin) BEFORE it fires. Reconciliation then reads the open as explained, not an anomaly, and there's an audit trail. - Admin-only (site:update), CSRF-guarded; fires only against a SAVED controller (real id → clean attribution; also stops a redirected/unsaved config from opening an arbitrary host's barrier). Sign-before-fire; a pulse failure is reported, not a 500. radarAlert relays (lamps) are excluded from the UI. - New reason code setup.relayTest in @parking/shared (+ EN template); sq/en keys. - EventLog constructed before setupRoutes so the route can sign. - Integration test (stub controller, no hardware): RBAC 403, CSRF 403, signed barrier_open_command on success, 400 unknown relay w/ no ledger row, 404 unknown controller, 400 bad relay value. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
33c4ea1e91 |
feat(entry): operator-issued entry + exit plate-swap reconciliation
Two halves of one anti-fraud design.
(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
- PRESENCE-GATED like the physical button: a real car must be present (radar/
loop AND camera busy). Enforced BOTH sides — the server re-checks current
presence so a direct POST can't bypass a disabled button; no presence loop
=> feature unavailable; a no-presence attempt signs an anomaly.
- FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
companion entry.operatorIssued anomaly (the adversary path always leaves a
red-flag row).
- capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
a legit car).
New session:create permission (migration 0019 -> operator role, admin-
revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
factored into one shared #issueTicket (button + operator). UI: the entry
BarrierLight becomes a clickable issue-control when presence+permission+shift
meet (confirm -> issue).
(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
- BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
pay/exit modal shows a red warning + "Override & release" (override signs an
attributed exit.plateSwapOverride). Flag+override, never a silent hard block
(exit fails-open; a plate is never the sole gate).
- READER path (no operator): log-only anomaly + fail-open.
Extended BoothExitResult + /api/exit (override); boothExit client returns a
structured swap result.
Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
114a32e6f2 |
feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.
- New `drawer` resource: drawer:create (operator records; admin-revocable per
role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
touches the drawer balance (the correction is settled outside the app). This
is what keeps a late review from leaking into the next operator's inherited
drawer — a denial that lands after the reviewed shift closed moves no cash.
Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
(operator: record + own; admin: review queue + all). routes/drawer.ts lifted
from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
for its other job = admin-sees-all-shifts). New DrawerManager.tsx.
Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
on-site), matching the card-tender gate.
shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
cfac14e09e |
fix(snapshots): normalize content-type on serve so stored images render
Cameras (Hikvision) return `Content-Type: image/jpeg; charset="UTF-8"` — a charset param on a binary body is malformed, and browsers refuse to decode an <img> declared that way. Old capture code persisted that raw header into snapshots.content_type (100/101 dev-DB rows); GET /api/snapshots/:id re-emitted it verbatim, so every legacy snapshot rendered blank in the booth modal. Capture was already hardened (encodeForStorage re-encodes to a clean image/jpeg, fail-soft via cleanType), but the serve route trusted the stored value. Export cleanType and apply it when setting the response header, so a bare image/jpeg is sent regardless of what was stored — un-breaks all legacy rows with no data migration. A stored value from an untrusted device is itself input; normalize on capture AND on serve. Adds cleanType unit tests. Verified: a previously-unrenderable 2560x1440 row now decodes in-browser. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
84f00db48b |
feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
Retention (keep-last / keep-daily-days) is operational policy the on-site admin should tune, not a server env var requiring a redeploy -- same reasoning that moved the target directory to the UI. - Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable; null = code default 7 / 30 per field). - BackupService reads retention fresh each run; status() exposes keepLast + keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone). - PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null to reset to default; 400 on negative). - UI: two retention fields on the Backup config card; one Save covers target + retention. i18n sq + en. BACKUP_KEY wired into Komodo: - komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret, alongside JWT / signing keys). - komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY. Also carries the small in-progress setup-intro i18n copy trim. Tests: 218 server tests green, incl. retention persist / reset-to-default / reject- negative and the updated status shape. Migration applies cleanly (needed a statement-breakpoint between the two ALTERs). Wiki backup-recovery updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
d5e41500a8 |
feat(backup): admin UI with admin-chosen target directory
The backup destination is now chosen by the on-site admin in the UI (Setup -> Backup), not a server env var. An env-pinned target defeats the purpose: the admin can't point backups at a freshly-plugged USB or a NAS mount without editing .env and restarting. The encryption key stays a server secret. Target storage: - New site_config.backup_target_dir (migration 0016, nullable; null = not configured). BackupService reads it fresh each run, so a UI change takes effect with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it backs up. Routes: - PUT /api/backup/config -- set/clear the target (backup:update; upserts id=1). - POST /api/backup/test -- probe a candidate path server-side (exists / is a directory / writable) so the admin gets feedback before relying on it. - status() now exposes targetDir + keyPresent, so the UI distinguishes 'no target set' from 'BACKUP_KEY missing'. UI (apps/web/src/BackupSettings.tsx): - A Setup -> Backup tab (gated backup:read): an editable target-path field with a Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status panel (config state, last-run size/pruned/error, a distinct amber missing-key warning), a Back up now button, and the restore-is-out-of-band note. Full i18n (sq + en); nav.backup. - API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup. Also includes a small in-progress copy trim to the setup-intro i18n strings. Verified live with Playwright: typed a path -> Test reported writable -> Save persisted it -> status reflected it and showed the key-missing warning. Whole monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
0c218179c4 |
feat(backup): encrypted on-site DB backup engine + local target
The SQLite DB is the signed append-only ledger, so a disk failure / stolen or destroyed PC means total revenue-history loss (open-question #5). This is the first slice of the backup-recovery design: the engine + a local/mounted target + a daily timer + a manual route. Engine (apps/server/src/backup.ts): - Consistent online copy of the live WAL DB via better-sqlite3's native .backup() (not a raw file copy, which can capture a torn WAL) — the restored copy is a byte-identical, queryable DB. - AES-256-GCM with a scrypt-derived key from BACKUP_KEY; self-describing header (magic|version|salt|iv|...|authTag) so a restore tool needs only the key + file. Zero new dependencies (Node crypto). - The plaintext intermediate is kept in scratch (not the removable/network target) and wiped in a finally, success or fail. - Retention: keep-last-N + one-per-day within N days. Wiring: - BackupService (env config, single in-flight guard, last-success/last-error). - routes/backup.ts: GET /api/backup/status (backup:read), POST /api/backup/run (backup:create), 409 when unconfigured. No restore route — restore is an out-of-band runbook action on a fresh appliance, not a console call. - New permission resource in @parking/shared. - server.ts: an unref'd daily timer, a no-op until BACKUP_TARGET_DIR + BACKUP_KEY are set, deliberately not run at startup (a just-power-cut booth shouldn't write to a possibly-unmounted disk). - openRawDb() added to @parking/db/testing (open a file without migrating, for restore-verification tests). BACKUP_KEY is deliberately SEPARATE from EVENT_SIGNING_KEY (independent rotation; backups travel, the signing key shouldn't). SMB/NFS work as mount paths; SFTP + admin UI + restore runbook are deferred slices. Tests: round-trip byte-identical, GCM tamper/wrong-key fail, short-key rejected, scratch cleaned, route auth/RBAC + 409. build/lint/test green (212 server tests). Wiki + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
f6e35bbebf |
fix(reader): correct the QR reader's identity — Dingtian DT-008, not "GEE"
An early wrong assumption named the QR/RFID access reader "GEE" / "GEE/Fondvision" / "GEE-QR-ER80" (and summarized a raw GEE PDF as its datasheet). There is no GEE device — it's the Dingtian DT-008 (dingtian-tech.com/en_us/qr_code_reader.html), the same vendor as the relay board, which is why it integrates the identical HTTP-GET-push way. Code: - Driver symbol geeQrReaderDriver → dingtianQrReaderDriver; label → "Dingtian DT-008 QR/RFID reader (HTTP push)"; comments/description rewritten to the real DT-008 facts (Wiegand 26/34, TCP/IP, USB, RS485 — not RS-232; QR/barcode + ID/IC/NFC — not DataMatrix/1D). - Persisted driverId "gee-qr-reader" → "dingtian-qr-reader" (the registry lookup key + the row created on assign in qr-reader.ts). - Migration 0015 rewrites existing devices.driver_id rows so configured readers keep resolving (applied to the dev DB — 2 rows; the booth applies it on boot). Behaviour is unchanged: naming + the persisted id only. Wiki + memory: - Renamed entities/gee-qr-er80.md → dingtian-dt008-reader.md and sources/gee-qr-er80.md → dingtian-dt008.md; rewrote both to the real DT-008 product-page specs while KEEPING all the verified-on-hardware protocol facts (cjihao serial, .jsp path, Connection: close). Fixed every cross-reference + "GEE" mention in 6 other pages. Memory gee-reader-serial-binding → dingtian-reader-serial-binding. The only surviving "GEE" mentions are deliberate naming-correction notes, the raw PDF filename, and the append-only log history. Full workspace build/lint/test green; dev DB readers verified resolving to the registered dingtian-qr-reader driver. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
f706726eeb |
feat(prefs): per-user UI font scale (A−/A+), saved to the profile
A header A−/value/A+ control scales the whole UI, persisted per user and restored on login from any booth — cloning the theme-pref pattern end to end. - DB: users.font_scale (migration 0014; percent, 100 = base, NOT NULL default). - Server: PUT /api/auth/font-scale (auth-guarded; clamps to 80–160, snaps to a 10-step); fontScale flows through sessionView → login + /me. - Client: setFontScalePref + applyFontScale; applied in App alongside theme; FontScaleToggle in the header; i18n sq+en. Scaling uses CSS `zoom` on the root, NOT root font-size: the app's type is pinned in px (text-[12px] etc., ~230 spots), which a font-size change would not scale — so the dense Active-sessions / Live-feed logs stayed tiny. `zoom` scales everything uniformly (text, spacing, icons) like the browser's Ctrl+/−, which is the readability win for operators who need larger text. Tests: 4 font-scale auth-route cases (persist + /me, clamp/snap, 400, default-100). Full workspace build/lint/test green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
6734e9815e |
fix(booth): backfill the live-feed plate + make plate search work
Two booth feed fixes: - Plate not showing until refresh. Plate recognition is async/advisory (snapshot.ts recognizePlate → a kind:"read" device_event keyed by the session identity), so it lands AFTER the entry/exit event already shipped over the WS without a plate; a refresh re-fetched via the bulk enrich path and showed it. Added a `plate-recognized` bus event (device-events.ts) emitted when the read is written; ws.ts forwards it; the client patchPlate(identity, plate) (live-store) backfills the already-rendered feed row in place and invalidates the Query-owned active-sessions list. No refresh. - Plate search didn't filter. Both the live-feed (BoothScreen) and active-sessions (ActiveSessions) search haystacks matched the wrong field — the displayed plate is the ENRICHED top-level e.plate/s.plate (set by enrichEvent), not payload.plate (the plate is unsigned, never in the signed payload). Switched the haystacks to the displayed field. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
38481f105f |
feat(booth): blink the Entry/Exit lights on radar presence (mirror relay 3)
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy)
off the camera lane-status only — they couldn't show the radar-only "detected,
not yet confirmed" state that makes the physical button lamp (relay 3) blink.
Now they mirror the lamp's 3-state rule per lane:
radar present + camera not busy → BLINK green↔red (~1 Hz)
camera busy → SOLID red
otherwise → SOLID green
End-to-end:
- LanePresence (lane-presence.ts): subscribes to deviceEvents.onInput, resolves
each presence edge to its lane via the new direction-agnostic presenceLaneOf()
(device-resolve.ts) — entry AND exit, unlike the entry-gated relayForPresence
the one-car-one-ticket gate uses — and emits a lane-presence {entry,exit} bus
event on change. Wired in server.ts (start + onClose).
- WS forwards it (hello snapshot + push) into live-store.radar.
- BarrierLight (BoothScreen.tsx) is now 3-state; blinks via the .lane-blink
keyframe (index.css), which holds solid-red under prefers-reduced-motion.
Same input + same rule as the lamp, so the screen and the post never disagree.
A new test (lane-presence.test.ts) caught a real bug: the first cut reused
relayForPresence, so the EXIT lane never resolved (it's entry-gated) and never
blinked — presenceLaneOf fixes it. Covers entry/exit independence, de-dupe
across several radars on one lane, and ignoring non-presence inputs.
Full workspace build/lint/test green (185 server tests). Updated the
button-light-indicator wiki page ("On-screen twin").
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
3a60367232 |
feat(setup): print a real test slip from the printer "Test connection" modal
healthCheck only opens the transport (TCP connect / USB open) — it proves the printer is REACHABLE, not that paper feeds and the head fires. Add a "Print test slip" action so the admin can physically confirm a printer is live (the new host-net USB /dev/usb/lpN path, or a network printer). - server: POST /api/setup/test-print — printer-only, re-merges stored secrets like /test (so an edited network printer authenticates), creates the device, and pushes a short slip via the device-agnostic printReport(). Fail-soft: a print error (paper out, head fault, transport drop) is reported, never a 500. Mirrors the test-anpr pattern. - web: testPrint() client + PrintTestResult; a button in the device modal shown for category=printer, with ok/fail rendering. i18n keys in sq + en (parity holds). Server 168 tests pass; web + server typecheck clean. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
420542ce10 |
fix(setup): add Dingtian relay-password field + secure secret re-merge on test
The relay control password (relay_pw) was read by the driver but had NO form field, so Test connection sent it as 0 → the device ignored the probe → a controller showed "offline" even though it pinged. Add a "Relay control password" config field (secret; blank keeps the stored value). Because relayPassword is redacted from the client, the edit form can't resend it — so the test endpoint now re-merges the stored secret by device id (mirroring save). It is re-merged ONLY when the submitted config addresses the SAME device: matching driverId and every connection-identity field it sets (host/port/binaryPort/httpPort/serial). A redirected host/port or mismatched driver yields NO secret, so a probe can't exfiltrate the password to an attacker host (the booth operator is the threat-model adversary). testDevice() now passes the device id; setup-secrets.test.ts covers the identity guard. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
8129b63a8c |
feat(profile): self-service name/email/password + desktop installers in CI
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
78d1f6808a |
feat(subs): admin can correct a subscription's plan VERSION
A subscription froze its planVersionId at sale (reproducible pricing). There was no way to move a sold sub onto a different VERSION of the SAME plan — needed when an admin publishes v2 with different timeframes (e.g. mujor-naten-cdo-dite v1 "every day" → v2 "weekdays only") and wants an existing subscriber on it, or back on v1. Backend (PUT /api/subscriptions/:id): - accept planVersionId; honored only with the subscription:plan permission (stronger than subscription:update — a plan-management action). Non-privileged caller sending a change → 403, not silently dropped. - validated to belong to the sub's EXISTING planId (a different plan = a different price basis = a re-sale → 400). - price/currency/period/planId stay frozen; only planVersionId moves. The swap is server-logged for audit (the row is mutable master data, not on the ledger). Past signed entry/exit events keep their own windowTariffVersionId, so history reprices identically — only future access uses the new version's windows. Frontend (SubscriptionManager): - pass the session user through the route (like RolesManager). - admin-only "Versioni" picker in the edit modal: lists every version of the sub's plan by effective date + a timeframe summary (days + window, or 24/7), current pre-selected. The plan itself stays read-only. Sends planVersionId only when it changed. - i18n: subs.version/versionHint/versionCurrent/versionOnlyOne/everyDay/allDay in both sq + en. Verified on a writable DB copy: version changed, price + planId frozen, cross-plan version rejected. Live DB untouched. build+lint 14/14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
1b54775b4d |
feat(shift): two-pane shift history — list + per-shift activity log, timeframe presets
Rework the shift screen into a master/detail view on /shift: the shift CONTROL (open/close, drawer vouchers, X-report) on top, then a two-pane history below — shift list on the LEFT, the selected shift's signed activity log on the RIGHT. - Timeframe presets replace the bare from/to inputs: Yesterday / Last week / Last month / All / Custom (custom reveals the date pickers). Filters the shift list by start time. - Activity log = every ledger event in the selected shift's [start, end] window (entries, exits, payments, vouchers, anomalies, the Z-report), rendered like the booth live feed (same EVENT_STYLE), with the shift's drawer reconciliation in the pane header. - Scope unchanged + enforced SERVER-SIDE: an operator sees only their own shifts (no operator filter); an admin (shift:cash) sees all + the operator filter. The list auto-selects the newest shift. API: /api/events gains an optional `until` (ISO) upper bound so a shift's window can be fetched ([start,end]); fetchEvents passes it. Verified on live data: a closed shift window returns just its 20 events out of 260. Build+lint 12/12 (i18n parity). The same component also backs /setup/shifts. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
36f30d39ff |
feat(plans): reactivate + delete-when-unused; card layout fixes overlap
Addresses three issues with the plan catalog screen: 1. Retired plans had NO actions (the action cell was gated on "current version", which a retired plan lacks) — so there was no way to make one in-force again. Add POST /:planId/reactivate (inverse of retire) + a Reactivate button on retired plans. 2. No delete. Add DELETE /:planId, allowed ONLY when zero subscriptions reference the planId (any version) — a referenced plan version must survive for reproducible repricing/audit, so an in-use delete returns 409 and the UI says "retire it instead". The Delete button only shows when the plan has 0 subscribers. 3. The 6-column table overflowed max-w-3xl: action buttons overlapped and the status badges wrapped to a second line. Replace it with a CARD list (one card per planId, grouped across versions): name + status on top, price · hours · effective on a wrap row, "used by N" expandable to holder names, and actions on their own bordered row — nothing overlaps, badges stay inline. Build+lint 12/12 (i18n parity). Verified on a DB copy: unused plans report deletable; retire→reactivate flips active back. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
c64457020f |
fix(subs): resolve the plan version at the SALE instant, not validFrom
Selling/quoting a subscription resolved the plan version against `validFrom`, but validFrom is a DATE (midnight UTC for "starts today"). A plan published later the same day (effectiveFrom 15:22) then failed `effectiveFrom ≤ validFrom` (00:00), so resolvePlanVersion returned null → "no active plan for that planId", and the form's selectedPlan went null (hiding the new count field too). The plan/price in force is determined by WHEN THE SALE HAPPENS, not by the coverage start — like a tariff, the customer buys today's published rate. Resolve at new Date() in all three sites (validate, priceSale, /quote); validFrom is kept only for span pricing. Verified the two live plans now resolve. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
e0e218fa61 |
refactor: plan timeframes use a per-day-of-week picker (like the V2 tariff)
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).
Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.
outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
53e1e7b25c |
feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).
1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
amount = span price × quantity; maxConcurrent defaults to the quantity so all
N cars can be inside. Quantity rides in the payment payload.
2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
tariff (the subscriber is a transient for that time):
- early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
the vehicle_entry payload), collected at exit;
- late exit: window-close → departure, and exit is GATED
(sub.refused.unpaidWindow) until paid at the booth.
Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
reuses computeFee + the active tariff version
(apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
business gate — the fail-open rule still governs the offline path.
3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
max(0, quantity − itsCarsInside) per active subscription, so transients see
"full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
never gated by full.
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.
Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
fd4608a8f1 |
feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts
Re-model subscription pricing from per-row, operator-typed prices into an admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now SELLS by picking a plan over a date span; the price is LOOKED UP, never typed — removing the fat-finger risk on a money field — and day/week/month periods make the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span. - Schema/migration 0010: new `subscription_plans` (immutable, effective-dated, keyed by a stable planId; period day/week/month + per-period price + active flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a "Monthly" plan from the existing site default price (no data loss). - Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period), amount = periods × per-period price. Ceil = any started period is full (hotel practice). `resolvePlanVersion` picks the latest active version ≤ sale instant. - Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked sell path derives the amount from the plan; `POST /api/subscriptions/quote` returns a server-computed quote so the operator can't override it. The signed-payment sale fix is unchanged — only the amount SOURCE moved; payload now carries planId/planVersionId/periods. Updates never re-sell (price frozen). - Frontend: SubscriptionManager sell form swaps the price field for a plan picker + start/end dates + a live quote line. New SubscriptionPlansManager (Setup tab) for the admin catalog. i18n (sq+en) for both. Verified on a copy of the live DB: 0010 applies (existing subs intact), a 3-night hotel sale prices to 2,400 ALL, appends one signed payment with planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
cb68cbafdb |
feat: mid-shift X-report (read-only takings-so-far)
Let the operator see, on demand during an open shift, the opening float inherited, cash/card collected so far, pay-ins/pay-outs, and the current expected drawer balance — without closing. GET /api/shift/report (shift:read; 204 when no shift is open) returns the same drawer projection the Z-report computes. Factored that math into a shared ShiftService.#summariseWindow(open, asOf) used by BOTH the X-report (asOf=now, read-only) and close()'s Z-report (asOf=endedAt, signed), so the two can't drift. The X-report appends NOTHING — it's a snapshot, not an accountability mark; the Z-report at close remains the signed record. UI: a "Takings so far" button on the shift control reveals a cyan X-report panel; the header still shows the live drawer total for the at-a-glance figure. Verified against a copy of the live DB: X figures match drawerBalance(), the drawer identity holds, zero events appended, chain still verifies. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
2835f78635 |
feat: re-model drawer cash as directional vouchers (Mandat Arkëtimi / Pagese)
Replace the single signed-± cash_movement with two distinct financial documents — the direction is the event TYPE, not the sign of an amount: cash_in = Mandat Arkëtimi (receipt / pay-IN, +) voucher AR-NNNN cash_out = Mandat Pagese (disbursement / pay-OUT, −) voucher PA-NNNN Each carries a positive magnitude, voucher number, reason, the operator who raised it and the admin who authorized it, and prints an Albanian slip. Authorization changes from admin-only to operator-RAISED / admin-AUTHORIZED: any shift:create holder raises the voucher, but POST /api/cash-voucher only commits when authorizedBy is a real admin (shift:cash) re-entering their password (verified server-side). Keeps the float control while letting the operator do the booth paperwork. Legacy cash_movement events are kept — they still verify and still fold into the drawer (signed-±); the append-only chain is never rewritten. The drawer fold and the Z-report window now sum all three types. Verified against a copy of the live DB with the real signing modules: cash_in 3000 + cash_out 5000 → drawer −2000, hash-chain verifies OK. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
a20400c2c5 |
fix: record subscription sale as a signed payment (close off-book hole)
Creating a priced subscription wrote only the mutable `subscriptions`
master row and appended NOTHING to the signed ledger — so the cash an
operator collected showed in the live feed, drawer, and shift Z-report
nowhere, leaving no signed trace. A booth operator could sell
subscriptions and pocket the money untraceably — the exact
operator-as-adversary path the append-only signed ledger exists to close.
Found live: 3 priced subscriptions (27,000 ALL) had zero payment events.
Selling a priced subscription now appends a signed `payment` event at
create time: amount = priceMinor x months (full multi-month prepay),
operator-chosen tender (cash->drawer / card->bank), payload
{ subscriptionSale: true, permitId, operator, months }. Folds into the
shift Z-report/drawer with no new summing logic; the feed badges it
"subscription sale" and resolves the holder name. The create response
returns the recorded { sale }; subscriptionRoutes now takes the EventLog
and ShiftService.
Not hard-gated on an open shift (a sale can happen outside the booth money
path) — it warns instead. The 3 historical off-book sales are not
back-fillable (append-only forbids forging dated events) — reconcile via
cash_movement or a Z-report note.
Verified against a copy of the live DB with the real signing modules:
signed payment appended, hash-chain still verifies, lands in shift cash
totals. Build + lint 12/12.
Wiki: subscription "Collecting the fee" deferred -> BUILT (+ the off-book
hole and why); shift sale-folds-in; threat-model worked example
("store the price != account for the sale").
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
cdb55a8652 |
feat: show recognized plate in live feed + active sessions
Surface the advisory ANPR plate (device_events kind="read", keyed by session identity — unsigned, prunable, never an access decision) next to entry/exit events in the live feed and on active-session rows. Resolved at serialize time (new plate-lookup.ts; prefers an entry read; one device_events scan per page) like subscriber-name enrichment — the signed ledger is untouched. Adds plate? to the shared LedgerEvent and to ActiveSession/SessionLookup; a small amber badge in the UI. Caveat: a vehicle_entry is signed + pushed over WS before the async ANPR read lands, so a fresh feed row may show no plate until reload; always present on active sessions. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
3d02134711 |
feat(tariff): Tariff Lab — pure session-pricing simulator
Test rates "in time" (overnight windows, daily caps, overstay) in seconds against any tariff version, instead of waiting hours/days. No real ledger writes. - Extract priceSession() into @parking/shared: the grace/overstay wrapper over computeFee (unpaid -> entry..now; within-grace -> settled 0; grace-expired -> overstay, a fresh period from grace-expiry). PayStation.quote() now calls it so the booth and the lab can never diverge. - API (tariffs.ts, tariff:read, read-only): POST /api/tariff/simulate prices a hypothetical session (active/any version/inline structure) and returns the priceSession outcome + a 30m..3d duration curve (see where the daily cap flattens); GET /api/tariff/simulate/session/:identity prefills from a real ledger session. - UI TariffLab.tsx at Setup -> "Tariff Lab": version picker, entry/asOf times, optional payment+grace, category, and load-a-real-ticket. Admin-gated, available on-site (useful to quote a dispute). - 4 new priceSession unit tests incl. the ticket-1245791632490 overstay-not-zero regression (40 pass). i18n lab.* + nav.tariffLab (sq+en). Verified live via the UI. Wiki: tariff (priceSession + Tariff Lab as-built), log. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
9ec644811a |
feat(vision): surface the recognized plate in the booth UI
The ANPR plate was saved (device_events kind:"read") but had no UI. Extend GET /api/snapshots/by-identity/:identity to also return plates[] (plate, confidence, region, direction, snapshotId, at) for that session, and render each as a cyan "Plate: AA558EE 100%" chip in the SnapshotStrip — so it shows in both the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. Deduped by plate+direction; session:read gated; i18n sq+en. Verified: by-identity returns plates[] for a seeded read (200, AA558EE 0.999 Albania entry). Build + lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
bfb6ab0b36 |
feat(logs): app log store — backend pino DB sink + frontend error collection
Add a third data stream (app_logs), distinct from the signed ledger and device telemetry, for operational/diagnostic logs — an offline appliance has no Sentry to ship to, so the host is the log store. Backend: a pino stream tees warn/error/fatal into app_logs (info/debug stay stdout-only) with no call-site change; the DB is built before Fastify so the logger has its sink. Frontend (lib/logger.ts): ships failed API requests (minus 401 churn), window.onerror, unhandledrejection, and a top-level React ErrorBoundary; console warn/error forwarded only at debug/trace. Batched/throttled POST, sendBeacon on pagehide, loop-safe (never logs the /api/logs call), best-effort everywhere. POST /api/logs (any signed-in user, CSRF, tolerant) + GET /api/logs gated by a new log:read permission (new `log` RBAC resource; admin holds it). Retention: pruned by age + row cap, hourly + at startup. UI: a Logs screen under /setup (filter level/source/since, expand to context+stack), sq+en. Migration 0009_app_logs. Verified end-to-end via app.inject: login -> POST 204 -> GET 200 with the record; backend warn/error persisted, info dropped; non-admin GET 403 / POST 204. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
f31e57b4ae |
feat: explainable activity log — reasons, subscriber names, snapshot gaps
The live activity feed flagged anomalies with no explanation and showed opaque session keys. Make events self-describing and clickable. - Clickable feed rows → read-only event-detail modal: humanized fields, entry/exit snapshots, and signed-chain provenance collapsed behind an audit disclosure (operator sees the story, auditor expands for crypto). - Localized reason codes (backend i18n): the signed ledger now carries a stable REASON_CODE + params (+ English fallback) instead of free-text English. The UI translates via reason.<code> catalogs in sq/en, so an Albanian operator reads Albanian — from the same immutable event. Adding a language is a catalog change, no re-signing. (@parking/shared REASON_CODES, reasonPayload; entry/exit/subscription flows emit codes.) - Subscriber-name resolution: a SUBSESS-… occurrence now shows the subscription holder's name (fallback "Abonent"/"Subscriber"). Resolved read-time server-side (events API + WS push) as a non-signed subscriberLabel; cached with invalidation on subscription edit/delete. - Failed-snapshot visibility: a camera that was attempted but unreachable now shows a "⚠ camera unreachable" tile instead of a silent gap. The snapshots API returns failures[] from telemetry, filtered so a recovered capture shows no stale warning. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
040c0ff4ca |
feat: tabbed setup, user metadata, light theme, scoped shift history
Consolidate the config screens under a single /setup hub with permission- gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing the top nav to Booth·Shift·Setup; old top-level paths redirect. Users: add optional profile metadata (full name, phone, email, address) on create/edit. Theme: a light palette saved to the user's profile (users.theme), toggled in the header beside the language switch and applied on load like the language preference. Both ride on a single additive migration (0008). Shift history: a new GET /api/shifts folds the signed shift_z_report chain into completed shifts, SCOPED server-side — operators see only their own; holders of shift:cash see all with an operator + date-range filter. Surfaced as the Shifts tab; an operator cannot read another operator's takings (param spoofing is ignored). These three features share the router, api client and i18n catalogs, so they land together. Verified live: theme persists across reload, metadata round- trips to the DB, and shift scoping holds (operator self-only, admin all+filter). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
ef0ecadff9 |
fix(auth): block privilege escalation via role/user management
The dynamic-RBAC management routes are themselves grantable (role:* and user:*), so a non-admin holding them could self-escalate: edit their own role to add a permission they lack, mint a privileged role, assign someone the admin role, or reset/delete a more-privileged account. Found by the commit security review (2× HIGH). Fix — enforce the RBAC invariant "you cannot grant beyond yourself": - roles.ts: role:create/update reject any permission not held by the caller (escalates()). An admin holds the full set, so it stays unrestricted. - users.ts: user:create/update reject assigning a role whose permissions exceed the caller's; update/password-reset/delete reject acting on a user whose current role exceeds the caller's (exceedsCaller()). The existing no-lockout + builtin-admin protections are unchanged. Verified: 10-assertion inject test — manager (role:* + user:* but no tariff:update, not admin) gets 403 on self-grant, minting a privileged role, assigning/resetting/deleting an admin; admin stays unrestricted; the manager can still create peers + in-scope roles (not over-blocked). Full build green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
d0841c8601 |
feat(auth): dynamic RBAC — composable roles + resource×CRUD permissions
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.
@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).
DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).
auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.
Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).
Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).
Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
d71ba82999 |
feat(booth): payment receipt / exit voucher — transparency slip + CP852 fixes
After a completed payment the customer always gets a transparency record:
entry time, payment time, duration parked, amount + tender. One shared
ESC/POS renderer (renderReceipt + ReceiptData in @parking/devices), two
modes: VOUCHER = those figures PLUS the scannable Code128 barcode and an
emphasised walk-back-grace line, so the one slip both proves payment and
self-exits at a distant exit reader (replaced the old barcode-only voucher);
STANDALONE = detail-only, auto-printed at payment when no voucher is issued.
Figures fold from the SIGNED ledger (latest payment event); printed on the
booth printer (failover to dispenser). Best-effort: a printer fault never
blocks the exit that already happened — the modal shows a note and offers
"Reprint receipt".
Server: booth-print.ts printPaymentReceipt() + receiptFigures(); routes
POST /api/voucher (voucher) + new POST /api/receipt (standalone/reprint).
Both ESC/POS drivers gained printReceipt(). Web: BoothPayModal auto-prints
after a non-voucher payment + reprint button; api.ts printReceipt().
CP852 fixes found on a real printout: (1) uppercase Ë was mapped to 0xEB
(that's ű) — correct byte is 0xD3; (2) Intl.NumberFormat injects a NO-BREAK
SPACE (U+00A0/U+202F) that isn't in CP852 and printed as "?" — line() now
normalises it to a plain space ("1000 Lekë"); (3) grace line wrapped
mid-word — split into two short lines.
Full build green; both receipt modes render-verified; routes live.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
cf1ff5676d |
feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.
computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.
Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
c9a2ef81a9 |
fix(tariff): forbid backdated effectiveFrom — versioning was retroactive
Version selection is "latest tariff_version with effectiveFrom <= entry time", but the publish handler accepted ANY effectiveFrom (defaulting to now). So an admin could publish a version with a backdated effectiveFrom and silently reprice sessions that had already entered — the retroactive rewrite the versioning exists to prevent. Pricing itself was sound (quote resolves by entry time; payment records tariffVersionId, freezing completed sessions); the leak was the publish side only. Reject effectiveFrom earlier than now (60s skew tolerance); future-dated (scheduling a price change) stays allowed; bad ISO -> 400. Combined with entry-time selection this is structural: once a car has entered, no later publish can reprice it. Did not pin tariffVersionId onto vehicle_entry (not needed). Verified 5/5 via inject against a copy of the live DB. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |