33 Commits

Author SHA1 Message Date
julian 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
2026-06-19 10:57:17 +02:00
julian 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
2026-06-19 10:09:18 +02:00
julian 8444bf34c3 feat(web): pop-out modal forms for setup/subscriptions/roles
Add a reusable ui/Modal (Radix Dialog + terminal chrome) and move the
add/edit forms in the Devices setup, Subscriptions and Roles screens into it,
leaving each list in the page behind the modal. The Devices wizard's per-
category device form is also fully translated (setup.* i18n keys).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:08:33 +02:00
julian 808fb26ab6 feat(web): UI component layer + dark-theme reskin
The TRM tokens were good but every screen hand-rolled inputs and buttons as
bare outlines on near-black panels, so fields, cards and buttons were
visually indistinguishable. Add a component layer (.input/.select/.textarea
as recessed slots, .btn family with a FILLED primary, .card scaffolding) and
adopt it across the booth/shift/login/tariff/site screens — several of which
were still light-theme inline styles dropped on a dark background.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:07:39 +02:00
julian 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
2026-06-19 01:27:02 +02:00
julian 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
2026-06-19 01:19:28 +02:00
julian 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
2026-06-18 20:46:38 +02:00
julian 9c9f777784 docs(wiki): record the two configured printers (Cashino at lane, Rongta at booth)
The rongta-printer "Deployment" section still described a single
2026-06-14 unit. The live site now runs two: entry-dispenser 10.0.10.9
(Cashino, `cashino` ping-only driver) and booth-receipt 10.0.10.10
(Rongta, full status-page monitoring). Follow-up to 3e6773a.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:05:40 +02:00
julian 486f8deae6 fix(shift): update Z-report title to Albanian translation 2026-06-18 20:04:13 +02:00
julian 3e6773a6d5 fix(devices): Cashino printer — ping-only driver (no false status) + Albanian role wording
The Cashino 80mm printer reported wrong status: it ran on the `rongta`
driver, whose readStatus() scrapes the Rongta board's /prn_stat.htm status
page — which the Cashino does not serve — yielding a bogus degraded/page-
error verdict while the printer was online and printing fine. Root cause:
the Cashino is an ESC/POS PRINT clone with no trustworthy STATUS mechanism.

Fix: extract the shared ESC/POS rendering + transport (renderTicket/
renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/
qrCode) from printer-rongta into drivers/printer-escpos.ts, and add a
dedicated `cashino` driver that reuses that print path but is deliberately
NOT MonitorableDevice (no readStatus). isMonitorable() is then false, so the
device monitor falls back to healthCheck() — a plain TCP reachability ping:
reachable -> ready, unreachable -> offline, never a guessed paper/cover
state it cannot sense. Rongta driver unchanged (still scrapes its page,
still monitorable). Register + re-export cashinoDriver.

Verified at runtime (cashino registered, isMonitorable=false, no readStatus,
healthCheck->offline on unreachable) and live: /api/devices/status shows both
printers ready (lane via ping, booth via page). The live entry-dispenser at
10.0.10.9 was switched rongta->cashino in the operator DB (backed up).

Also fix the Albanian device-role chip wording, which read wrong as a
"{category} {role}" label: access mixed "i përzier" -> "hyrje/dalje"
(it means a barrier spanning both directions); printer lane "korsia" ->
"në korsi"; booth "kabina" -> "në kabinë". English tidied to match
(mixed->entry/exit, lane->at lane, booth->at booth).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:00:42 +02:00
julian 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
2026-06-18 20:00:13 +02:00
julian 91cc79b14e feat(web): adopt TRM design-system tokens (tokens only)
Linked Claude Design project "TRM — Tracking & Race Management" is a
race-timing kit, not a parking design. Adopted its TOKENS only — no TRM
components. Aligned the existing term-* accents onto TRM's exact night/
semantic values (surfaces → night scale; amber→#f2a516, green→#2e8c4a,
red→#e8412b flag, cyan→#2563c8 blue) so the whole booth UI shifts palette
with zero component edits. Exposed TRM's full vocabulary (night/ink/paper
scales, flag/amber/green/blue, viz-1..8, 4px spacing, type scale, square
radii, sharp offset shadows) as Tailwind v4 utilities for new work.

Offline appliance: dropped TRM's Google-Fonts @import (no runtime network);
Goldplay display face not self-hosted yet — falls back to a sans stack.

Web build green; login renders on the new palette.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 19:46:56 +02:00
julian dfa76346d6 feat(tariff): complete the progressive ladder — require open-ended last block, hours-based composer
The stepped-block engine already does "first N hrs x X, next N hrs x Y, ...,
24h cap" (ordered blocks, per-block rate, rolling-24h cap). No new axis; this
completes the model and removes its footgun.

- validateTariffStructure (shared) now REQUIRES the last block to be open-ended
  (uptoMin: null). A bounded final block silently inherited its own rate past
  its bound (a hidden, never-stated price — e.g. the live ALL tariff billed
  hour 4+ at the 3rd-hour rate). rateAt() still prices legacy bounded-tail
  versions; validation is publish-only, so published immutable versions are
  unaffected (no migration).
- TariffComposer edits bands as a DURATION in hours ("first 2 hours, then next
  3 hours"), accumulated into the engine's cumulative uptoMin (minutes) on
  submit. The last row is a pinned, non-removable "thereafter (open-ended)"
  band, so a published card always satisfies the open-ended-last rule.
  blocksToForm round-trips stored minutes back to band hours (legacy loads).
- i18n: replaced upToMin/egExample with bandDuration/hoursUnit/egHours (sq+en,
  catalog parity green).

Verified: validator rejects bounded-last / accepts open-ended; computeFee
correct at 1/2/3/5/6/24h for a 0-2h@200,2-5h@100,5h+@50 + 1000 cap card. Full
build green. Wiki (tariff.md, log.md) updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:58:47 +02:00
julian 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
2026-06-18 16:58:36 +02:00
julian b8ddda86e7 feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling
Rounds out subscriptions across enrollment, the barrier flow, and the booth.

- RFID credentials enabled with a "Read card" enrollment flow: the operator
  arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
  reader's next read is captured into the form and NOT dispatched to the access
  flow — the OTHER reader keeps serving live entry/exit. Routes:
  /api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
  per-occurrence id (SUBSESS-<short>), not the credential value, with
  permitId in the payload. Direction is decided by the barrier the reader sits
  at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
  admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
  pay/exit modal shows a subscription mode (snapshots + a single audited
  Open-barrier action) to assist a faulty exit reader / missing card;
  reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
  "abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
  verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.

Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:26:48 +02:00
julian bba988c4e8 feat(subscription): QR credentials — operator-choose (QR-only now), auto-generate, multi-month, printed card
Builds out subscription credentials on top of the rename.

- Operator chooses the credential type; only QR is live (RFID shown disabled
  "soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
  globally-unique-checked) — the customer/operator never picks the value.
  RF stays operator-entered (the physical card id). Reader output decided =
  TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
  validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
  (GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
  create (best-effort — never fails the create; returns {printed,printError})
  + reprint via POST /api/subscriptions/:id/print and a "Print code" button.

Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 14:48:38 +02:00
julian 5697137c52 feat(subscription): rename permit→subscription + monthly pricing
The "permit/lejet" feature is really a subscription. Full rename of the
mutable master data, plus a recurring monthly price.

- DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions,
  permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id.
- Pricing: per-subscription priceMinor + period(monthly) + currency, with a
  site default (site_config.subscription_monthly_price_minor) pre-filling the form.
- Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts
  (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en).
- The signed ledger `permitId` payload is intentionally kept — immutable
  hash-chained history; renaming it would break verification of past events.

Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed
payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber).

Also carries the device-footer UI surface (api DeviceStatus, router mount,
i18n devices) due to shared-file overlap with the preceding footer commit.

Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions
preserved). Live DB migrated. Full monorepo builds clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:15:04 +02:00
julian ca8c7f2fa2 fix(exit): stuck active session — paid ticket with no vehicle_exit
A paid car that left via a manual barrier re-open kept no vehicle_exit, so
activeSessions() saw it as permanently open and the grace-expiry eviction
(which only ran for exited sessions) never fired — it lingered forever
(ticket T-397815c0).

- reopenBarrier() now signs a vehicle_exit (source:manual) when the session
  is still open, closing it; still no second exit when already exited
  (phantom re-close — no double-count).
- activeSessions() ages out a PAID open session past grace even with no exit
  (unpaid open sessions never age out — a car owing money stays). Pure
  display filter; the signed log is untouched.

Verified both fixes + chain integrity on a fresh DB. A one-off corrective
vehicle_exit was appended to the live ledger to clear T-397815c0.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:14:45 +02:00
julian f87e4c0d6b feat(devices): live device-status footer across all categories
Generalise printer-only monitoring to every configured device. New
DeviceMonitor polls all enabled devices each tick (default 8s): printers
via rich readStatus(), relays/readers/cameras via the generic healthCheck()
reachability probe, flattened to one traffic-light (ready/degraded/offline)
+ detail, deduped (emit on change only), fail-toward-offline.

- device-status bus event + GET /api/devices/status snapshot.
- Pushed over the existing /api/ws (hello carries the initial set;
  device-status frame per change).
- Web: live-store devices map, WS handler, DeviceFooter chip-per-device
  (role label not vendor; click a degraded/offline chip for an issues panel).

Verified roleKind resolution + change-only emit on a fresh DB.

Note: the footer's UI surface (api type, router mount, i18n devices) rides
in the subsequent subscription commit due to shared-file overlap.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:14:36 +02:00
julian 4e2e4feedb feat(shift): site-wide single-open shift + booth money-path gate
A shift becomes a SITE-WIDE accountability period — at most one open at a
time — so every taking is unambiguously attributed to one operator. Login
stays decoupled from shifts (an operator can log in off-shift to review).

Backend:
- ShiftService.currentOpenShift()/requireOpenShift(); open() refuses when ANY
  shift is open and throws ShiftAlreadyOpenError{heldBy} (self vs. other).
- requireShift preHandler gates /api/pay, /api/exit, /api/voucher,
  /api/barrier/reopen → 409 {code:"no_shift"}; read-only lookups stay open.
- GET /api/shift/current returns site-wide {open:{startedAt,operator},isMine}.
- GET /api/events?since=<iso> for per-shift log scoping (db: re-export gte).

Frontend:
- Header shift button: open / close-mine / disabled-when-another-holds-it.
- Pay/exit modal gate banner (one-click open; "held by X" when another's);
  pay/exit/voucher disabled until this operator's shift is open.
- Active-Sessions barrier re-open gated the same way.
- Live feed scoped to the open shift's window; shared useShift() Query
  invalidated over the WS on shift_open/shift_z_report/cash_movement.
- sq/en strings for the control + gate.

Wiki: shift.md (site-wide single-open + gate; superseded per-operator note),
booth-console.md (header control + gate), log entry.

Verified: site-wide invariant + heldBy + handover + chain integrity on a
fresh migrated DB (11/11); db/server/web build clean.
2026-06-18 12:13:17 +02:00
julian 48660d3ec8 docs(wiki): reconcile with session — booth console, i18n, live WS
File concept pages for the operator-UI architecture ([[booth-console]]: stack,
/api/ws live feed, anti-CSWSH) and [[i18n]] (per-user server-stored language;
resolves a dangling code-comment link). Qualify the stale 'plain React' note on
react-vite-spa. Backfill log entries for the live WebSocket, frontend foundation,
and i18n builds (which had none), plus a reconciliation lint entry. Catalog
booth-exit-flow + the two new pages in index; fix the concept count (27→41).
2026-06-18 11:50:58 +02:00
julian 14c83e182a feat(web): i18n with react-i18next — Albanian default, English second
Add react-i18next with two key-parity-checked catalogs (sq default/fallback, en).
Active language driven by the logged-in user's stored preference (applied after
/me resolves); SQ/EN toggle in the header persists via PUT /api/auth/language.
Translate the booth (screen, pay/exit modal, active sessions, snapshots, status),
Login, ShiftControl, SiteSettings, PermitManager, TariffComposer.

SetupWizard deferred (its content is server-provided; needs backend catalog i18n).
2026-06-18 11:47:39 +02:00
julian 445bca0bf6 feat(auth): per-user UI language preference (sq default, en)
Add users.language ('sq'|'en', default 'sq'; migration 0003). Returned from
/api/auth/login and /api/auth/me (read from the DB, not the JWT — so changing it
needs no re-login). New PUT /api/auth/language for self-service. Loaded on login
and restored from any booth. Printed tickets stay Albanian (customer-facing).
2026-06-18 11:47:30 +02:00
julian 062feeae2f docs(wiki): update index + log for booth console, drawer, and tariff research
Catalog the new concept/source pages and append chronological log entries for the
tariff research, live WebSocket, booth pay/exit, active sessions, and shift drawer
work.
2026-06-18 11:05:43 +02:00
julian 50a3095ef3 feat(shift): cash drawer balance carried across shifts + admin cash movements
New signed cash_movement event (admin-only): load/remove drawer float, signed +
attributed. ShiftService folds cash payments + movements by time into a drawer
balance; shift open auto-inherits the prior shift's expected closing drawer as its
opening float; the Z-report reports opening/taken/added/removed/expected (= next
shift's opening float). Card payments excluded (settle to bank). Routes: POST
/api/cash-movement, drawer in GET /api/shift/current. ShiftControl shows the live
drawer + admin load/remove form + Z-report drawer block. Wiki: shift.md.
2026-06-18 11:05:36 +02:00
julian eb3dc18e67 feat(booth): active sessions panel + audited barrier re-open
Active Sessions panel lists sessions that are open OR exited-but-within-grace
(barrier state is unconfirmed, so a paid car is presumed possibly-present until
grace expires). Row click → pay/exit modal; 'Open barrier' (paid sessions only —
no payment, no button) fires a human-intervention re-pulse signed as an attributed
anomaly, never a second vehicle_exit. Wiki: booth-exit-flow.md.

Note: the backend (PayStation.activeSessions, ExitFlow.reopenBarrier, routes,
api.ts) landed with the prior commit's shared files.
2026-06-18 11:05:26 +02:00
julian 06dab1e790 feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots
Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth
reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and
signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket
id barcode; site_config.exit_voucher_default (migration 0002) drives the default.
Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher.

Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'),
SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
2026-06-18 11:05:10 +02:00
julian 9956488fd5 chore: removed graphify 2026-06-18 11:03:45 +02:00
julian 49df2015c8 feat(web): frontend foundation — Tailwind terminal theme, Query, Router, Zustand + live booth screen
Add tailwindcss (Bloomberg-terminal theme in index.css), @tanstack/react-query +
react-router, zustand, and Radix primitives. Router with role-guarded routes;
QueryClient wrapping the existing apiFetch; a small Zustand live store fed by a
/api/ws client that invalidates Query caches. Booth screen: live occupancy gauge
+ streaming entry/exit/payment feed. Vite proxies the WS upgrade.

Note: BoothScreen references the pay/exit modal + active-sessions panel added in
following commits; final HEAD builds.
2026-06-18 11:00:42 +02:00
julian c2f06a5d2a feat(server): live booth WebSocket feed (/api/ws)
Add @fastify/websocket. EventLog fires an onAppended callback after each durable
append; device-events gains a ledger channel (emitLedger). /api/ws fans out
ledger + occupancy + printer-status to authenticated booth clients. Origin
allowlist (WS_ALLOWED_ORIGINS) replaces CSRF for the handshake (anti-CSWSH).

Note: server.ts also reflects later booth route wiring; the final HEAD builds.
2026-06-18 11:00:22 +02:00
julian 58d8f06ba0 docs(wiki): tariff research — legacy ParkSQL2017 schema, time-tiers & validation/sponsorship design
Ingest the predecessor SQL Server schema (raw + source summary) and file design
pages for time-of-day/seasonal tariff tiers and merchant validation/postpaid
sponsorship. Cross-link tariff.md and validation-discounts.md. No code.
2026-06-18 10:59:21 +02:00
julian 71aaad03b9 exit: open free within entry-grace, no pay-station visit
A quick in-and-out the tariff prices at 0 (stay <= gracePeriodEntryMin) now
exits at the gate instead of being refused as "not paid". exit-flow resolves
the active site tariff (same logic as the pay station) and, if computeFee for
entry->now is 0, mints a signed $0 payment event (reason: free entry-grace)
then signs the vehicle_exit and opens. The $0 payment keeps the append-only
ledger invariant that an exit is covered by a payment, so a grace exit stays
attributable in the audit trail. A real payment still takes precedence (the
walk-back grace path is untouched). Sign+open extracted to #signExitAndOpen,
shared by both paths.
2026-06-17 12:17:28 +02:00
julian 727c62da90 ticket: site metadata header + scannable Albanian ticket; widen barcode
- site_config gains optional park identity (park_name, operator_name, nius,
  address, phone, email); additive Drizzle migration 0001. GET/PUT
  /api/site-config read/write the full config (PUT partial patch, admin only);
  SiteSettings + SetupWizard expose the fields.
- renderTicket() prints an Albanian header sourced from site_config, the
  all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits,
  and a lost-ticket footer. CP852 codepage so ë/ç render.
- Widen the Code128 module width 2->3 and height 80->100 dots so the
  short-range "Simple" QR/barcode reader decodes reliably (was barely reading
  at module width 2 on the 80mm head).

See wiki/concepts/site-metadata.md and ticket-encoding.md.
2026-06-17 12:17:21 +02:00
127 changed files with 18429 additions and 1712 deletions
+1 -20
View File
@@ -1,24 +1,5 @@
{ {
"hooks": { "hooks": {
"PreToolUse": [ "PreToolUse": []
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
}
]
}
]
} }
} }
+1
View File
@@ -24,3 +24,4 @@ dist/
# Graphify knowledge-graph output (dev tool; generated, not committed) # Graphify knowledge-graph output (dev tool; generated, not committed)
graphify-out/ graphify-out/
parking.sqlite*.bak-*
-10
View File
@@ -86,13 +86,3 @@ For the full reasoning behind each, follow the links from `wiki/overview.md`.
- TypeScript throughout. Match the style of surrounding code. - TypeScript throughout. Match the style of surrounding code.
- Confirm before destructive or outward-facing actions. Commit/push only when asked. - Confirm before destructive or outward-facing actions. Commit/push only when asked.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+11
View File
@@ -8,6 +8,13 @@
# Generate one with: openssl rand -hex 32 # Generate one with: openssl rand -hex 32
JWT_SECRET= JWT_SECRET=
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
# Generate with: openssl rand -hex 32
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
# dev, but set a dedicated key before production. Events store the key that signed
# them (keyId), so verifyChain still validates a chain that spans a key change.
EVENT_SIGNING_KEY=
# Optional ---------------------------------------------------------------- # Optional ----------------------------------------------------------------
# PORT=3000 # PORT=3000
# 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.
@@ -18,3 +25,7 @@ JWT_SECRET=
# 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
# ADMIN_PASS= # ADMIN_PASS=
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
WS_ALLOWED_ORIGINS=http://localhost:5173
+1
View File
@@ -16,6 +16,7 @@
"@fastify/cors": "11.2.0", "@fastify/cors": "11.2.0",
"@fastify/jwt": "10.1.0", "@fastify/jwt": "10.1.0",
"@fastify/static": "9.1.3", "@fastify/static": "9.1.3",
"@fastify/websocket": "^11.2.0",
"@parking/db": "workspace:*", "@parking/db": "workspace:*",
"@parking/devices": "workspace:*", "@parking/devices": "workspace:*",
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
+2 -2
View File
@@ -62,14 +62,14 @@ if (existing && process.env.FORCE !== "1") {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
if (existing) { if (existing) {
await db.update(users).set({ passwordHash, role: "admin" }).where(eq(users.id, existing.id)); await db.update(users).set({ passwordHash, roleId: "admin" }).where(eq(users.id, existing.id));
console.log(`reset password for admin "${username}"`); console.log(`reset password for admin "${username}"`);
} else { } else {
await db.insert(users).values({ await db.insert(users).values({
id: randomUUID(), id: randomUUID(),
username, username,
passwordHash, passwordHash,
role: "admin", roleId: "admin",
}); });
console.log(`created admin "${username}"`); console.log(`created admin "${username}"`);
} }
+81 -8
View File
@@ -1,16 +1,22 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify"; import type { FastifyReply, FastifyRequest } from "fastify";
import type { Role } from "@parking/shared"; import { eq, rolePermissions, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
// Local JWT auth helpers — fully local, no external identity provider // Local JWT auth helpers — fully local, no external identity provider
// (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it); // (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it);
// a separate readable CSRF cookie + matching header defends mutations // a separate readable CSRF cookie + matching header defends mutations
// (double-submit). See wiki/entities/local-jwt-auth.md. // (double-submit). See wiki/entities/local-jwt-auth.md.
//
// Authorization is DYNAMIC RBAC: the token carries the user's `roleId`, and each
// guarded route resolves that role's PERMISSION SET (cached in memory) and checks
// the permission it requires. Editing a role takes effect on the next request —
// no re-login, no token bloat, no stale perms. See @parking/shared PERMISSIONS.
declare module "@fastify/jwt" { declare module "@fastify/jwt" {
interface FastifyJWT { interface FastifyJWT {
payload: { sub: string; username: string; role: Role; csrf: string }; payload: { sub: string; username: string; roleId: string; csrf: string };
user: { sub: string; username: string; role: Role; csrf: string }; user: { sub: string; username: string; roleId: string; csrf: string };
} }
} }
@@ -96,17 +102,84 @@ function assertCsrf(req: FastifyRequest): void {
} }
} }
// --- Permission resolution + cache -------------------------------------------
// A role's permission set is read from `role_permissions` and cached in memory.
// SQLite is single-writer/single-process here, so a module-level Map is a correct
// cache: every role / role-permission mutation calls bumpPermsCache() to clear it,
// and the next request re-reads. The built-in `admin` role always resolves to the
// FULL permission set in code (never trusts the DB rows for it), so administration
// can't be accidentally narrowed.
const ADMIN_PERMS: ReadonlySet<Permission> = new Set(PERMISSIONS);
const permsCache = new Map<string, ReadonlySet<Permission>>();
// The DB handle the permission resolver reads from. Set ONCE at startup via
// initAuth() so route guards don't each have to thread `db` (several route
// modules only receive a monitor/service, not the db). Single-process server.
let authDb: Db | null = null;
/** Wire the permission resolver to the app's DB. Call once in buildServer(). */
export function initAuth(db: Db): void {
authDb = db;
permsCache.clear();
}
/** Clear the permission cache. Call after ANY write to roles / role_permissions
* (or a user's roleId) so the change takes effect on the next request. */
export function bumpPermsCache(): void {
permsCache.clear();
}
/** The permission set for a role id, cached. `admin` is always the full set. */
export function permissionsFor(roleId: string): ReadonlySet<Permission> {
if (roleId === ADMIN_ROLE_ID) return ADMIN_PERMS;
const hit = permsCache.get(roleId);
if (hit) return hit;
if (!authDb) throw new Error("auth not initialised (call initAuth)");
const rows = authDb
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.where(eq(rolePermissions.roleId, roleId))
.all();
const set = new Set(rows.map((r) => r.permission as Permission));
permsCache.set(roleId, set);
return set;
}
/** True if the role grants every listed permission. */
export function roleHasPermissions(
roleId: string,
required: readonly Permission[],
): boolean {
const granted = permissionsFor(roleId);
return required.every((p) => granted.has(p));
}
/** /**
* preHandler role guard. Verifies the JWT (from the HttpOnly cookie), enforces * preHandler permission guard. Verifies the JWT (from the HttpOnly cookie),
* CSRF on mutations, then checks the role. Authorization is a simple per-route * enforces CSRF on mutations, then requires the user's role to grant ALL of the
* role check — no Casbin/RBAC engine needed at this scale. * listed permissions. Authorization is a per-route permission check against the
* dynamic, admin-composed role grid — no Casbin/RBAC engine needed at this scale.
*/ */
export function requireRole(...allowed: Role[]) { export function requirePermission(...required: Permission[]) {
return async (req: FastifyRequest, _reply: FastifyReply) => { return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify(); // reads the token cookie (configured in server.ts) await req.jwtVerify(); // reads the token cookie (configured in server.ts)
assertCsrf(req); assertCsrf(req);
if (!req.user || !allowed.includes(req.user.role)) { if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 }); throw Object.assign(new Error("forbidden"), { statusCode: 403 });
} }
}; };
} }
/**
* preHandler that requires a valid signed-in session but NO specific permission —
* for "about me" routes (/me, change own language) every authenticated user may
* call regardless of role. Still enforces CSRF on mutations.
*/
export async function requireAuth(
req: FastifyRequest,
_reply: FastifyReply,
): Promise<void> {
await req.jwtVerify();
assertCsrf(req);
}
+156
View File
@@ -0,0 +1,156 @@
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
import {
printWithFailover,
registry,
type PrinterDevice,
type PrinterInstance,
type ReceiptData,
type TicketHeader,
} from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js";
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
// from the exit, the customer pays at the booth and walks a printed voucher to the
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
//
// This mirrors the entry flow's printer selection + header build, but prints on the
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
/** Park identity for the voucher header, from site_config (all fields optional). */
function ticketHeader(db: Db): TicketHeader | undefined {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
/** Build live printer instances for failover selection (entry direction covers the
* booth-receipt role too — the booth printer is configured on the entry side). */
function loadPrinters(db: Db): PrinterInstance[] {
const rows = devicesByDirection(db, "printer", "entry");
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/** The receipt figures for a paid session, folded from the SIGNED ledger
* (authoritative). Null if there's no entry or no payment for this id — the
* caller should have validated paid + open before printing. */
function receiptFigures(
db: Db,
ticketId: string,
): Omit<ReceiptData, "voucher" | "header"> | null {
const rows = db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, ticketId))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
// The LATEST payment is the one we receipt (an overstay top-up re-pays).
let payment: (typeof rows)[number] | undefined;
for (const r of rows) if (r.type === "payment") payment = r;
if (!payment) return null;
const p = (payment.payload ?? {}) as {
amountMinor?: number;
currency?: string;
tender?: "cash" | "card";
graceExitMin?: number;
};
return {
ticketId,
enteredAt: entry.occurredAt,
paidAt: payment.occurredAt,
amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0,
currency: p.currency ?? "ALL",
tender: p.tender === "card" ? "card" : "cash",
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
};
}
/**
* Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over
* to the entry dispenser). The receipt is the customer's transparency record:
* entry time, payment time, duration, amount + tender — folded from the signed
* ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the
* walk-back grace, so the one slip both proves payment AND self-exits at a
* distant exit reader (this replaces the old barcode-only voucher). In standalone
* mode (`voucher:false`) it is detail-only, printed at payment when the booth is
* at the exit. Returns the id of the printer that printed it.
* Throws NoPrinterAvailableError if none can; throws if the session isn't payable.
*/
export async function printPaymentReceipt(
db: Db,
ticketId: string,
opts: { voucher: boolean },
logger: FastifyBaseLogger,
): Promise<string> {
const figures = receiptFigures(db, ticketId);
if (!figures) {
throw new Error(`no paid session to receipt for ${ticketId}`);
}
const printers = loadPrinters(db);
const data: ReceiptData = {
...figures,
voucher: opts.voucher,
header: ticketHeader(db),
};
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReceipt(data),
);
logger.info(
`${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`,
);
return printedBy;
}
/**
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
* a scannable QR of the credential code + holder/validity, so the operator can hand
* it to the customer. Used on subscription creation and on a "reprint" action.
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
*/
export async function printSubscriptionCard(
db: Db,
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const data = {
code: card.code,
holderName: card.holderName ?? null,
validFrom: card.validFrom ?? null,
validTo: card.validTo ?? null,
header: ticketHeader(db),
};
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printSubscriptionCard(data),
);
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
return printedBy;
}
+89
View File
@@ -0,0 +1,89 @@
// Credential capture ("enroll a card"): lets an operator present a physical RFID
// card/chip (or a QR) to ONE chosen reader and have its value captured for a
// subscription credential, instead of typing it. SINGLE-SHOT + short TTL so the
// chosen reader is only "borrowed" for one read / a few seconds; the OTHER reader is
// never affected and keeps serving the live entry/exit flow.
//
// Flow: arm(deviceId) → the reader route checks tryConsume() on each read; the next
// read from that armed reader is captured (NOT dispatched to the access flow — the
// barrier must not open for a card being enrolled) and capture auto-disarms. The
// booth form polls result() until the value appears (or it times out / is cancelled).
//
// In-memory + single-site single-writer (one booth) → no DB, no cross-process
// concerns. See wiki/entities/subscription.md.
const CAPTURE_TTL_MS = Number(process.env.CAPTURE_TTL_MS ?? 30_000);
export type CaptureState =
| { status: "idle" }
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
| { status: "expired"; deviceId: string };
export class CredentialCapture {
#armedDeviceId: string | null = null;
#expiresAt = 0;
#captured: { deviceId: string; value: string; capturedAt: number } | null = null;
#lastExpiredDeviceId: string | null = null;
/** Arm a single-shot capture on one reader (by its `devices.id`). Replaces any
* prior arming (only one capture at a time). Clears a stale captured/expired
* result so the form starts fresh. */
arm(deviceId: string): { expiresAt: number } {
this.#armedDeviceId = deviceId;
this.#expiresAt = Date.now() + CAPTURE_TTL_MS;
this.#captured = null;
this.#lastExpiredDeviceId = null;
return { expiresAt: this.#expiresAt };
}
/** Cancel any pending arming (operator closed the form / clicked cancel). */
cancel(): void {
this.#armedDeviceId = null;
this.#expiresAt = 0;
}
/**
* Called by the reader route on EVERY read. If this reader is the armed one (and
* not expired), capture the value, disarm, and return true → the caller must NOT
* dispatch this read to the access flow. Otherwise false → dispatch normally.
*/
tryConsume(deviceId: string, value: string): boolean {
if (this.#armedDeviceId == null) return false;
if (Date.now() > this.#expiresAt) {
// Window lapsed before a card was presented — disarm, mark expired.
this.#lastExpiredDeviceId = this.#armedDeviceId;
this.#armedDeviceId = null;
this.#expiresAt = 0;
return false;
}
if (deviceId !== this.#armedDeviceId) return false; // a read from the OTHER reader
if (!value) return false;
this.#captured = { deviceId, value, capturedAt: Date.now() };
this.#armedDeviceId = null; // single-shot
this.#expiresAt = 0;
return true;
}
/** Current state for the booth form's poll. Lazily transitions armed→expired. */
state(): CaptureState {
if (this.#captured) return { status: "captured", ...this.#captured };
if (this.#armedDeviceId != null) {
if (Date.now() > this.#expiresAt) {
this.#lastExpiredDeviceId = this.#armedDeviceId;
this.#armedDeviceId = null;
this.#expiresAt = 0;
return { status: "expired", deviceId: this.#lastExpiredDeviceId };
}
return { status: "armed", deviceId: this.#armedDeviceId, armedAt: this.#expiresAt - CAPTURE_TTL_MS, expiresAt: this.#expiresAt };
}
if (this.#lastExpiredDeviceId) return { status: "expired", deviceId: this.#lastExpiredDeviceId };
return { status: "idle" };
}
/** Clear a consumed/expired result once the form has read it. */
clear(): void {
this.#captured = null;
this.#lastExpiredDeviceId = null;
}
}
+56 -2
View File
@@ -1,5 +1,6 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { PrinterStatus } from "@parking/devices"; import type { PrinterStatus } from "@parking/devices";
import type { LedgerEventRow } from "@parking/db";
// Internal event bus for device-originated events (button presses, etc.). // Internal event bus for device-originated events (button presses, etc.).
// Hardware drivers / inbound device pushes emit here; business logic (entry // Hardware drivers / inbound device pushes emit here; business logic (entry
@@ -16,7 +17,7 @@ export interface DeviceInputEvent {
} }
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader. // A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
// Drives identity-based flows (exit validation, permits, pay-station lookup). `kind` // Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
// mirrors IdentitySource. See parking-session.md. // mirrors IdentitySource. See parking-session.md.
export interface DeviceReadEvent { export interface DeviceReadEvent {
readonly driverId: string; readonly driverId: string;
@@ -34,7 +35,7 @@ export interface DeviceReadEvent {
export interface ReadOutcome { export interface ReadOutcome {
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */ /** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
readonly accepted: boolean; readonly accepted: boolean;
/** Which way it went, when known (permit/exit infer this). */ /** Which way it went, when known (subscription/exit infer this). */
readonly direction?: "entry" | "exit"; readonly direction?: "entry" | "exit";
/** Human-readable reason (for logs / the reader UI), esp. on reject. */ /** Human-readable reason (for logs / the reader UI), esp. on reject. */
readonly reason?: string; readonly reason?: string;
@@ -48,6 +49,33 @@ export interface PrinterStatusEvent {
readonly status: PrinterStatus; readonly status: PrinterStatus;
} }
/**
* The unified live status of ANY configured device — what the booth footer shows.
* Every enabled device is polled: printers via their rich `readStatus()`
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
* reachability probe. `state` is the common traffic-light; `detail` carries the
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
* and wiki/concepts/device-status-monitoring.md.
*/
export interface DeviceStatusEvent {
readonly deviceId: string; // devices id
readonly driverId: string;
readonly category: "access" | "reader" | "camera" | "printer";
/**
* The device's ROLE descriptor for the footer label — NOT the vendor. A
* direction-style token the client localises and pairs with the category, so the
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
* - undetermined: null (chip shows the category alone)
*/
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
readonly state: "ready" | "degraded" | "offline";
readonly detail?: string;
readonly checkedAt: string; // ISO-8601
}
class DeviceEventBus extends EventEmitter { class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void { emitInput(event: DeviceInputEvent): void {
this.emit("input", event); this.emit("input", event);
@@ -74,6 +102,32 @@ class DeviceEventBus extends EventEmitter {
this.on("printer-status", cb); this.on("printer-status", cb);
return () => this.off("printer-status", cb); return () => this.off("printer-status", cb);
} }
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
* (all categories — relays, readers, cameras, printers). Drives the booth
* device-status footer over the WS. */
emitDeviceStatus(event: DeviceStatusEvent): void {
this.emit("device-status", event);
}
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
this.on("device-status", cb);
return () => this.off("device-status", cb);
}
/**
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
* payment, void, …). The payload is the persisted row — business facts only, no
* secrets — so it is safe to fan out to authenticated booth clients over the WS.
* This is a read-side notification ONLY: it never feeds back into append/sign/
* chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts.
*/
emitLedger(event: LedgerEventRow): void {
this.emit("ledger", event);
}
onLedger(cb: (event: LedgerEventRow) => void): () => void {
this.on("ledger", cb);
return () => this.off("ledger", cb);
}
} }
/** Process-wide device event bus. */ /** Process-wide device event bus. */
+156
View File
@@ -0,0 +1,156 @@
import type { FastifyBaseLogger } from "fastify";
import { devices, type Db, type DeviceRow } from "@parking/db";
import { isMonitorable, registry } from "@parking/devices";
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
import { directionOf, relaysOf } from "./device-resolve.js";
// Unified live DEVICE monitor — the source for the booth's device-status footer.
// Every enabled, configured device is probed on an interval, regardless of
// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the
// same capability the PrinterMonitor uses), and a relay/reader/camera via the
// generic healthCheck() reachability probe every Device implements. The result is
// flattened to a common traffic-light (ready | degraded | offline) + a detail
// string, cached per device id, and emitted on the bus ONLY when it changes.
//
// This is device-agnostic (talks to the adapter interfaces, never a driver SDK)
// and read-only — polling a device never drives a relay or mutates the ledger.
// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md.
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
/**
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
* tokens the client localises next to the category:
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
* than one direction; null if it declares none yet
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt)
*/
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) {
case "reader":
case "camera": {
const d = directionOf(db, row); // entry | exit | both
return d;
}
case "access": {
const dirs = new Set(relaysOf(row).map((r) => r.direction));
if (dirs.size === 0) return null;
if (dirs.size > 1) return "mixed";
const only = [...dirs][0]; // entry | exit | both
return only ?? null;
}
case "printer": {
const role = (row.config as { role?: string }).role;
if (role === "booth-receipt") return "booth";
if (role === "entry-dispenser") return "lane";
return null;
}
default:
return null;
}
}
export class DeviceMonitor {
readonly #db: Db;
readonly #log: FastifyBaseLogger;
readonly #pollMs: number;
/** Latest unified status per device id. */
readonly #latest = new Map<string, DeviceStatusEvent>();
#timer: ReturnType<typeof setInterval> | null = null;
#ticking = false;
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS) {
this.#db = db;
this.#log = log;
this.#pollMs = pollMs;
}
/** Begin polling. Idempotent. */
start(): void {
if (this.#timer) return;
void this.#tick(); // immediate first pass so the footer fills without a wait
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
this.#timer.unref?.();
this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`);
}
stop(): void {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
}
/** Current snapshot for the API / a freshly-connected WS client. */
snapshot(): DeviceStatusEvent[] {
return [...this.#latest.values()];
}
async #tick(): Promise<void> {
if (this.#ticking) return; // never overlap polls
this.#ticking = true;
try {
// Re-read the device set each tick so a newly-assigned/removed device is
// picked up without a restart.
const rows = await this.#db.select().from(devices).all();
const enabled = rows.filter((r) => r.enabled);
const present = new Set(enabled.map((r) => r.id));
// Drop devices that are gone/disabled (so the footer doesn't show stale ones).
for (const id of [...this.#latest.keys()]) {
if (!present.has(id)) this.#latest.delete(id);
}
await Promise.all(enabled.map((r) => this.#poll(r)));
} catch (err) {
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
} finally {
this.#ticking = false;
}
}
async #poll(row: DeviceRow): Promise<void> {
const cfg = (row.config ?? {}) as Record<string, unknown>;
const base = {
deviceId: row.id,
driverId: row.driverId,
category: row.category,
roleKind: roleKindOf(this.#db, row),
};
let next: DeviceStatusEvent;
const driver = registry.get(row.driverId);
if (!driver) {
// Configured against a driver that's no longer registered — surface it,
// don't silently hide it.
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
} else {
try {
const device = driver.create(cfg as never);
// Printers expose richer paper/cover/cutter status; everything else uses
// the generic reachability probe. Both flatten to the same traffic-light.
if (isMonitorable(device)) {
const s = await device.readStatus();
next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt };
} else {
const h = await device.healthCheck();
next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() };
}
} catch (err) {
// A probe that throws (build error, timeout) reads as offline — never crash
// the tick, and fail toward "there's a problem" rather than false-healthy.
next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() };
}
}
const prev = this.#latest.get(row.id);
this.#latest.set(row.id, next);
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
this.#log.info(
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${row.id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
);
deviceEvents.emitDeviceStatus(next);
}
}
}
+86 -10
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomInt } from "node:crypto";
import { sessions, type Db, type DeviceRow } from "@parking/db"; import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
import { import {
NoPrinterAvailableError, NoPrinterAvailableError,
printWithFailover, printWithFailover,
@@ -8,7 +8,9 @@ import {
type PrinterDevice, type PrinterDevice,
type PrinterInstance, type PrinterInstance,
type TicketData, type TicketData,
type TicketHeader,
} from "@parking/devices"; } from "@parking/devices";
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js"; import type { DeviceInputEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js"; import { getOccupancy } from "./occupancy.js";
@@ -72,15 +74,19 @@ export class EntryFlow {
async #runEntry(resolved: ResolvedRelay): Promise<void> { async #runEntry(resolved: ResolvedRelay): Promise<void> {
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry: // CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT // no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
// gated here (their flow ignores site-full; their own maxConcurrent applies), so // gated here (their flow ignores site-full; their own maxConcurrent applies), so
// subscribers aren't locked out. "Full" is a soft policy seam for valet over- // they aren't locked out. "Full" is a soft policy seam for valet over-
// capacity later. See wiki/concepts/capacity-occupancy.md. // capacity later. See wiki/concepts/capacity-occupancy.md.
const occ = getOccupancy(this.#db); const occ = getOccupancy(this.#db);
if (occ.full) { if (occ.full) {
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true }, payload: {
...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }),
entryRefused: true,
full: true,
},
}); });
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`); this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
return; return;
@@ -91,7 +97,7 @@ export class EntryFlow {
const printers = this.#loadPrinters(); const printers = this.#loadPrinters();
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry. // 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, issuedAt }; const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
try { try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) => const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket), d.printTicket(ticket),
@@ -105,19 +111,31 @@ export class EntryFlow {
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
identity: ticketId, identity: ticketId,
payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false }, payload: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false },
}); });
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`); this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
return; return;
} }
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant). // 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
// `category` is FROZEN here (in the signed payload) so the tariff prices and
// later reprices the same way at exit. Today every transient takes the SITE
// default category (operator policy, site_config.default_vehicle_category;
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
// is the future seam — source it from `resolved` then. A V1/no-category tariff
// ignores it; only V2 category cards consult it.
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const category =
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
? cfg.defaultVehicleCategory
: DEFAULT_VEHICLE_CATEGORY;
await this.#log.append({ await this.#log.append({
type: "vehicle_entry", type: "vehicle_entry",
direction: "entry", direction: "entry",
source: "ticket", source: "ticket",
identity: ticketId, identity: ticketId,
payload: { sessionRef: ticketId, ticketPrinted: true }, payload: { sessionRef: ticketId, ticketPrinted: true, category },
occurredAt: issuedAt, occurredAt: issuedAt,
}); });
@@ -182,9 +200,67 @@ export class EntryFlow {
} }
return out; return out;
} }
/** Park identity for the ticket header, from site_config (all fields optional;
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
#ticketHeader(): TicketHeader | undefined {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
} }
/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */ /**
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
*
* Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
* — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so
* collisions are negligible at lot scale; the unique constraints on
* ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual
* entry reject a typo (validateTicketCode) instead of failing as "session not found".
*/
function newTicketId(): string { function newTicketId(): string {
return `T-${randomUUID()}`; let body = "";
for (let i = 0; i < 12; i += 1) body += String(randomInt(10));
return body + luhnCheckDigit(body);
}
/** The Luhn (mod-10) check digit for an all-digit string. */
function luhnCheckDigit(digits: string): string {
let sum = 0;
// Walk right-to-left; the check digit sits at position 0 from the right, so the
// last body digit is an "even" position that gets doubled.
let double = true;
for (let i = digits.length - 1; i >= 0; i -= 1) {
let d = digits.charCodeAt(i) - 48;
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return String((10 - (sum % 10)) % 10);
}
/**
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
* Lets a manual-entry path (operator types the code off the ticket when readers are
* down) reject a typo up front. A scanned/looked-up id that predates this format
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
* never reject an id that already exists in the ledger. See ticket-encoding.md.
*/
export function validateTicketCode(code: string): boolean {
if (!/^\d{13}$/.test(code)) return false;
const body = code.slice(0, 12);
return luhnCheckDigit(body) === code[12];
} }
+56
View File
@@ -0,0 +1,56 @@
import { eq, subscriptions, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
// We resolve them when serializing an event for the API / WS feed — never on the
// signed record itself.
//
// Today: a subscription occurrence's identity is an opaque `SUBSESS-…` key. The human
// who matters is the subscription HOLDER, whose name lives on the subscriptions row
// (mutable master data — NOT signed into the event). We resolve payload.permitId →
// holder_name so the feed reads "Aqif Kopertoni" rather than "SUBSESS-08cd1c52e219".
/** Fallback label when a subscription has no holder name (or was deleted). Matches the
* i18n key `booth.subscriberFallback`; kept here in English for the API/log layer. */
const SUBSCRIBER_FALLBACK = "Subscriber";
/** Tiny holder-name cache. Single-writer SQLite; a subscription rename is rare and the
* feed is not security-sensitive, so a short-lived cache is plenty. Invalidate by
* process lifetime — restart picks up renames; for live correctness the lookup is
* cheap enough that we just read per miss. */
const holderCache = new Map<string, string | null>();
/** Resolve a subscription id to its holder name (or null), memoized. */
function holderName(db: Db, permitId: string): string | null {
if (holderCache.has(permitId)) return holderCache.get(permitId) ?? null;
const row = db
.select({ holderName: subscriptions.holderName })
.from(subscriptions)
.where(eq(subscriptions.id, permitId))
.get();
const name = row?.holderName?.trim() || null;
holderCache.set(permitId, name);
return name;
}
/** Drop a cached holder name (call after a subscription create/update/delete). */
export function invalidateHolder(permitId: string): void {
holderCache.delete(permitId);
}
/** Clear the whole holder cache (call on bulk subscription changes). */
export function clearHolderCache(): void {
holderCache.clear();
}
/**
* Attach read-time display fields to a raw ledger row before it goes to a client.
* Currently: `subscriberLabel` for subscription occurrences. Idempotent and cheap;
* non-subscription events pass through unchanged (no `subscriberLabel`).
*/
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
if (!permitId) return event;
return { ...event, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
}
+46 -4
View File
@@ -81,15 +81,34 @@ export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex"); return createHash("sha256").update(canonical, "utf8").digest("hex");
} }
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
* Returns undefined when the key that signed an event is not available. */
export type SignerResolver = (keyId: string) => Signer | undefined;
export class EventLog { export class EventLog {
readonly #db: Db; readonly #db: Db;
readonly #signer: Signer; readonly #signer: Signer;
/** Picks the verifying signer per event keyId; lets a chain span key rotations
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
* callers that don't pass one (single-key chains, tests). */
readonly #resolveVerifier: SignerResolver;
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
* to fan the event out to live booth clients (WS). It is best-effort and must
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
readonly #onAppended?: (row: LedgerEventRow) => void;
/** Serialize appends: each waits for the previous to finish. */ /** Serialize appends: each waits for the previous to finish. */
#tail: Promise<unknown> = Promise.resolve(); #tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer) { constructor(
db: Db,
signer: Signer,
resolveVerifier?: SignerResolver,
onAppended?: (row: LedgerEventRow) => void,
) {
this.#db = db; this.#db = db;
this.#signer = signer; this.#signer = signer;
this.#resolveVerifier = resolveVerifier ?? (() => signer);
this.#onAppended = onAppended;
} }
/** Append one event to the chain. Returns the persisted row. Serialized. */ /** Append one event to the chain. Returns the persisted row. Serialized. */
@@ -97,7 +116,16 @@ export class EventLog {
const run = this.#tail.then(() => this.#appendNow(input)); const run = this.#tail.then(() => this.#appendNow(input));
// Keep the chain going even if one append rejects (don't wedge the lock). // Keep the chain going even if one append rejects (don't wedge the lock).
this.#tail = run.catch(() => undefined); this.#tail = run.catch(() => undefined);
return run; // Read-side notification, AFTER the row is durably written. Wrapped so a
// failing sink can never reject the append or break the chain lock above.
return run.then((row) => {
try {
this.#onAppended?.(row);
} catch {
// best-effort fan-out only — swallow.
}
return row;
});
} }
#appendNow(input: AppendInput): LedgerEventRow { #appendNow(input: AppendInput): LedgerEventRow {
@@ -146,7 +174,13 @@ export class EventLog {
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the * Walk the chain oldest→newest and recompute hashes + signatures. Returns the
* first detected break, or { ok: true }. This is what reconciliation and an * first detected break, or { ok: true }. This is what reconciliation and an
* integrity self-check call. Catches: tampered content, reordering, a deleted * integrity self-check call. Catches: tampered content, reordering, a deleted
* row (index gap), and a forged/invalid signature. * row (index gap), a forged/invalid signature, and an event signed under a key
* that is no longer configured.
*
* Each row is verified against the signer for ITS OWN `keyId`, not the current
* append signer — so a chain that spans a key rotation (e.g. early events under
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
* verifies end to end. See signer.buildVerifier.
*/ */
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } { verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
@@ -159,8 +193,16 @@ export class EventLog {
if ((row.prevHash ?? null) !== prevHash) { if ((row.prevHash ?? null) !== prevHash) {
return { ok: false, index: row.index, reason: "prevHash does not match chain" }; return { ok: false, index: row.index, reason: "prevHash does not match chain" };
} }
const verifier = this.#resolveVerifier(row.keyId);
if (!verifier) {
return {
ok: false,
index: row.index,
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
};
}
const canonical = canonicalize(row); const canonical = canonicalize(row);
if (!this.#signer.verify(canonical, row.signature)) { if (!verifier.verify(canonical, row.signature)) {
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" }; return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
} }
prevHash = hashEvent(canonical); prevHash = hashEvent(canonical);
+326 -26
View File
@@ -1,8 +1,8 @@
import { eq, ledgerEvents, sessions, type Db, type DeviceRow } from "@parking/db"; import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices"; import { registry, type AccessControlDevice } from "@parking/devices";
import type { ResolvedRelay } from "./device-resolve.js"; import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js"; import { snapshotAsync } from "./snapshot.js";
import type { LedgerPayload } from "@parking/shared"; import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -30,9 +30,32 @@ interface SessionView {
readonly enteredAt: string; readonly enteredAt: string;
readonly open: boolean; // no vehicle_exit yet readonly open: boolean; // no vehicle_exit yet
readonly paidAt: string | null; // latest payment time, if any readonly paidAt: string | null; // latest payment time, if any
/** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to
* exit / re-open without a `payment`. */
readonly subscription: boolean;
readonly graceExitMin: number | null; // from the payment's tariff context, if known readonly graceExitMin: number | null; // from the payment's tariff context, if known
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
// active tariff resolves (then we fall back to the normal paid check).
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
} }
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
* the barrier didn't open (payment stands; operator opens manually). */
export type BoothExitResult =
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
| { ok: true; opened: true }
| { ok: true; opened: false; reason: string };
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
* intervention was recorded (signed anomaly) but the relay did not fire. */
export type BoothReopenResult =
| { ok: false; reason: string }
| { ok: true; opened: boolean; reason?: string };
export class ExitFlow { export class ExitFlow {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
@@ -45,8 +68,187 @@ export class ExitFlow {
this.#logger = logger; this.#logger = logger;
} }
/**
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
* relay site-wide, pulses it, and fires the exit snapshot.
*
* Returns a discriminated result so the route can react precisely:
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
* — nothing is signed beyond the existing anomaly; the operator takes payment.
* - { ok: true, opened: true } on a clean exit.
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
* operator opens manually. Payment is never rolled back.
*/
async exitForBooth(identity: string): Promise<BoothExitResult> {
const id = identity.trim();
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
const key = `booth:${id}`;
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
this.#inFlight.add(key);
try {
const view = this.#sessionFor(id);
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
// path) so a booth attempt on a bad ticket is auditable.
if (!view || !view.open) {
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason };
}
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
const freeGrace = view.paidAt == null && view.freeGrace != null;
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!freeGrace && (!paid || !withinGrace)) {
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
}
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
// reader path does.
if (freeGrace && view.freeGrace) {
await this.#log.append({
type: "payment",
identity: id,
payload: {
sessionRef: id,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
...reasonPayload("exit.freeGrace"),
},
});
}
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
// to let the car out has been made and validated. Then attempt the open.
await this.#signExit(id);
if (!resolved) {
await this.#openFailedAnomaly(id, "no exit relay configured");
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
await this.#openFailedAnomaly(id, "exit controller would not build");
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
}
this.#fireExitSnapshot(id);
this.#closeSessionCache(id);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/**
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
* opens the barrier with a signed trace.
*
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
* the UI also hides the button). It re-pulses the exit relay and signs an `anomaly`
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
*
* CLOSING THE SESSION (fix 2026-06-18): if the session is still OPEN (no
* `vehicle_exit` yet), the manual re-open *is* this car leaving — so we also sign a
* `vehicle_exit` (attributed as human-intervention). Without it the paid session
* would linger in the Active Sessions list FOREVER, since the grace-expiry eviction
* only applies to already-exited sessions (the T-397815c0 bug). If the session is
* already CLOSED (a prior exit exists — the phantom re-close case), we do NOT sign a
* second exit (that would double-count occupancy): anomaly only, as before.
* See wiki/concepts/booth-exit-flow.md.
*/
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
const id = identity.trim();
if (!id) return { ok: false, reason: "ticket id required" };
const view = this.#sessionFor(id);
if (!view) return { ok: false, reason: "no session for ticket" };
// Authorization to re-open: a PAID transient (paid, or paid-then-exited within
// grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must
// assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit
// flow instead — enforced here, not just in the UI (the no-unpaid-bypass rule).
if (view.paidAt == null && !view.subscription) {
return { ok: false, reason: "session not paid — no barrier open without payment" };
}
const key = `reopen:${id}`;
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
this.#inFlight.add(key);
try {
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
// the physical open succeeds).
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
...reasonPayload("exit.manualOpen"),
source: "booth",
barrierReopen: true,
...(operator ? { operator } : {}),
},
});
// Close an OPEN session: the re-open is the exit. Sign the vehicle_exit so the
// session leaves the active list + occupancy settles. Skip when already exited
// (no double-count). Recorded as a human-intervention exit for the audit trail.
if (view.open) {
await this.#signExit(id, "manual");
this.#closeSessionCache(id);
this.#fireExitSnapshot(id);
this.#logger.info(`barrier re-open also closed open session ${id} (human-intervention exit)`);
}
if (!resolved) {
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
}
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the /** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
* read dispatcher from the reader's binding, which has ruled out a permit match). */ * read dispatcher from the reader's binding, which has ruled out a subscription match). */
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> { async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const key = `${e.deviceId}:${e.value}`; const key = `${e.deviceId}:${e.value}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
@@ -66,14 +268,36 @@ export class ExitFlow {
// No matching open session — unknown/duplicate ticket. Reject + log. // No matching open session — unknown/duplicate ticket. Reject + log.
if (!view || !view.open) { if (!view || !view.open) {
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for credential"; const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
identity: e.value, identity: e.value,
payload: { reason, exitRefused: true }, payload: { ...rp, exitRefused: true },
}); });
this.#logger.warn(`exit refused: no open session for ${e.value}`); this.#logger.warn(`exit refused: no open session for ${e.value}`);
return { accepted: false, direction: "exit", reason }; return { accepted: false, direction: "exit", reason: rp.reason };
}
// FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate
// with no pay-station visit. Mint a signed $0 `payment` first so the ledger keeps
// its "an exit is covered by a payment" invariant, then fall through to open.
// Only when NOT already paid (a real payment, walk-back grace, takes precedence).
if (view.paidAt == null && view.freeGrace) {
await this.#log.append({
type: "payment",
// No `source` (not operator-keyed nor a read) — the payload reason marks it.
identity: e.value,
payload: {
sessionRef: e.value,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
...reasonPayload("exit.freeGrace"),
},
});
this.#logger.info(`exit free within entry-grace (${e.value})`);
return this.#signExitAndOpen(resolved, e);
} }
// PAID + within walk-back grace? // PAID + within walk-back grace?
@@ -84,49 +308,83 @@ export class ExitFlow {
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000; Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!paid || !withinGrace) { if (!paid || !withinGrace) {
const reason = !paid const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
? "exit refused — not paid (pay at the station)"
: "exit refused — walk-back grace expired (top-up required)";
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
identity: e.value, identity: e.value,
payload: { reason, exitRefused: true, sessionRef: e.value }, payload: { ...rp, exitRefused: true, sessionRef: e.value },
}); });
this.#logger.warn(`exit refused (${e.value}): ${reason}`); this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
return { accepted: false, direction: "exit", reason }; return { accepted: false, direction: "exit", reason: rp.reason };
} }
// Valid: sign the exit BEFORE opening, then open, then update the cache. // Valid (a real payment within walk-back grace): sign + open.
await this.#log.append({ return this.#signExitAndOpen(resolved, e);
type: "vehicle_exit", }
direction: "exit",
source: e.kind === "plate" ? "lpr" : "ticket", /** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
identity: e.value, * Shared by the paid-exit and free-entry-grace paths. The caller has already
payload: { sessionRef: e.value }, * established the session is allowed out (and, for grace, minted the $0 payment). */
}); async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
const access = this.#buildAccess(resolved.controller); const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay); if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`); else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
// SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate). this.#fireExitSnapshot(e.value);
this.#closeSessionCache(e.value);
return { accepted: true, direction: "exit" };
}
/** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
* or "manual" (a human-intervention barrier re-open that closes an open session —
* see reopenBarrier). */
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source,
identity,
payload: {
sessionRef: identity,
...(source === "manual" ? reasonPayload("exit.manualOpen") : {}),
},
});
}
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
#fireExitSnapshot(identity: string): void {
void snapshotAsync({ void snapshotAsync({
db: this.#db, db: this.#db,
direction: "exit", direction: "exit",
identity: e.value, identity,
logger: this.#logger, logger: this.#logger,
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`)); }).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
}
/** Update the (rebuildable) session projection cache to closed. */
#closeSessionCache(identity: string): void {
try { try {
this.#db this.#db
.update(sessions) .update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "closed" }) .set({ exitedAt: new Date().toISOString(), state: "closed" })
.where(eq(sessions.id, e.value)) .where(eq(sessions.id, identity))
.run(); .run();
} catch (err) { } catch (err) {
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`); this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
} }
return { accepted: true, direction: "exit" }; }
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
* The payment + exit STAND; this tells the operator to open manually. */
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity,
payload: { ...reasonPayload("exit.open.failed"), detail, source: "booth", exitOpenFailed: true },
});
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
} }
/** Fold the signed ledger into a session view for one identity (authoritative). */ /** Fold the signed ledger into a session view for one identity (authoritative). */
@@ -153,15 +411,57 @@ export class ExitFlow {
} }
} }
// Free entry-grace: if the tariff prices entry→now at 0 (a quick in-and-out),
// the exit may open at the gate. Resolve against the tariff in force at entry,
// same as the pay station. Null when no payment is needed yet and no tariff
// resolves — then exit falls back to the normal paid check.
let freeGrace: SessionView["freeGrace"] = null;
if (!exited && paidAt == null) {
const tv = this.#tariffVersionFor(entry.occurredAt);
if (tv) {
const structure = tv.structure as unknown as TariffStructure;
// Same frozen-at-entry category the pay station uses, so the free-grace
// check agrees with the booth quote for V2 category tariffs.
const category = (entry.payload as { category?: string } | null)?.category;
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
if (fee === 0) {
freeGrace = {
tariffVersionId: tv.id,
currency: tv.currency,
graceExitMin: structure.gracePeriodExitMin,
};
}
}
}
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const subscription = entryPl.permit === true || entryPl.permitId != null;
return { return {
identity, identity,
enteredAt: entry.occurredAt, enteredAt: entry.occurredAt,
open: !exited, open: !exited,
paidAt, paidAt,
subscription,
graceExitMin, graceExitMin,
freeGrace,
}; };
} }
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
#tariffVersionFor(at: string) {
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = this.#db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
/** Build a live access adapter from a resolved controller row, or null. */ /** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null { #buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
+243 -2
View File
@@ -1,4 +1,4 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db"; import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { computeFee, type TariffStructure, type Tender } from "@parking/shared"; import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -35,6 +35,57 @@ export interface Quote {
readonly graceExitMin: number; readonly graceExitMin: number;
} }
/** One row in the booth Active Sessions list. A session is "active" while it is
* still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a
* paid/exited car is presumed possibly-still-present until grace expires. The
* "Open barrier" action is offered only when `paidAt != null` (no payment, no
* button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */
export interface ActiveSession {
readonly identity: string;
readonly source: string | null;
readonly enteredAt: string;
/** null while still inside; set once a vehicle_exit is signed (may still be present). */
readonly exitedAt: string | null;
readonly open: boolean;
readonly paidAt: string | null;
/** Amount owed now (open + unpaid only; null otherwise / no tariff). */
readonly amountMinor: number | null;
readonly currency: string | null;
readonly withinGrace: boolean;
readonly graceExpiresAt: string | null;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
readonly subscription: boolean;
/** The subscription id (on-chain `permitId`), when `subscription` is true. */
readonly subscriptionId: string | null;
/** The subscriber's holder name (for a friendly label instead of the raw key). */
readonly subscriptionHolder: string | null;
}
/** Booth session view: everything the pay/exit modal needs in one read. */
export interface SessionLookup {
readonly identity: string;
readonly found: boolean;
/** Open = entered, no exit yet. */
readonly open: boolean;
readonly enteredAt: string | null;
readonly exitedAt: string | null;
/** Latest payment time, if paid. */
readonly paidAt: string | null;
/** Amount owed right now (the quote). Null when no session / no active tariff. */
readonly amountMinor: number | null;
readonly currency: string | null;
/** True when paid AND still within the walk-back grace window. */
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
readonly graceExpiresAt: string | null;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
readonly subscription: boolean;
readonly subscriptionId: string | null;
readonly subscriptionHolder: string | null;
}
export class PayStation { export class PayStation {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
@@ -55,7 +106,11 @@ export class PayStation {
if (!tv) throw new NoTariffError(); if (!tv) throw new NoTariffError();
const structure = tv.structure as unknown as TariffStructure; const structure = tv.structure as unknown as TariffStructure;
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure); // Category was frozen in the signed vehicle_entry payload — pricing AND repricing
// both read it from there, so a V2 category tariff yields the same amount at the
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
const category = (entry.payload as { category?: string } | null)?.category;
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
return { return {
identity, identity,
enteredAt: entry.occurredAt, enteredAt: entry.occurredAt,
@@ -109,6 +164,192 @@ export class PayStation {
return { amountMinor, currency: q.currency }; return { amountMinor, currency: q.currency };
} }
/**
* One-read session view for the booth pay/exit modal: entry/exit times, paid
* state, amount owed now, and walk-back-grace status. Read-only — folds the
* signed ledger (authoritative). A quote failure (no tariff) leaves amount null
* rather than throwing, so the modal can still show the session.
*/
lookup(identity: string): SessionLookup {
const id = identity.trim();
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, id))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) {
return {
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
subscription: false, subscriptionId: null, subscriptionHolder: null,
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
const exitRow = rows.find((r) => r.type === "vehicle_exit");
const open = !exitRow;
let paidAt: string | null = null;
let graceExitMin: number | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
}
}
const graceExpiresAt =
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
// Amount owed now (best-effort; null if no tariff resolves). Only meaningful while
// open AND transient — a subscription is prepaid, never quoted/charged.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && !isSubscription) {
try {
const q = this.quote(id);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
}
return {
identity: id, found: true, open,
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
paidAt, amountMinor, currency, withinGrace, graceExpiresAt,
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
};
}
/**
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
* until grace expires). One ledger scan, grouped by identity (cheaper than N
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
* (authoritative — not the sessions projection cache, which can drift).
* See wiki/concepts/booth-exit-flow.md.
*/
activeSessions(): ActiveSession[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Group the relevant events per identity in one pass.
type Acc = {
enteredAt?: string;
source: string | null;
exitedAt?: string;
paidAt?: string;
graceExitMin?: number;
subscriptionId?: string | null;
};
const byId = new Map<string, Acc>();
for (const r of rows) {
const id = r.identity;
if (!id) continue;
if (r.type === "vehicle_entry") {
const a = byId.get(id) ?? { source: r.source ?? null };
a.enteredAt = r.occurredAt;
a.source = r.source ?? a.source;
// Subscription occurrence? The entry payload carries permit:true + permitId
// (the on-chain field). Mark it so the booth never tries to charge it.
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
byId.set(id, a);
} else if (r.type === "vehicle_exit") {
const a = byId.get(id);
if (a) a.exitedAt = r.occurredAt;
} else if (r.type === "payment") {
const a = byId.get(id);
if (a) {
a.paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
}
}
}
const now = Date.now();
const out: ActiveSession[] = [];
for (const [identity, a] of byId) {
if (!a.enteredAt) continue; // no entry → not a real session
const open = a.exitedAt == null;
const graceExpiresAt =
a.paidAt && a.graceExitMin != null
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
: null;
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
const paid = a.paidAt != null;
// ACTIVE membership:
// - exited + within grace → still shown (barrier unconfirmed, may be present);
// - exited + past grace → presumed gone, omitted;
// - open + UNPAID → always shown (a car owing money never ages out —
// it's genuinely still inside until it pays, however long that takes);
// - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
// manual barrier re-open before that path closed the session, or a historical
// session like T-397815c0) it would otherwise linger forever. The signed log
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
if (!open && !withinGrace) continue;
if (open && paid && graceExpiresAt != null && !withinGrace) continue;
const isSubscription = a.subscriptionId !== undefined;
// Amount owed now: only meaningful for an open + unpaid TRANSIENT session. A
// subscription is prepaid — never quote/charge it.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && a.paidAt == null && !isSubscription) {
try {
const q = this.quote(identity);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null */
}
}
out.push({
identity,
source: a.source,
enteredAt: a.enteredAt,
exitedAt: a.exitedAt ?? null,
open,
paidAt: a.paidAt ?? null,
amountMinor,
currency,
withinGrace,
graceExpiresAt,
subscription: isSubscription,
subscriptionId: a.subscriptionId ?? null,
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
});
}
// Newest entry first.
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
return out;
}
/** The subscriber's holder name for a subscription id (for a friendly UI label),
* or null. Best-effort: a deleted subscription just yields null. */
#holderOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
try {
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.holderName ?? null;
} catch {
return null;
}
}
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */ /** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
#openEntry(identity: string) { #openEntry(identity: string) {
const rows = this.#db const rows = this.#db
-222
View File
@@ -1,222 +0,0 @@
import { eq, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
// Reached from the read dispatcher when a read matches a permit (not an open ticket).
// See wiki/entities/permit.md.
//
// Two optional, independent bindings:
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
// permit's cars may be inside at once; enforced over the session projection.
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
// too (card/QR OR plate). When unset, any car may use the permit's card/QR.
//
// Direction is inferred from session state for THAT car (the read credential value
// is the per-car session key): no open session → ENTRY; open session → EXIT. So a
// fleet permit can have several cars in at once, each its own session, and
// anti-passback falls out (a second "entry" on a car already in becomes its exit).
export interface PermitMatch {
readonly permitId: string;
/** The specific credential/plate value read — the per-car session key. */
readonly carKey: string;
readonly via: "card" | "qr" | "plate";
}
export class PermitFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** Resolve a read to a permit (by card/QR credential, or by a bound plate), or null. */
match(e: DeviceReadEvent): PermitMatch | null {
// Card / QR / generic credential value.
const cred = this.#db
.select()
.from(permitCredentials)
.where(eq(permitCredentials.value, e.value))
.get();
if (cred) {
return { permitId: cred.permitId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a permit's bound plate is an identity.
if (e.kind === "plate") {
const plate = this.#db.select().from(permitPlates).where(eq(permitPlates.plate, e.value)).get();
if (plate) return { permitId: plate.permitId, carKey: e.value, via: "plate" };
}
return null;
}
/** Run the permit entry/exit for a matched read at a barrier. `resolved` is the
* reader's bound relay; its direction constrains, "both" defers to session state. */
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
const key = `${m.permitId}:${m.carKey}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#run(resolved, e, m);
} catch (err) {
this.#logger.error(`permit-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
if (!permit) return { accepted: false, reason: "permit not found" };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
const invalid =
permit.status !== "active" ||
(permit.validFrom != null && now < permit.validFrom) ||
(permit.validTo != null && now > permit.validTo);
if (invalid) {
const reason = `permit ${permit.status}/out-of-window`;
await this.#reject(m, reason);
return { accepted: false, reason };
}
// Direction: the car's open-session state is the natural verb (in→exit, out→entry).
// The barrier the car is at (resolved.direction) must AGREE — a car at an exit
// barrier that isn't inside (or at an entry barrier while already in) is a
// wrong-barrier / anti-passback signal, refused + logged. A "both" barrier follows
// the session state.
const carOpen = this.#carHasOpenSession(m.carKey);
const inferred: FlowDirection = carOpen ? "exit" : "entry";
if (resolved.direction !== "both" && resolved.direction !== inferred) {
const reason = `permit wrong barrier — ${resolved.direction} barrier but car would ${inferred}`;
await this.#reject(m, reason);
return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason };
}
if (carOpen) {
// EXIT: this car is already inside → the read is its exit.
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey,
payload: { sessionRef: m.carKey, permitId: m.permitId },
});
await this.#open(resolved, "exit", m.carKey, "permit exit");
this.#closeCache(m.carKey);
return { accepted: true, direction: "exit" };
}
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
if (permit.maxConcurrent != null) {
const open = this.#permitOpenCount(m.permitId);
if (open >= permit.maxConcurrent) {
const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`;
await this.#reject(m, reason);
return { accepted: false, direction: "entry", reason };
}
}
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey,
// No ticket, no fee — the permit IS the authorization. Recorded for audit.
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true },
occurredAt: now,
});
await this.#open(resolved, "entry", m.carKey, "permit entry");
try {
this.#db
.insert(sessions)
.values({ id: m.carKey, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" })
.run();
} catch (err) {
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
}
return { accepted: true, direction: "entry" };
}
/** Does this specific car (credential value) have an open session right now? */
#carHasOpenSession(carKey: string): boolean {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, carKey))
.orderBy(ledgerEvents.index)
.all();
const entries = rows.filter((r) => r.type === "vehicle_entry").length;
const exits = rows.filter((r) => r.type === "vehicle_exit").length;
return entries > exits;
}
/** How many of this permit's cars are inside right now (fold over the ledger). */
#permitOpenCount(permitId: string): number {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "vehicle_entry"))
.all()
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId);
let open = 0;
for (const entry of rows) {
if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
open += 1;
}
return open;
}
async #reject(m: PermitMatch, reason: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity: m.carKey,
payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true },
});
this.#logger.warn(`permit refused (${m.carKey}): ${reason}`);
}
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
void snapshotAsync({
db: this.#db,
direction: dir,
identity: carKey,
logger: this.#logger,
}).catch((err) => this.#logger.error(`permit snapshot error: ${(err as Error).message}`));
}
#closeCache(carKey: string): void {
try {
this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`);
}
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
}
+14 -14
View File
@@ -2,32 +2,32 @@ import { devices, eq, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { ExitFlow } from "./exit-flow.js"; import type { ExitFlow } from "./exit-flow.js";
import type { PermitFlow } from "./permit-flow.js"; import type { SubscriptionFlow } from "./subscription-flow.js";
import { relayForDevice } from "./device-resolve.js"; import { relayForDevice } from "./device-resolve.js";
// Routes a credential read (ticket scan / plate / card) to the right flow. A read // Routes a credential read (ticket scan / plate / card) to the right flow. A read
// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the // can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
// credential is (decision 2026-06-15): // credential is (decision 2026-06-15):
// - matches a permit (card/QR/bound plate) → PERMIT flow, // - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
// - else → transient EXIT flow (open ticket session → exit, else reject+log). // - else → transient EXIT flow (open ticket session → exit, else reject+log).
// //
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read // The reader is BOUND to a controller relay (config.controllerId + relay), so a read
// resolves to exactly the barrier it sits at, and the direction is inherited from // resolves to exactly the barrier it sits at, and the direction is inherited from
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so // that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit" // it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
// reader the exit side; "both" defers to the flow's own inference (permit: session // reader the exit side; "both" defers to the flow's own inference (subscription:
// state; transient: exit). // session state; transient: exit).
export class ReadDispatcher { export class ReadDispatcher {
readonly #db: Db; readonly #db: Db;
readonly #exit: ExitFlow; readonly #exit: ExitFlow;
readonly #permit: PermitFlow; readonly #subscription: SubscriptionFlow;
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) { constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db; this.#db = db;
this.#exit = exit; this.#exit = exit;
this.#permit = permit; this.#subscription = subscription;
this.#logger = logger; this.#logger = logger;
} }
@@ -41,13 +41,13 @@ export class ReadDispatcher {
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" }; return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
} }
const permit = this.#permit.match(e); const sub = this.#subscription.match(e);
if (permit) { if (sub) {
return this.#permit.run(resolved, e, permit); return this.#subscription.run(resolved, e, sub);
} }
// Not a permit → transient ticket exit. An ENTRY reader can't produce a transient // Not a subscription → transient ticket exit. An ENTRY reader can't produce a
// exit (transient entry is the button flow, not a reader), so reject+log rather // transient exit (transient entry is the button flow, not a reader), so reject+log
// than treat an entry scan as an exit. // rather than treat an entry scan as an exit.
if (resolved.direction === "entry") { if (resolved.direction === "entry") {
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" }; return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
} }
+91 -10
View File
@@ -1,10 +1,11 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, users, type Db } from "@parking/db"; import { eq, roles, users, type Db } from "@parking/db";
import { import {
clearAuthCookies, clearAuthCookies,
newCsrfToken, newCsrfToken,
requireRole, permissionsFor,
requireAuth,
setAuthCookies, setAuthCookies,
} from "../auth.js"; } from "../auth.js";
@@ -16,6 +17,46 @@ interface LoginBody {
password: string; password: string;
} }
const LANGS = ["sq", "en"] as const;
type Lang = (typeof LANGS)[number];
interface LanguageBody {
language: Lang;
}
const THEMES = ["dark", "light"] as const;
type Theme = (typeof THEMES)[number];
interface ThemeBody {
theme: Theme;
}
/** 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
* permissions are the source of truth. */
function sessionView(
db: Db,
user: {
id: string;
username: string;
roleId: string;
language: string;
theme: string;
fullName?: string | null;
},
) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
const permissions = [...permissionsFor(user.roleId)];
return {
id: user.id,
username: user.username,
roleId: user.roleId,
roleName: role?.name ?? user.roleId,
permissions,
language: user.language,
theme: user.theme,
fullName: user.fullName ?? null,
};
}
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => { app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
const { username, password } = req.body ?? {}; const { username, password } = req.body ?? {};
@@ -33,15 +74,19 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
const csrf = newCsrfToken(); const csrf = newCsrfToken();
// No expiresIn: the token is valid until explicit logout (see auth.ts). // No expiresIn: the token is valid until explicit logout (see auth.ts). The
// token carries roleId (not the permission list) — perms resolve per-request,
// so a role edit applies immediately with no re-login.
const token = await reply.jwtSign({ const token = await reply.jwtSign({
sub: user.id, sub: user.id,
username: user.username, username: user.username,
role: user.role, roleId: user.roleId,
csrf, csrf,
}); });
setAuthCookies(reply, token, csrf); setAuthCookies(reply, token, csrf);
return { id: user.id, username: user.username, role: user.role }; // `language` is NOT in the JWT (identity/role only) — it's a mutable preference
// read from the DB, so changing it needs no token refresh.
return sessionView(db, user);
}); });
app.post("/api/auth/logout", async (_req, reply) => { app.post("/api/auth/logout", async (_req, reply) => {
@@ -49,13 +94,49 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
return { ok: true }; return { ok: true };
}); });
// Who am I — used by the SPA to bootstrap session state on load. // Who am I — used by the SPA to bootstrap session state on load. Reads the live
// `language` preference from the DB (not the token).
app.get( app.get(
"/api/auth/me", "/api/auth/me",
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") }, { preHandler: requireAuth },
async (req) => { async (req, reply) => {
const { sub, username, role } = req.user; const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
return { id: sub, username, role }; if (!row) {
// The user was deleted while their cookie was still valid — clear it.
clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" });
}
return sessionView(db, row);
},
);
// Change MY own UI language preference (any signed-in user). Persisted to the
// users row so it's restored on the next login, from any booth. See i18n.md.
app.put<{ Body: LanguageBody }>(
"/api/auth/language",
{ preHandler: requireAuth },
async (req, reply) => {
const language = req.body?.language;
if (!language || !LANGS.includes(language)) {
return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` });
}
await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run();
return { language };
},
);
// Change MY own UI theme preference (any signed-in user). Persisted to the users
// row like `language`, so it's restored on the next login from any booth.
app.put<{ Body: ThemeBody }>(
"/api/auth/theme",
{ preHandler: requireAuth },
async (req, reply) => {
const theme = req.body?.theme;
if (!theme || !THEMES.includes(theme)) {
return reply.code(400).send({ error: `theme must be one of: ${THEMES.join(", ")}` });
}
await db.update(users).set({ theme }).where(eq(users.id, req.user.sub)).run();
return { theme };
}, },
); );
} }
+21
View File
@@ -0,0 +1,21 @@
import type { FastifyInstance } from "fastify";
import { requirePermission } from "../auth.js";
import type { DeviceMonitor } from "../device-monitor.js";
// Unified device-status snapshot for the booth footer. The DeviceMonitor polls all
// configured devices (relays/readers/cameras via healthCheck, printers via their
// rich readStatus) in the background; this exposes its cache. Live updates ride the
// booth WebSocket (kind:"device-status") — this REST route is the initial load /
// fallback. Any authenticated role may read (operational, not a setup action).
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
export async function deviceStatusRoutes(
app: FastifyInstance,
monitor: DeviceMonitor,
): Promise<void> {
const guard = requirePermission("device:read");
app.get("/api/devices/status", { preHandler: guard }, async () => ({
devices: monitor.snapshot(),
}));
}
+23 -8
View File
@@ -1,6 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, ledgerEvents, type Db } from "@parking/db"; import { desc, gte, ledgerEvents, type Db } from "@parking/db";
import { requireRole } from "../auth.js"; import type { LedgerEvent } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { enrichEvent } from "../event-enrich.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
// Read access to the append-only signed event log. NO write/update/delete routes // Read access to the append-only signed event log. NO write/update/delete routes
@@ -13,17 +15,30 @@ export async function eventRoutes(
db: Db, db: Db,
eventLog: EventLog, eventLog: EventLog,
): Promise<void> { ): Promise<void> {
// Any authenticated role may read the log (it's the audit trail). // Reading the log (the audit trail).
const guard = requireRole("admin", "operator", "cashier", "readonly"); const guard = requirePermission("event:read");
// Recent events, newest first. `limit` caps the page (default 100, max 1000). // Recent events, newest first. `limit` caps the page (default 100, max 1000).
app.get<{ Querystring: { limit?: string } }>( // Optional `since` (ISO) scopes the page to events at/after that instant — the
// booth passes the current shift's start so the live feed shows ONLY this shift's
// activity (logs are per-shift, not all history). See wiki/concepts/shift.md.
app.get<{ Querystring: { limit?: string; since?: string } }>(
"/api/events", "/api/events",
{ preHandler: guard }, { preHandler: guard },
async (req) => { async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all(); const since = (req.query.since ?? "").trim();
return { events: rows }; const rows = db
.select()
.from(ledgerEvents)
.where(since ? gte(ledgerEvents.occurredAt, since) : undefined)
.orderBy(desc(ledgerEvents.index))
.limit(limit)
.all();
// Attach read-time display fields (e.g. subscriber name) without touching the
// signed record. The cast bridges the Drizzle row to the shared LedgerEvent.
const events = rows.map((r) => enrichEvent(db, r as unknown as LedgerEvent));
return { events };
}, },
); );
@@ -32,7 +47,7 @@ export async function eventRoutes(
// reconciliation job / "is the log intact?" check calls. // reconciliation job / "is the log intact?" check calls.
app.get( app.get(
"/api/events/verify", "/api/events/verify",
{ preHandler: requireRole("admin") }, { preHandler: requirePermission("event:read") },
async () => eventLog.verifyChain(), async () => eventLog.verifyChain(),
); );
} }
+168 -10
View File
@@ -1,15 +1,22 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js"; import type { Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import { requirePermission } from "../auth.js";
import { import {
NoOpenSessionError, NoOpenSessionError,
NoTariffError, NoTariffError,
type PayStation, type PayStation,
} from "../pay-station.js"; } from "../pay-station.js";
import type { ExitFlow } from "../exit-flow.js";
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
import { printPaymentReceipt } from "../booth-print.js";
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session // Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
// then takes payment; the payment becomes a signed ledger event. PCI scope stays // when the booth is at/near the exit — open the barrier. The payment becomes a
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender` // signed ledger event; PCI scope stays OUT of the app (card capture is a standalone
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md. // P2PE terminal; `tender` just records cash vs. card). The booth exit reuses the
// SAME validation as the reader path — no booth-only bypass admits an unpaid car.
// See wiki/concepts/tariff.md, parking-session.md, booth-exit-flow.md, bom.md.
interface QuoteQuery { interface QuoteQuery {
identity: string; identity: string;
@@ -20,15 +27,108 @@ interface PayBody {
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */ /** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
overrideMinor?: number; overrideMinor?: number;
} }
interface ExitBody {
identity: string;
}
interface VoucherBody {
identity: string;
}
interface ReceiptBody {
identity: string;
}
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> { export async function payRoutes(
// Cashier/operator/admin operate the pay station; readonly may not. app: FastifyInstance,
const guard = requireRole("admin", "operator", "cashier"); db: Db,
payStation: PayStation,
exitFlow: ExitFlow,
shift: ShiftService,
): Promise<void> {
// Reads (lookup, active sessions, quote) need session/payment read; the booth
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
// single guard covers the whole booth flow — anyone who takes payment also reads
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
const guard = requirePermission("payment:create");
const readGuard = requirePermission("session:read");
// 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
// accountability period). Read-only lookups (session/active/quote) stay ungated so
// the modal can still DISPLAY the session and prompt the operator to open a shift.
// Returns 409 { error, code: "no_shift" } so the UI can show the "open a shift"
// prompt rather than a generic failure. See wiki/concepts/shift.md.
const requireShift = async (
_req: import("fastify").FastifyRequest,
reply: import("fastify").FastifyReply,
) => {
try {
shift.requireOpenShift();
} catch (err) {
if (err instanceof NoShiftOpenError) {
return reply.code(409).send({ error: err.message, code: "no_shift" });
}
throw err;
}
};
// Active sessions for the booth list: still-open OR exited-but-within-grace
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
app.get("/api/sessions/active", { preHandler: readGuard }, async () => ({
sessions: payStation.activeSessions(),
}));
// Session lookup for the booth pay/exit modal: entry/exit times, paid state,
// amount owed now, walk-back-grace status. Read-only (no side effect).
app.get<{ Params: { identity: string } }>(
"/api/session/:identity",
{ preHandler: readGuard },
async (req, reply) => {
const identity = (req.params.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
return payStation.lookup(identity);
},
);
// Booth-driven exit: validate (paid + grace, or free entry-grace) THEN sign
// vehicle_exit + open the barrier. Maps the discriminated result to HTTP:
// - validation reject → 409 with a reason (operator takes payment first),
// - exit signed but barrier didn't open → 200 { opened:false } (payment stands;
// operator opens manually; an anomaly is already signed),
// - clean exit → 200 { opened:true }.
app.post<{ Body: ExitBody }>(
"/api/exit",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const res = await exitFlow.exitForBooth(identity);
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
return reply.code(200).send(res);
},
);
// Human-intervention barrier re-open for an ACTIVE (paid) session — damaged
// ticket / dead scanner / phantom re-close. Re-pulses the exit relay + signs an
// anomaly (attributed); NEVER a second vehicle_exit. Refused without a payment
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
app.post<{ Body: ExitBody }>(
"/api/barrier/reopen",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const operator = req.user?.username;
const res = await exitFlow.reopenBarrier(identity, operator);
if (!res.ok) return reply.code(409).send({ error: res.reason });
return reply.code(200).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",
{ preHandler: guard }, { preHandler: readGuard },
async (req, reply) => { async (req, reply) => {
const identity = (req.query.identity ?? "").trim(); const identity = (req.query.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" }); if (!identity) return reply.code(400).send({ error: "identity required" });
@@ -43,7 +143,7 @@ export async function payRoutes(app: FastifyInstance, payStation: PayStation): P
// Pay: take payment and append the signed `payment` event. // Pay: take payment and append the signed `payment` event.
app.post<{ Body: PayBody }>( app.post<{ Body: PayBody }>(
"/api/pay", "/api/pay",
{ preHandler: guard }, { preHandler: [guard, requireShift] },
async (req, reply) => { async (req, reply) => {
const { identity, tender, overrideMinor } = req.body ?? {}; const { identity, tender, overrideMinor } = req.body ?? {};
if (!identity || (tender !== "cash" && tender !== "card")) { if (!identity || (tender !== "cash" && tender !== "card")) {
@@ -60,6 +160,64 @@ export async function payRoutes(app: FastifyInstance, payStation: PayStation): P
} }
}, },
); );
// Print an exit voucher (the paid ticket id reprinted as a barcode) on the booth
// printer. Used when the booth is far from the exit — the customer self-scans the
// voucher at the exit reader, which runs the normal validated exit. Requires the
// session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md.
app.post<{ Body: VoucherBody }>(
"/api/voucher",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const view = payStation.lookup(identity);
if (!view.found || !view.open) {
return reply.code(404).send({ error: "no open session for ticket" });
}
if (view.paidAt == null) {
return reply.code(409).send({ error: "session not paid — take payment before printing a voucher" });
}
try {
const printedBy = await printPaymentReceipt(db, identity, { voucher: true }, app.log);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) {
return reply.code(503).send({ error: err.message });
}
return reply.code(500).send({ error: (err as Error).message });
}
},
);
// Print a standalone PAYMENT RECEIPT (transparency: entry/paid/duration/amount,
// no barcode) on the booth printer. Used (a) auto, right after a payment when no
// voucher is issued, and (b) on-demand "reprint" if the slip jammed. Requires the
// session to be PAID. See wiki/concepts/booth-exit-flow.md.
app.post<{ Body: ReceiptBody }>(
"/api/receipt",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const view = payStation.lookup(identity);
if (!view.found) {
return reply.code(404).send({ error: "no session for ticket" });
}
if (view.paidAt == null) {
return reply.code(409).send({ error: "session not paid — nothing to receipt" });
}
try {
const printedBy = await printPaymentReceipt(db, identity, { voucher: false }, app.log);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) {
return reply.code(503).send({ error: err.message });
}
return reply.code(500).send({ error: (err as Error).message });
}
},
);
} }
function mapError(reply: import("fastify").FastifyReply, err: unknown) { function mapError(reply: import("fastify").FastifyReply, err: unknown) {
-160
View File
@@ -1,160 +0,0 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, permitCredentials, permitPlates, permits, type Db } from "@parking/db";
import { requireRole } from "../auth.js";
// Permit (subscription) admin CRUD. A permit is mutable master data — admins
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
// trail stays append-only (see wiki/entities/permit.md). A permit is an aggregate:
// the permit row + its credentials (card/QR) + its bound plates. The API treats them
// as one unit (create/update replace the child sets; delete removes all).
interface Credential {
kind: "rf" | "qr";
value: string;
}
interface PermitBody {
holderName?: string;
contact?: string;
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
maxConcurrent?: number | null;
validFrom?: string | null;
validTo?: string | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
}
export async function permitRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Admin manages permits; operator/cashier/readonly may LIST (to look one up).
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
const writeGuard = requireRole("admin");
// Validate the body; returns problems (empty = ok). Shared by create + update.
function validate(b: PermitBody): string[] {
const errs: string[] = [];
if (b.maxConcurrent != null) {
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
errs.push("maxConcurrent must be a positive integer, or null for unbound");
}
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
for (const c of b.credentials ?? []) {
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
errs.push("each credential needs kind (rf|qr) and a non-empty value");
break;
}
}
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
errs.push("a permit needs at least one credential or one bound plate (else nothing identifies it)");
}
return errs;
}
function loadAggregate(id: string) {
const permit = db.select().from(permits).where(eq(permits.id, id)).get();
if (!permit) return null;
const credentials = db.select().from(permitCredentials).where(eq(permitCredentials.permitId, id)).all();
const plates = db.select().from(permitPlates).where(eq(permitPlates.permitId, id)).all();
return {
...permit,
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
plates: plates.map((p) => p.plate),
};
}
// Replace a permit's child rows (credentials + plates) from the body.
function writeChildren(id: string, b: PermitBody) {
db.delete(permitCredentials).where(eq(permitCredentials.permitId, id)).run();
db.delete(permitPlates).where(eq(permitPlates.permitId, id)).run();
for (const c of b.credentials ?? []) {
db.insert(permitCredentials).values({ id: randomUUID(), permitId: id, kind: c.kind, value: c.value.trim() }).run();
}
for (const p of b.plates ?? []) {
if (p.trim()) db.insert(permitPlates).values({ id: randomUUID(), permitId: id, plate: p.trim() }).run();
}
}
// List all permits (with their credentials + plates).
app.get("/api/permits", { preHandler: readGuard }, async () => {
const rows = db.select().from(permits).all();
return { permits: rows.map((r) => loadAggregate(r.id)) };
});
// Create a permit.
app.post<{ Body: PermitBody }>("/api/permits", { preHandler: writeGuard }, async (req, reply) => {
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
const id = randomUUID();
db.insert(permits)
.values({
id,
holderName: b.holderName ?? null,
contact: b.contact ?? null,
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: b.validTo ?? null,
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
return reply.code(201).send(loadAggregate(id));
});
// Update a permit (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: PermitBody }>(
"/api/permits/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(permits).where(eq(permits.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "permit not found" });
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
db.update(permits)
.set({
holderName: b.holderName ?? null,
contact: b.contact ?? null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: b.validTo ?? null,
status: b.status ?? existing.status,
})
.where(eq(permits.id, req.params.id))
.run();
writeChildren(req.params.id, b);
return loadAggregate(req.params.id);
},
);
// Revoke (soft): the common case — keeps the permit + its history, just bars it.
// A revoked permit fails the entry check (see permit-flow.ts). Use DELETE only to
// fully remove a permit created in error.
app.post<{ Params: { id: string } }>(
"/api/permits/:id/revoke",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.update(permits).set({ status: "revoked" }).where(eq(permits.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
return loadAggregate(req.params.id);
},
);
// Hard delete a permit + its child rows. (Past ledger events that reference it
// are untouched — the audit trail is append-only and independent of this row.)
app.delete<{ Params: { id: string } }>(
"/api/permits/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.delete(permits).where(eq(permits.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
db.delete(permitCredentials).where(eq(permitCredentials.permitId, req.params.id)).run();
db.delete(permitPlates).where(eq(permitPlates.permitId, req.params.id)).run();
return reply.code(204).send();
},
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js"; import { requirePermission } from "../auth.js";
import { deviceEvents } from "../device-events.js"; import { deviceEvents } from "../device-events.js";
import type { PrinterMonitor } from "../printer-monitor.js"; import type { PrinterMonitor } from "../printer-monitor.js";
@@ -12,7 +12,7 @@ export async function printerRoutes(
app: FastifyInstance, app: FastifyInstance,
monitor: PrinterMonitor, monitor: PrinterMonitor,
): Promise<void> { ): Promise<void> {
const guard = requireRole("admin", "operator", "cashier", "readonly"); const guard = requirePermission("device:read");
// Current status of every monitored printer (cached — no device round-trip). // Current status of every monitored printer (cached — no device round-trip).
app.get("/api/printers/status", { preHandler: guard }, async () => ({ app.get("/api/printers/status", { preHandler: guard }, async () => ({
+21 -2
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import { eq, devices, type Db } from "@parking/db"; import { eq, devices, type Db } from "@parking/db";
import type { DeviceReadEvent } from "../device-events.js"; import type { DeviceReadEvent } from "../device-events.js";
import type { ReadDispatcher } from "../read-dispatch.js"; import type { ReadDispatcher } from "../read-dispatch.js";
import type { CredentialCapture } from "../credential-capture.js";
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our // GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on // host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
@@ -30,6 +31,7 @@ export async function qrReaderRoutes(
app: FastifyInstance, app: FastifyInstance,
db: Db, db: Db,
dispatcher: ReadDispatcher, dispatcher: ReadDispatcher,
capture: CredentialCapture,
): Promise<void> { ): Promise<void> {
// Resolve the lane_devices row whose config.serial matches the reader's reported // Resolve the lane_devices row whose config.serial matches the reader's reported
// serial (cjihao). The row id is a normal UUID; the serial is config the admin // serial (cjihao). The row id is a normal UUID; the serial is config the admin
@@ -59,10 +61,19 @@ export async function qrReaderRoutes(
// Map the reader's serial → its assigned lane_devices row id (the dispatcher // Map the reader's serial → its assigned lane_devices row id (the dispatcher
// resolves the lane from that row). If unassigned, deviceId stays the serial so // resolves the lane from that row). If unassigned, deviceId stays the serial so
// the dispatcher simply finds no lane and rejects (status:0) — never crashes. // the dispatcher simply finds no lane and rejects (status:0) — never crashes.
const deviceId = readerRowIdForSerial(serial) ?? serial; const matchedRowId = readerRowIdForSerial(serial);
const deviceId = matchedRowId ?? serial;
let accepted = false; let accepted = false;
if (cardid) { if (cardid) {
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
// value for the subscription form and do NOT run the access flow (we must not
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
// Reads from the OTHER reader are untouched and dispatch normally below.
if (capture.tryConsume(deviceId, cardid)) {
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
accepted = true; // beep "ok" so the operator knows the card was read
} else {
const read: DeviceReadEvent = { const read: DeviceReadEvent = {
driverId: "gee-qr-reader", driverId: "gee-qr-reader",
deviceId, deviceId,
@@ -73,11 +84,19 @@ export async function qrReaderRoutes(
try { try {
const outcome = await dispatcher.dispatch(read); const outcome = await dispatcher.dispatch(read);
accepted = outcome.accepted; accepted = outcome.accepted;
if (!accepted) app.log.info(`QR ${cardid} rejected: ${outcome.reason ?? "?"}`); // Per-read diagnostic: which reader (serial) sent it, which configured device
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
// the logs (e.g. an entry-side scan resolving to the exit relay).
app.log.info(
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
);
} catch (err) { } catch (err) {
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`); app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
} }
} }
}
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid). // Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
// output 0 = Access (drive the reader's access line on a valid read). // output 0 = Access (drive the reader's access line on a valid read).
+167
View File
@@ -0,0 +1,167 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
// Role management (admin). Roles are DATA: an admin composes a role from the
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
// role. The built-in `admin` role (id ADMIN_ROLE_ID) is PROTECTED — it can't be
// edited or deleted and always resolves to every permission in code. Every write
// here bumps the in-memory permission cache so changes take effect on the next
// request. See @parking/shared PERMISSIONS and ../auth.ts.
//
// PRIVILEGE-ESCALATION GUARD: `role:update`/`role:create` must NOT let a caller
// grant a permission they don't themselves hold — otherwise a non-admin with
// `role:*` could edit their own role to add (say) `tariff:update`, or mint a role
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
// only put permissions they ALREADY hold onto a role. An admin (full set) is
// unrestricted, which is the intended behaviour.
interface RoleBody {
name: string;
permissions: string[];
}
interface UpdateBody {
name?: string;
permissions?: string[];
}
const VALID = new Set<string>(PERMISSIONS);
/** Validate + dedupe a requested permission list against the code-defined grid. */
function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | { ok: false; bad: string } {
if (!Array.isArray(input)) return { ok: false, bad: "permissions must be an array" };
const out = new Set<Permission>();
for (const p of input) {
if (typeof p !== "string" || !VALID.has(p)) return { ok: false, bad: `unknown permission: ${String(p)}` };
out.add(p as Permission);
}
return { ok: true, perms: [...out] };
}
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requirePermission("role:read");
const createGuard = requirePermission("role:create");
const updateGuard = requirePermission("role:update");
const deleteGuard = requirePermission("role:delete");
/** A role + its permission list + how many users hold it. */
function roleView(roleId: string) {
const role = db.select().from(roles).where(eq(roles.id, roleId)).get();
if (!role) return null;
const perms = db
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.where(eq(rolePermissions.roleId, roleId))
.all()
.map((r) => r.permission);
const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length;
// The admin role always reports the full grid (it's enforced in code).
return {
id: role.id,
name: role.name,
builtin: role.builtin === 1,
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
userCount,
};
}
/** Replace a role's permission rows with `perms` (in a single pass). */
function setPermissions(roleId: string, perms: Permission[]): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
for (const p of perms) {
db.insert(rolePermissions).values({ roleId, permission: p }).run();
}
}
// The full permission grid (for the role-composer checkbox UI) + every role.
app.get("/api/roles", { preHandler: readGuard }, async () => {
const all = db.select().from(roles).all();
return {
catalog: PERMISSIONS,
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
};
});
/** Reject any permission in `perms` the caller does not themselves hold — so a
* non-admin can't grant privileges beyond their own. Returns the offending
* permission, or null if all are within the caller's set. (Admin holds the full
* set, so it never trips.) */
function escalates(callerRoleId: string, perms: Permission[]): Permission | null {
const held = permissionsFor(callerRoleId);
return perms.find((p) => !held.has(p)) ?? null;
}
// Create a composable role from a name + a permission set.
app.post<{ Body: RoleBody }>("/api/roles", { preHandler: createGuard }, async (req, reply) => {
const name = (req.body?.name ?? "").trim();
if (!name) return reply.code(400).send({ error: "name required" });
if (db.select().from(roles).where(eq(roles.name, name)).get()) {
return reply.code(409).send({ error: "a role with that name already exists" });
}
const cleaned = cleanPermissions(req.body?.permissions ?? []);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
const over = escalates(req.user.roleId, cleaned.perms);
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run();
setPermissions(id, cleaned.perms);
bumpPermsCache();
return reply.code(201).send(roleView(id));
});
// Edit a role's name and/or permission set. The built-in admin role is locked.
app.put<{ Params: { id: string }; Body: UpdateBody }>(
"/api/roles/:id",
{ preHandler: updateGuard },
async (req, reply) => {
const id = req.params.id;
const role = db.select().from(roles).where(eq(roles.id, id)).get();
if (!role) return reply.code(404).send({ error: "role not found" });
if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
}
if (req.body?.name != null) {
const name = req.body.name.trim();
if (!name) return reply.code(400).send({ error: "name cannot be empty" });
const clash = db.select().from(roles).where(eq(roles.name, name)).get();
if (clash && clash.id !== id) return reply.code(409).send({ error: "a role with that name already exists" });
db.update(roles).set({ name }).where(eq(roles.id, id)).run();
}
if (req.body?.permissions != null) {
const cleaned = cleanPermissions(req.body.permissions);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
const over = escalates(req.user.roleId, cleaned.perms);
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
setPermissions(id, cleaned.perms);
}
bumpPermsCache();
return roleView(id);
},
);
// Delete a role. Refused if it's built-in or any user still holds it.
app.delete<{ Params: { id: string } }>(
"/api/roles/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const id = req.params.id;
const role = db.select().from(roles).where(eq(roles.id, id)).get();
if (!role) return reply.code(404).send({ error: "role not found" });
if (role.builtin === 1) {
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;
if (holders > 0) {
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();
db.delete(roles).where(eq(roles.id, id)).run();
bumpPermsCache();
return { ok: true };
},
);
}
+176 -85
View File
@@ -12,7 +12,7 @@ import {
type DeviceCategory, type DeviceCategory,
type DeviceConfig, type DeviceConfig,
} from "@parking/devices"; } from "@parking/devices";
import { requireRole } from "../auth.js"; import { requirePermission } from "../auth.js";
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"; import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.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
@@ -51,12 +51,134 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out; return out;
} }
/** Result of the device configure pipeline: a ready-to-persist config, or an
* HTTP error to send back. Shared by assign (create) and patch (edit). */
type ConfigureOutcome =
| { config: Record<string, unknown>; warnings: string[] }
| { error: { code: number; message: string } };
/**
* Validate + configure a device, returning the config to persist. Runs the same
* pipeline for both create and edit: validate the driver config, fix
* preconditions, harden (relay password + protocol lockdown), and set up input
* push (Digest creds + push URLs). Each step is a device write (the device
* reboots on apply). The caller owns the DB row; this never touches the DB.
*
* `id` is the assignment id (stable across an edit) — it's baked into the push
* URL, so editing in place keeps the device pushing to the same path.
* `existingConfig` carries forward secrets the client never sees on edit
* (push/relay passwords), so a PATCH that omits them doesn't wipe them.
*/
async function configureDevice(
app: FastifyInstance,
args: {
id: string;
driverId: string;
config: DeviceConfig;
backendIp?: string;
existingConfig?: Record<string, unknown>;
},
): Promise<ConfigureOutcome> {
const { id, driverId, config, backendIp, existingConfig } = args;
// Start from any machine-only secrets already on the row (push/relay passwords
// are redacted out of the client's copy, so an edit would otherwise drop them),
// then layer the submitted config on top.
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
// The web password the admin typed is a DESIRED value, not a stored fact:
// it's passed to the driver (via create(config) below) as the rotation
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return { error: { code: 400, message: (err as Error).message } };
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return {
error: {
code: 502,
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
},
};
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return {
error: {
code: 400,
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
},
};
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
}
return { config: fullConfig, warnings: hardenWarnings };
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
registerBuiltinDrivers(); registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line)); setDeviceLogSink((line) => app.log.info(line));
// Setup endpoints require an admin (cookie-based JWT — see ../auth.ts). // Device setup is site administration — it changes which hardware the site runs
const adminGuard = requireRole("admin"); // and how readers bind to relays. Gated on site:update. See ../auth.ts.
const adminGuard = requirePermission("site:update");
// Catalog of selectable drivers per category (no secrets — schema only). // Catalog of selectable drivers per category (no secrets — schema only).
// `discoverable` flags drivers that can scan the LAN; `pushCapable` flags // `discoverable` flags drivers that can scan the LAN; `pushCapable` flags
@@ -166,100 +288,69 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
const id = randomUUID(); const id = randomUUID();
const fullConfig: Record<string, unknown> = { ...config }; const outcome = await configureDevice(app, { id, driverId, config, backendIp });
// The web password the admin typed is a DESIRED value, not a stored fact: if ("error" in outcome) {
// it's passed to the driver (via create(config) below) as the rotation return reply.code(outcome.error.code).send({ error: outcome.error.message });
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return reply.code(502).send({
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
});
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return reply.code(400).send({
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
});
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return reply
.code(502)
.send({ error: `device configuration failed: ${(err as Error).message}` });
} }
const row = { const row = {
id, id,
category, category,
driverId, driverId,
config: fullConfig, config: outcome.config,
enabled: true, enabled: true,
}; };
await db.insert(devices).values(row); await db.insert(devices).values(row);
// Don't echo device secrets back (push Digest password, web-UI login, …). // Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({ return reply.code(201).send({
...row, ...row,
config: redactSecrets(fullConfig), config: redactSecrets(outcome.config),
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}), ...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
});
},
);
// Edit an assigned device in place. Same configure pipeline as assign, but it
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
// since the id is baked into the device's input-push URL
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
// break push until reconfigured; PATCH re-runs harden/push against the same id.
// The category and driver are fixed at create time (an edit can't change what
// KIND of device a slot is); only config changes. Admin-only.
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
"/api/setup/assign/:id",
{ preHandler: adminGuard },
async (req, reply) => {
const existing = await db
.select()
.from(devices)
.where(eq(devices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
const { config, backendIp } = req.body;
const outcome = await configureDevice(app, {
id: existing.id,
driverId: existing.driverId,
config,
backendIp,
// Carry forward machine-only secrets the client never received, so an
// edit that omits them doesn't blank out push/relay passwords.
existingConfig: existing.config,
});
if ("error" in outcome) {
return reply.code(outcome.error.code).send({ error: outcome.error.message });
}
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
return reply.code(200).send({
id: existing.id,
category: existing.category,
driverId: existing.driverId,
config: redactSecrets(outcome.config),
enabled: existing.enabled,
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
}); });
}, },
); );
+72 -8
View File
@@ -1,26 +1,90 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js"; import { requirePermission, roleHasPermissions } from "../auth.js";
import { import {
InvalidCashMovementError,
NoOpenShiftError, NoOpenShiftError,
ShiftAlreadyOpenError, ShiftAlreadyOpenError,
type ShiftService, type ShiftService,
} from "../shift-service.js"; } from "../shift-service.js";
interface CashMovementBody {
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
amountMinor: number;
reason?: string;
currency?: string;
}
interface ShiftsQuery {
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
operator?: string;
/** ISO window over shift START time. */
from?: string;
to?: string;
}
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is // Shift endpoints (manned mode). The operator is the logged-in user; a shift is
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and // opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it. // local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> { export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
// Cashier/operator/admin run shifts; readonly can't. // Reading the shift state vs. opening/closing one's own shift.
const guard = requireRole("admin", "operator", "cashier"); const readGuard = requirePermission("shift:read");
const guard = requirePermission("shift:create");
// Is the current operator's shift open? (For the UI to show Start vs. End.) // The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
app.get("/api/shift/current", { preHandler: guard }, async (req) => { // to render the header control: no shift → "Open"; my shift → "Close" (enabled);
const operator = req.user.username; // someone else's shift → disabled. Also returns the live drawer balance.
const open = shift.openShiftFor(operator); // - open: the open shift { startedAt, operator } or null (site-wide)
return { operator, open: open ? { startedAt: open.occurredAt } : null }; // - isMine: true iff the open shift belongs to the requesting operator
// - operator: the requesting user (for the UI's own identity)
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
const me = req.user.username;
const open = shift.currentOpenShift();
const heldBy = open?.identity ?? null;
const drawer = shift.drawerBalance();
return {
operator: me,
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me,
drawerMinor: drawer.balanceMinor,
currency: drawer.currency,
};
}); });
// Completed shift history. SCOPED by permission:
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
// `operator` and a `from`/`to` time window over each shift's START.
// This keeps one operator from reading another's takings while letting admins
// reconcile across the site. The data is the signed shift_z_report chain.
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
const q = req.query ?? {};
// Non-admins are hard-scoped to themselves regardless of any operator param.
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
const shifts = shift.listShifts({ operator, from, to });
return { shifts, scope: canSeeAll ? "all" : "self" };
});
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
app.post<{ Body: CashMovementBody }>(
"/api/cash-movement",
{ preHandler: requirePermission("shift:cash") },
async (req, reply) => {
const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody);
try {
return await shift.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency);
} catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => { app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
try { try {
return await shift.open(req.user.username); return await shift.open(req.user.username);
+81 -12
View File
@@ -1,43 +1,112 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db"; import { eq, siteConfig, type Db } from "@parking/db";
import { requireRole } from "../auth.js"; import { requirePermission } from "../auth.js";
import { getOccupancy } from "../occupancy.js"; import { getOccupancy } from "../occupancy.js";
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed // Site config (capacity) + live occupancy. Occupancy is a fold over the signed
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at // ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md. // capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
interface SiteConfigBody { // Optional park-metadata text fields (all nullable). Trimmed; "" → null.
const TEXT_FIELDS = [
"parkName",
"operatorName",
"nius",
"address",
"phone",
"email",
// IANA timezone for tariff wall-clock windows (copied into each published version).
"timezone",
// Default vehicle/customer category frozen onto each transient entry.
"defaultVehicleCategory",
] as const;
type TextField = (typeof TEXT_FIELDS)[number];
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Nominal capacity; null = no limit. */ /** Nominal capacity; null = no limit. */
capacity?: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault?: boolean;
/** Site default monthly subscription price in minor units (pre-fills the form). */
subscriptionMonthlyPriceMinor?: number | null;
}
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
* + every metadata field. */
type SiteConfig = {
capacity: number | null; capacity: number | null;
exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = {
capacity: row?.capacity ?? null,
exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
} as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
}
/** Trim a text field; empty string becomes null so blank input clears it. */
function normText(v: unknown): string | null {
if (v == null) return null;
const s = String(v).trim();
return s === "" ? null : s;
} }
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requireRole("admin", "operator", "cashier", "readonly"); const readGuard = requirePermission("site:read");
const writeGuard = requireRole("admin"); const writeGuard = requirePermission("site:update");
// Live occupancy: cars inside, capacity, free, full. Any signed-in role. // Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db)); app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Read site config (capacity). // Read site config (capacity + park metadata).
app.get("/api/site-config", { preHandler: readGuard }, async () => { app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return { capacity: row?.capacity ?? null }; return toSiteConfig(row);
}); });
// Set capacity (admin). null or 0+ integer. // Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
// (only the fields PRESENT in the body are updated; absent fields are untouched).
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => { app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
const { capacity } = req.body ?? ({} as SiteConfigBody); const body = req.body ?? ({} as SiteConfigBody);
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
const patch: Partial<typeof siteConfig.$inferInsert> = {};
if ("capacity" in body) {
const c = body.capacity;
if (c != null && (!Number.isInteger(c) || c < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" }); return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
} }
patch.capacity = c ?? null;
}
if ("exitVoucherDefault" in body) {
if (typeof body.exitVoucherDefault !== "boolean") {
return reply.code(400).send({ error: "exitVoucherDefault must be a boolean" });
}
patch.exitVoucherDefault = body.exitVoucherDefault;
}
if ("subscriptionMonthlyPriceMinor" in body) {
const p = body.subscriptionMonthlyPriceMinor;
if (p != null && (!Number.isInteger(p) || p < 0)) {
return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" });
}
patch.subscriptionMonthlyPriceMinor = p ?? null;
}
for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]);
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
if (existing) { if (existing) {
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run(); db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else { } else {
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run(); db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
} }
return { capacity: capacity ?? null }; const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
}); });
} }
+44 -6
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, eq, snapshots, type Db } from "@parking/db"; import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
import { requireRole } from "../auth.js"; import { requirePermission } from "../auth.js";
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see // Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence // packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
@@ -9,14 +9,18 @@ import { requireRole } from "../auth.js";
// never via the API. // never via the API.
export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void> {
const guard = requireRole("admin", "operator", "cashier", "readonly"); const guard = requirePermission("session:read");
// Snapshot metadata for one session/credential identity (NOT the bytes), newest // Snapshot metadata for one session/credential identity (NOT the bytes), newest
// first — lets the UI show "entry/exit image" links beside an event. // first — lets the UI show "entry/exit image" links beside an event. We also return
// FAILED capture attempts (from snapshot telemetry) so the operator can tell a
// camera that was offline from a direction that simply has no camera — otherwise a
// missing shot is a silent gap. See snapshot.ts (recordFailure).
app.get<{ Params: { identity: string } }>( app.get<{ Params: { identity: string } }>(
"/api/snapshots/by-identity/:identity", "/api/snapshots/by-identity/:identity",
{ preHandler: guard }, { preHandler: guard },
async (req) => { async (req) => {
const identity = req.params.identity;
const rows = db const rows = db
.select({ .select({
id: snapshots.id, id: snapshots.id,
@@ -27,10 +31,44 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
capturedAt: snapshots.capturedAt, capturedAt: snapshots.capturedAt,
}) })
.from(snapshots) .from(snapshots)
.where(eq(snapshots.identity, req.params.identity)) .where(eq(snapshots.identity, identity))
.orderBy(desc(snapshots.capturedAt)) .orderBy(desc(snapshots.capturedAt))
.all(); .all();
return { snapshots: rows };
// Failed attempts: kind="snapshot" telemetry whose detail.identity matches and
// detail.ok === false. There may be both a failure and (on a retry) a success
// for the same direction; we keep only failures with NO successful shot in the
// same direction, so a recovered capture doesn't show a stale warning.
const haveDir = new Set<string | null>(rows.map((r) => r.direction));
const telemetry = db
.select({ detail: deviceEvents.detail, deviceId: deviceEvents.deviceId, occurredAt: deviceEvents.occurredAt })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "snapshot")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const failures: {
direction: "entry" | "exit" | null;
deviceId: string;
error: string;
occurredAt: string;
}[] = [];
const seenFailDir = new Set<string>();
for (const row of telemetry) {
const d = (row.detail ?? {}) as { identity?: string; ok?: boolean; error?: string; direction?: string };
if (d.identity !== identity || d.ok !== false) continue;
const dir = d.direction === "entry" || d.direction === "exit" ? d.direction : null;
const dirKey = dir ?? "both";
if (haveDir.has(dir) || seenFailDir.has(dirKey)) continue; // a success exists, or already shown
seenFailDir.add(dirKey);
failures.push({
direction: dir,
deviceId: row.deviceId ?? "",
error: d.error ?? "capture failed",
occurredAt: row.occurredAt ?? "",
});
}
return { snapshots: rows, failures };
}, },
); );
+372
View File
@@ -0,0 +1,372 @@
import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import { requirePermission } from "../auth.js";
import { invalidateHolder } from "../event-enrich.js";
import { printSubscriptionCard } from "../booth-print.js";
import type { CredentialCapture } from "../credential-capture.js";
import { directionOf } from "../device-resolve.js";
// Subscription admin CRUD. A subscription is mutable master data — admins
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
// trail stays append-only (see wiki/entities/subscription.md). A subscription is an
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
// them as one unit (create/update replace the child sets; delete removes all).
//
// Pricing: priceMinor + period ("monthly") + currency record the recurring plan
// (e.g. 10,000 ALL / month). Collecting the fee into the ledger/shift is deferred —
// here we just store the agreed price and the coverage window.
interface Credential {
kind: "rf" | "qr";
/** For RF: the physical card/tag id (required). For QR: optional — left blank, the
* server AUTO-GENERATES an unguessable code (the customer never picks it). */
value?: string;
}
interface SubscriptionBody {
holderName?: string;
contact?: string;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = no price set. */
priceMinor?: number | null;
period?: "monthly";
/** ISO-4217 currency of priceMinor (e.g. "ALL"). */
currency?: string | null;
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
maxConcurrent?: number | null;
validFrom?: string | null;
validTo?: string | null;
/** Months paid for. When set (with validFrom), validTo = validFrom + months — the
* multi-month case (e.g. 3 months). Takes precedence over an explicit validTo. */
months?: number | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
}
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
* delivers the full string over TCP/IP (the host-in-the-loop path), so length is
* free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */
function newQrCode(): string {
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const bytes = randomBytes(15);
let out = "";
for (const b of bytes) out += alphabet[b % 32];
return `SUB-${out}`;
}
/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo →
* Feb 28/29). Returns ISO. */
function addMonths(iso: string, months: number): string {
const d = new Date(iso);
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
// If the month rolled past (e.g. day 31 → next month had fewer days), clamp back.
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString();
}
export async function subscriptionRoutes(
app: FastifyInstance,
db: Db,
capture: CredentialCapture,
): Promise<void> {
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
const readGuard = requirePermission("subscription:read");
const createGuard = requirePermission("subscription:create");
const updateGuard = requirePermission("subscription:update");
const deleteGuard = requirePermission("subscription:delete");
// Validate the body; returns problems (empty = ok). Shared by create + update.
function validate(b: SubscriptionBody): string[] {
const errs: string[] = [];
if (b.maxConcurrent != null) {
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
errs.push("maxConcurrent must be a positive integer, or null for unbound");
}
}
if (b.priceMinor != null) {
if (!Number.isInteger(b.priceMinor) || b.priceMinor < 0) {
errs.push("priceMinor must be a non-negative integer (minor units), or null");
}
if (!b.currency?.trim()) {
errs.push("currency is required when a price is set");
}
}
if (b.period != null && b.period !== "monthly") {
errs.push("period must be 'monthly' (the only period supported today)");
}
if (b.months != null) {
if (!Number.isInteger(b.months) || b.months < 1) {
errs.push("months must be a positive integer");
}
if (!b.validFrom?.trim()) {
errs.push("validFrom is required when months is set (validTo = validFrom + months)");
}
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
for (const c of b.credentials ?? []) {
if (c.kind !== "rf" && c.kind !== "qr") {
errs.push("each credential needs kind (rf|qr)");
break;
}
// RF must carry the physical card id; QR may be blank (server auto-generates).
if (c.kind === "rf" && !c.value?.trim()) {
errs.push("an RF credential needs a non-empty value (the card/tag id)");
break;
}
}
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
}
return errs;
}
function loadAggregate(id: string) {
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
if (!sub) return null;
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
return {
...sub,
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
plates: plates.map((p) => p.plate),
};
}
/** Is this credential value already used by ANY subscription? (Global uniqueness —
* a value is the lane identity, so it must resolve to one subscription.) */
function valueTaken(value: string): boolean {
return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null;
}
/** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */
function mintQrCode(): string {
for (let i = 0; i < 5; i += 1) {
const code = newQrCode();
if (!valueTaken(code)) return code;
}
throw new Error("could not mint a unique QR code");
}
// Replace a subscription's child rows (credentials + plates) from the body. QR
// credentials with no value are SERVER-GENERATED here (the customer never picks the
// code). The generated value is returned via loadAggregate so the UI can print it.
function writeChildren(id: string, b: SubscriptionBody) {
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
for (const c of b.credentials ?? []) {
const supplied = c.value?.trim();
// QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a
// QR being preserved on edit).
const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : "";
if (!value) continue; // guarded by validate(); defensive
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run();
}
for (const p of b.plates ?? []) {
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
}
}
/** Resolve the coverage end: months (validFrom + months) wins over an explicit validTo. */
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
if (b.months != null && b.validFrom?.trim()) return addMonths(b.validFrom.trim(), b.months);
if (b.validTo !== undefined) return b.validTo ?? null;
return fallback;
}
// List all subscriptions (with their credentials + plates).
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
const rows = db.select().from(subscriptions).all();
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
});
// --- Credential capture ("enroll a card") -------------------------------
// The operator picks a reader and presents an RFID card to it; the next read on
// that reader is captured for the form instead of opening a barrier. The OTHER
// reader keeps serving the live flow. Single-shot + TTL. See credential-capture.ts.
// The readers the operator can capture on (entry/exit by their bound relay).
app.get("/api/subscriptions/readers", { preHandler: readGuard }, async () => {
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
return {
readers: rows
.filter((r) => r.enabled)
.map((r) => ({ id: r.id, driverId: r.driverId, direction: directionOf(db, r) })),
};
});
// Arm capture on a reader (by devices.id). Operator-or-admin (booth action).
app.post<{ Body: { deviceId?: string } }>(
"/api/subscriptions/capture/arm",
{ preHandler: readGuard },
async (req, reply) => {
const deviceId = (req.body?.deviceId ?? "").trim();
if (!deviceId) return reply.code(400).send({ error: "deviceId required" });
const reader = db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!reader || reader.category !== "reader" || !reader.enabled) {
return reply.code(404).send({ error: "no such enabled reader" });
}
return capture.arm(deviceId);
},
);
// Poll the capture state (idle | armed | captured | expired). The form polls this
// and, on "captured", reads `value` into the credential field then clears it.
app.get("/api/subscriptions/capture", { preHandler: readGuard }, async () => capture.state());
// Operator cancelled / closed the form — disarm and clear any result.
app.post("/api/subscriptions/capture/cancel", { preHandler: readGuard }, async () => {
capture.cancel();
capture.clear();
return { ok: true };
});
// Create a subscription.
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
const id = randomUUID();
db.insert(subscriptions)
.values({
id,
holderName: b.holderName ?? null,
contact: b.contact ?? null,
priceMinor: b.priceMinor ?? null,
period: b.period ?? "monthly",
currency: b.priceMinor != null ? (b.currency ?? null) : null,
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: resolveValidTo(b, null),
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
const sub = loadAggregate(id);
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
// a print failure NEVER fails the create (the subscription + its code are saved);
// the response carries { printed, printError } so the UI can warn + offer reprint.
const printResult = await tryPrintCard(sub);
return reply.code(201).send({ ...sub, ...printResult });
});
/** The first QR credential's code for a subscription aggregate, or null. */
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
const cred = sub?.credentials.find((c) => c.kind === "qr");
return cred?.value ?? null;
}
/** Best-effort print of a subscription's QR card. Returns a flag + optional error
* (never throws). No QR credential → nothing to print (printed:false, no error). */
async function tryPrintCard(
sub: ReturnType<typeof loadAggregate>,
): Promise<{ printed: boolean; printedBy?: string; printError?: string }> {
const code = qrCodeOf(sub);
if (!sub || !code) return { printed: false };
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return { printed: true, printedBy };
} catch (err) {
const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`);
return { printed: false, printError };
}
}
// Update a subscription (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
"/api/subscriptions/:id",
{ preHandler: updateGuard },
async (req, reply) => {
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "subscription not found" });
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
db.update(subscriptions)
.set({
holderName: b.holderName ?? null,
contact: b.contact ?? null,
priceMinor: b.priceMinor === undefined ? existing.priceMinor : b.priceMinor,
period: b.period ?? existing.period,
currency:
b.priceMinor === undefined
? existing.currency
: b.priceMinor != null
? (b.currency ?? null)
: null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: resolveValidTo(b, existing.validTo),
status: b.status ?? existing.status,
})
.where(eq(subscriptions.id, req.params.id))
.run();
writeChildren(req.params.id, b);
// The holder name may have changed — drop the feed-label cache for this sub.
invalidateHolder(req.params.id);
return loadAggregate(req.params.id);
},
);
// Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the
// customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if
// the subscription is gone; 409 if it has no QR credential; 503 if no printer.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/print",
{ preHandler: readGuard },
async (req, reply) => {
const sub = loadAggregate(req.params.id);
if (!sub) return reply.code(404).send({ error: "subscription not found" });
const code = qrCodeOf(sub);
if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" });
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
// Revoke (soft): the common case — keeps the subscription + its history, just bars
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
// DELETE only to fully remove one created in error.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/revoke",
{ preHandler: updateGuard },
async (req, reply) => {
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
return loadAggregate(req.params.id);
},
);
// Hard delete a subscription + its child rows. (Past ledger events that reference it
// are untouched — the audit trail is append-only and independent of this row.)
app.delete<{ Params: { id: string } }>(
"/api/subscriptions/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) 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);
return reply.code(204).send();
},
);
}
+48 -10
View File
@@ -1,8 +1,11 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, eq, tariffVersions, tariffs, type Db } from "@parking/db"; import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { validateTariffStructure, type TariffStructure } from "@parking/shared"; import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared";
import { requireRole } from "../auth.js"; import { requirePermission } from "../auth.js";
/** Default site timezone for wall-clock tariff windows when none is configured. */
const DEFAULT_TZ = "Europe/Tirane";
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs // Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never // are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
@@ -20,10 +23,10 @@ interface PublishBody {
const SITE_TARIFF_NAME = "Site tariff"; const SITE_TARIFF_NAME = "Site tariff";
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Any signed-in role may READ the tariff (the pay station / operator UI needs it). // Reading the rate card (pay station / operator UI needs it).
const readGuard = requireRole("admin", "operator", "cashier", "readonly"); const readGuard = requirePermission("tariff:read");
// Only an admin may PUBLISH a new version (it changes what customers are charged). // Publishing a new version changes what customers are charged.
const writeGuard = requireRole("admin"); 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.
function ensureSiteTariff(): string { function ensureSiteTariff(): string {
@@ -58,18 +61,53 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
if (!currency || typeof currency !== "string" || currency.length < 3) { if (!currency || typeof currency !== "string" || currency.length < 3) {
return reply.code(400).send({ error: "currency (ISO 4217) required" }); return reply.code(400).send({ error: "currency (ISO 4217) required" });
} }
const problems = validateTariffStructure(structure); // For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
// validation that requires tz passes. A V1 (bare) structure is left untouched.
let toStore: TariffStructure = structure;
if (structure && isTariffV2(structure)) {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
toStore = { ...structure, tz };
}
const problems = validateTariffStructure(toStore);
if (problems.length) { if (problems.length) {
return reply.code(400).send({ error: "invalid tariff structure", problems }); return reply.code(400).send({ error: "invalid tariff structure", problems });
} }
// effectiveFrom must NOT be in the past. A version is selected by
// "latest effectiveFrom <= entry time", so a backdated effectiveFrom would
// retroactively reprice already-entered sessions — exactly the immutability
// the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid
// backdating: a new version applies only from publish (now) forward; a future
// effectiveFrom (scheduling a price change) is allowed. A small skew tolerance
// absorbs client/server clock drift + request round-trip. Once a car has
// entered, no later publish can reprice it (no effectiveFrom can predate it).
const now = Date.now();
const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack
let effective = new Date().toISOString();
if (effectiveFrom != null) {
const t = Date.parse(effectiveFrom);
if (Number.isNaN(t)) {
return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" });
}
if (t < now - SKEW_MS) {
return reply.code(400).send({
error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions",
});
}
effective = new Date(t).toISOString();
}
const tariffId = ensureSiteTariff(); const tariffId = ensureSiteTariff();
const id = randomUUID(); const id = randomUUID();
const row = { const row = {
id, id,
tariffId, tariffId,
effectiveFrom: effectiveFrom ?? new Date().toISOString(), effectiveFrom: effective,
currency, currency,
structure: structure as unknown as Record<string, unknown>, structure: toStore as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null, createdBy: req.user?.username ?? null,
}; };
db.insert(tariffVersions).values(row).run(); db.insert(tariffVersions).values(row).run();
+245
View File
@@ -0,0 +1,245 @@
import { randomUUID } from "node:crypto";
import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { eq, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID } from "@parking/shared";
import { permissionsFor, requirePermission } from "../auth.js";
// 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
// role (RBAC); the role resolves to a permission set at request time. Passwords
// are bcrypt-hashed (cost 12) and never returned. See @parking/shared PERMISSIONS.
//
// NO-LOCKOUT INVARIANT: the app refuses to delete, or move off the `admin` role,
// the LAST user still holding `admin`. Administration can therefore never be
// locked out of the appliance. See wiki/entities/local-jwt-auth.md.
//
// PRIVILEGE-ESCALATION GUARD: a non-admin caller with `user:*` must NOT be able to
// (a) ASSIGN a role whose permissions exceed their own (e.g. hand themselves or a
// peer the admin role, or any role broader than theirs), nor (b) MODIFY a user who
// already holds a role broader than the caller's (resetting an admin's password is
// account takeover; deleting an admin is sabotage). Both are blocked below by
// comparing permission SETS. An admin holds the full set, so it is unrestricted.
// Optional profile metadata accepted on create/update. All nullable; "" is treated
// as "clear" (→ null). Trimmed before persisting.
interface ProfileBody {
fullName?: string | null;
phone?: string | null;
email?: string | null;
address?: string | null;
}
interface CreateBody extends ProfileBody {
username: string;
password: string;
roleId: string;
}
interface UpdateBody extends ProfileBody {
username?: string;
roleId?: string;
}
interface PasswordBody {
password: string;
}
const MIN_PASSWORD = 8;
const PROFILE_FIELDS = ["fullName", "phone", "email", "address"] as const;
/** Pull the optional profile fields out of a body → a patch of trimmed values
* ("" → null). Absent keys are omitted (so an update only touches what's sent). */
function profilePatch(body: ProfileBody): Record<string, string | null> {
const out: Record<string, string | null> = {};
for (const k of PROFILE_FIELDS) {
const v = body[k];
if (v === undefined) continue;
const trimmed = typeof v === "string" ? v.trim() : "";
out[k] = trimmed === "" ? null : trimmed;
}
return out;
}
export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requirePermission("user:read");
const createGuard = requirePermission("user:create");
const updateGuard = requirePermission("user:update");
const deleteGuard = requirePermission("user:delete");
/** Count users currently holding the protected admin role. */
function adminCount(): number {
return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length;
}
/** True if removing/relocating `userId` from admin would leave zero admins. */
function isLastAdmin(userId: string): boolean {
const u = db.select().from(users).where(eq(users.id, userId)).get();
return u?.roleId === ADMIN_ROLE_ID && adminCount() <= 1;
}
/** A user row safe to return — never the password hash. */
function publicUser(u: {
id: string;
username: string;
roleId: string;
language: string;
createdAt: string;
fullName?: string | null;
phone?: string | null;
email?: string | null;
address?: string | null;
}) {
return {
id: u.id,
username: u.username,
roleId: u.roleId,
language: u.language,
createdAt: u.createdAt,
fullName: u.fullName ?? null,
phone: u.phone ?? null,
email: u.email ?? null,
address: u.address ?? null,
};
}
/** True if `targetRoleId` grants any permission the caller's role does NOT hold,
* i.e. assigning or touching it would let the caller act beyond their own
* privileges. (Admin holds the full set, so it never trips.) */
function exceedsCaller(callerRoleId: string, targetRoleId: string): boolean {
if (callerRoleId === targetRoleId) return false;
const held = permissionsFor(callerRoleId);
for (const p of permissionsFor(targetRoleId)) {
if (!held.has(p)) return true;
}
return false;
}
// List all users (no password hashes) + their role names for display.
app.get("/api/users", { preHandler: readGuard }, async () => {
const rows = db.select().from(users).all();
const roleRows = db.select().from(roles).all();
const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
return {
users: rows.map((u) => ({ ...publicUser(u), roleName: roleName.get(u.roleId) ?? u.roleId })),
};
});
// Create a user. Username unique; password >= 8 chars; roleId must exist.
app.post<{ Body: CreateBody }>("/api/users", { preHandler: createGuard }, async (req, reply) => {
const username = (req.body?.username ?? "").trim();
const password = req.body?.password ?? "";
const roleId = (req.body?.roleId ?? "").trim();
if (!username || !roleId) {
return reply.code(400).send({ error: "username and roleId required" });
}
if (password.length < MIN_PASSWORD) {
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
}
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
return reply.code(400).send({ error: "unknown roleId" });
}
// No-escalation: can't create a user with a role broader than your own.
if (exceedsCaller(req.user.roleId, roleId)) {
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()) {
return reply.code(409).send({ error: "username already exists" });
}
const id = randomUUID();
const passwordHash = await bcrypt.hash(password, 12);
db.insert(users).values({ id, username, passwordHash, roleId, ...profilePatch(req.body) }).run();
const created = db.select().from(users).where(eq(users.id, id)).get()!;
return reply.code(201).send(publicUser(created));
});
// Update a user's username and/or role. Guarded against orphaning admin.
app.put<{ Params: { id: string }; Body: UpdateBody }>(
"/api/users/:id",
{ preHandler: updateGuard },
async (req, reply) => {
const id = req.params.id;
const existing = db.select().from(users).where(eq(users.id, id)).get();
if (!existing) return reply.code(404).send({ error: "user not found" });
// No-escalation: can't modify a user who already outranks you.
if (exceedsCaller(req.user.roleId, existing.roleId)) {
return reply.code(403).send({ error: "cannot modify a user whose role exceeds your own" });
}
const next: { username?: string; roleId?: string } & Record<string, string | null> = {
...profilePatch(req.body ?? {}),
};
if (req.body?.username != null) {
const username = req.body.username.trim();
if (!username) return reply.code(400).send({ error: "username cannot be empty" });
const clash = db.select().from(users).where(eq(users.username, username)).get();
if (clash && clash.id !== id) return reply.code(409).send({ error: "username already exists" });
next.username = username;
}
if (req.body?.roleId != null) {
const roleId = req.body.roleId.trim();
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
return reply.code(400).send({ error: "unknown roleId" });
}
// No-escalation: can't promote a user into a role broader than your own.
if (exceedsCaller(req.user.roleId, roleId)) {
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
}
// No-lockout: don't move the last admin off the admin role.
if (roleId !== ADMIN_ROLE_ID && isLastAdmin(id)) {
return reply.code(409).send({ error: "cannot change the role of the last admin" });
}
next.roleId = roleId;
}
if (Object.keys(next).length === 0) {
return reply.code(400).send({ error: "nothing to update" });
}
db.update(users).set(next).where(eq(users.id, id)).run();
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
},
);
// Reset a user's password (admin sets a new one; >= 8 chars).
app.put<{ Params: { id: string }; Body: PasswordBody }>(
"/api/users/:id/password",
{ preHandler: updateGuard },
async (req, reply) => {
const id = req.params.id;
const target = db.select().from(users).where(eq(users.id, id)).get();
if (!target) {
return reply.code(404).send({ error: "user not found" });
}
// No-escalation: can't reset the password of a user who outranks you
// (that would be account takeover of a more-privileged account).
if (exceedsCaller(req.user.roleId, target.roleId)) {
return reply.code(403).send({ error: "cannot reset the password of a user whose role exceeds your own" });
}
const password = req.body?.password ?? "";
if (password.length < MIN_PASSWORD) {
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
}
const passwordHash = await bcrypt.hash(password, 12);
db.update(users).set({ passwordHash }).where(eq(users.id, id)).run();
return { ok: true };
},
);
// Delete a user. Refused if it's the last admin (no-lockout).
app.delete<{ Params: { id: string } }>(
"/api/users/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const id = req.params.id;
const target = db.select().from(users).where(eq(users.id, id)).get();
if (!target) {
return reply.code(404).send({ error: "user not found" });
}
// No-escalation: can't delete a user who outranks you.
if (exceedsCaller(req.user.roleId, target.roleId)) {
return reply.code(403).send({ error: "cannot delete a user whose role exceeds your own" });
}
if (isLastAdmin(id)) {
return reply.code(409).send({ error: "cannot delete the last admin" });
}
db.delete(users).where(eq(users.id, id)).run();
return { ok: true };
},
);
}
+117
View File
@@ -0,0 +1,117 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js";
import { deviceEvents } from "../device-events.js";
import { enrichEvent } from "../event-enrich.js";
import type { DeviceMonitor } from "../device-monitor.js";
import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
// server-pushed updates instead of polling: each signed ledger append (entry,
// exit, payment, void) is fanned out, and the recomputed occupancy rides along
// so the screen's count stays exact (occupancy is a fold over the same ledger,
// never a counter). Printer-status changes are forwarded too.
//
// Auth: the handshake is a normal GET through Fastify's lifecycle, so the same
// HttpOnly JWT cookie that guards the REST API guards this. We verify the JWT and
// role here. A browser's WebSocket constructor cannot set custom headers, so the
// CSRF double-submit header the REST mutations use is unavailable — which would
// leave the socket open to Cross-Site WebSocket Hijacking: a malicious page in the
// operator's browser could open ws://<booth>/api/ws, the browser would auto-attach
// the HttpOnly cookie, and the attacker would receive the live entry/exit/payment
// stream. The cookie alone is NOT a control here. So we replace the CSRF check with
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
/** Permission required to watch the live feed (a read-only stream of ledger +
* device status). Any role granted `report:read` may watch. */
const WATCH_PERMISSION = "report:read" as const;
/**
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
* (comma-separated) for a booth UI served from a different origin. A missing or
* mismatched Origin is rejected — that is the anti-CSWSH control.
*/
function isAllowedOrigin(origin: string | undefined, host: string | undefined): boolean {
if (!origin) return false; // no Origin → not a same-origin browser request
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
return false; // malformed Origin
}
if (host && originHost === host) return true; // same-origin (any scheme/port match via host)
const allow = (process.env.WS_ALLOWED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return allow.includes(origin);
}
type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown };
export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> {
app.get(
"/api/ws",
{
websocket: true,
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
// role. Reject a cross/absent origin before touching the token, so a hijack
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
preHandler: async (req) => {
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
}
await req.jwtVerify();
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
}
},
},
(socket) => {
const send = (msg: OutMsg) => {
// readyState 1 = OPEN; never throw out of an event-bus callback.
if (socket.readyState === 1) {
try {
socket.send(JSON.stringify(msg));
} catch {
/* drop on a broken socket */
}
}
};
// Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer).
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() });
// Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative.
const offLedger = deviceEvents.onLedger((event) => {
// Enrich with read-time display fields (subscriber name) before fan-out.
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) });
});
const offPrinter = deviceEvents.onPrinterStatus((event) => {
send({ kind: "printer-status", event });
});
// Unified device status (all categories) for the booth footer — pushed on
// change; the initial set rode the hello above.
const offDevice = deviceEvents.onDeviceStatus((event) => {
send({ kind: "device-status", event });
});
socket.on("close", () => {
offLedger();
offPrinter();
offDevice();
});
},
);
}
+68 -19
View File
@@ -1,24 +1,29 @@
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt"; import jwt from "@fastify/jwt";
import websocket from "@fastify/websocket";
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js"; import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
import { deviceEvents } from "./device-events.js"; 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 { PayStation } from "./pay-station.js"; import { PayStation } from "./pay-station.js";
import { PermitFlow } from "./permit-flow.js"; import { SubscriptionFlow } from "./subscription-flow.js";
import { ShiftService } from "./shift-service.js"; import { ShiftService } from "./shift-service.js";
import { ReadDispatcher } from "./read-dispatch.js"; import { ReadDispatcher } from "./read-dispatch.js";
import { CredentialCapture } from "./credential-capture.js";
import { PrinterMonitor } from "./printer-monitor.js"; import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js"; import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js";
import { roleRoutes } from "./routes/roles.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.js"; import { payRoutes } from "./routes/pay.js";
import { permitRoutes } from "./routes/permits.js"; import { subscriptionRoutes } from "./routes/subscriptions.js";
import { qrReaderRoutes } from "./routes/qr-reader.js"; import { qrReaderRoutes } from "./routes/qr-reader.js";
import { shiftRoutes } from "./routes/shift.js"; import { shiftRoutes } from "./routes/shift.js";
import { siteRoutes } from "./routes/site.js"; import { siteRoutes } from "./routes/site.js";
@@ -26,6 +31,8 @@ import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js"; import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js"; import { setupRoutes } from "./routes/setup.js";
import { deviceStatusRoutes } from "./routes/device-status.js";
import { wsRoutes } from "./routes/ws.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
@@ -42,8 +49,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
const db = opts.db ?? createDb(); const db = opts.db ?? createDb();
// Wire the RBAC permission resolver to this DB (route guards resolve a user's
// role → permission set through it). See auth.ts.
initAuth(db);
await app.register(cookie); await app.register(cookie);
// WebSocket support for the live booth feed (/api/ws). Registered before the
// routes so the `{ websocket: true }` route option is available.
await app.register(websocket);
// Local JWT signing with a local secret — no external identity provider. // Local JWT signing with a local secret — no external identity provider.
// Fail fast rather than fall back to a known default: a booth machine started // Fail fast rather than fall back to a known default: a booth machine started
// without a real secret would sign tokens anyone could forge (incl. an admin // without a real secret would sign tokens anyone could forge (incl. an admin
@@ -61,6 +76,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie. // Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db); await authRoutes(app, db);
// RBAC administration: compose roles (role:*) + manage users (user:*). The
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
await userRoutes(app, db);
await roleRoutes(app, db);
// 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.
@@ -80,15 +100,32 @@ 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());
// Unified device-status monitor: polls EVERY configured device (relays/readers/
// cameras via healthCheck, printers via rich readStatus) and feeds the booth's
// device-status footer over the WS. Read-only — never drives a relay.
// See wiki/concepts/device-status-monitoring.md.
const deviceMonitor = new DeviceMonitor(db, app.log);
await deviceStatusRoutes(app, deviceMonitor);
app.addHook("onReady", async () => deviceMonitor.start());
app.addHook("onClose", async () => deviceMonitor.stop());
// Append-only signed business LEDGER (ledger_events). Holds only business facts // Append-only signed business LEDGER (ledger_events). Holds only business facts
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw // (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED // button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
// in device_events. The entry flow (TODO) turns an input into a signed // in device_events. The entry flow (TODO) turns an input into a signed
// vehicle_entry once a ticket prints + the barrier is commanded. // vehicle_entry once a ticket prints + the barrier is commanded.
// See wiki/decisions/event-streams-split.md. // See wiki/decisions/event-streams-split.md.
const eventLog = new EventLog(db, buildSigner(app.log)); // The 4th arg is a read-side fan-out fired AFTER each durable append — used to
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
deviceEvents.emitLedger(row),
);
await eventRoutes(app, db, eventLog); await eventRoutes(app, db, eventLog);
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db, deviceMonitor);
// 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);
@@ -103,36 +140,48 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onClose", async () => unsubscribeEntry()); app.addHook("onClose", async () => unsubscribeEntry());
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the // Read-driven flows: a credential read (ticket scan / plate / card) routes via the
// dispatcher to either the PERMIT flow (if it matches a permit) or the transient // dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md. // transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
// parking-session.md.
const exitFlow = new ExitFlow(db, eventLog, app.log); const exitFlow = new ExitFlow(db, eventLog, app.log);
const permitFlow = new PermitFlow(db, eventLog, app.log); const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log);
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log); const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
const unsubscribeRead = deviceEvents.onRead((e) => { const unsubscribeRead = deviceEvents.onRead((e) => {
void readDispatcher.dispatch(e); void readDispatcher.dispatch(e);
}); });
app.addHook("onClose", async () => unsubscribeRead()); app.addHook("onClose", async () => unsubscribeRead());
// 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
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
const credentialCapture = new CredentialCapture();
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON // GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
// verdict (host-in-the-loop, synchronous). Routes the read through the dispatcher // verdict (host-in-the-loop, synchronous). The capture service can intercept a read
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md. // on an armed reader for enrollment; otherwise the read routes through the
await qrReaderRoutes(app, db, readDispatcher); // dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
// (sum payments by tender, print the Z-report). Constructed before the pay routes
// because the booth money path is GATED on an open shift. See wiki/concepts/shift.md.
const shiftService = new ShiftService(db, eventLog, app.log);
// Pay station (pay-on-foot): quote an open session against the active tariff + // Pay station (pay-on-foot): quote an open session against the active tariff +
// take payment → signed `payment` event. See wiki/concepts/tariff.md. // take payment → signed `payment` event. The booth pay/exit/voucher/re-open
// 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, payStation); await payRoutes(app, db, payStation, exitFlow, shiftService);
// 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.
await tariffRoutes(app, db); await tariffRoutes(app, db);
// Permit (subscription) admin CRUD. See wiki/entities/permit.md. // Subscription admin CRUD + credential capture (arm/poll/cancel). See
await permitRoutes(app, db); // wiki/entities/subscription.md.
await subscriptionRoutes(app, db, credentialCapture);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report // Shift open/close + drawer endpoints (shiftService constructed above).
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
const shiftService = new ShiftService(db, eventLog, app.log);
await shiftRoutes(app, shiftService); await shiftRoutes(app, shiftService);
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry // Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
+277 -20
View File
@@ -11,9 +11,16 @@ import type { EventLog } from "./event-log.js";
// See wiki/concepts/shift.md. // See wiki/concepts/shift.md.
export class ShiftAlreadyOpenError extends Error { export class ShiftAlreadyOpenError extends Error {
constructor(operator: string) { /** The operator who currently holds the open shift (may be someone else). */
super(`operator ${operator} already has an open shift`); readonly heldBy: string;
constructor(operator: string, heldBy: string) {
super(
heldBy === operator
? `operator ${operator} already has an open shift`
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
);
this.name = "ShiftAlreadyOpenError"; this.name = "ShiftAlreadyOpenError";
this.heldBy = heldBy;
} }
} }
export class NoOpenShiftError extends Error { export class NoOpenShiftError extends Error {
@@ -22,6 +29,33 @@ export class NoOpenShiftError extends Error {
this.name = "NoOpenShiftError"; this.name = "NoOpenShiftError";
} }
} }
/** Thrown by the booth money path when NO shift is open site-wide — an operator
* must open a shift before any payment/exit can be attributed to a shift. */
export class NoShiftOpenError extends Error {
constructor() {
super("no shift is open — open a shift before processing tickets");
this.name = "NoShiftOpenError";
}
}
/** A COMPLETED shift, reconstructed from its signed `shift_z_report` (which carries
* all the figures in its payload). This is the unit of the shift-history feature.
* `id` is the z_report's ledger id (stable, for the UI list key / future deep-link). */
export interface ShiftSummary {
readonly id: string;
readonly index: number;
readonly operator: string;
readonly startedAt: string;
readonly endedAt: string;
readonly cashTotalMinor: number;
readonly cardTotalMinor: number;
readonly currency: string | null;
readonly paymentCount: number;
readonly openingFloatMinor: number;
readonly cashAddedMinor: number;
readonly cashRemovedMinor: number;
readonly expectedDrawerMinor: number;
}
export interface ShiftReport { export interface ShiftReport {
readonly operator: string; readonly operator: string;
@@ -31,9 +65,25 @@ export interface ShiftReport {
readonly cardTotalMinor: number; readonly cardTotalMinor: number;
readonly currency: string | null; readonly currency: string | null;
readonly paymentCount: number; readonly paymentCount: number;
// --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number;
/** Admin cash LOADED into the drawer during the shift (sum of + movements). */
readonly cashAddedMinor: number;
/** Admin cash REMOVED from the drawer during the shift (sum of − movements, as +). */
readonly cashRemovedMinor: number;
/** Expected drawer at close = opening + cashTaken + added − removed. Carries forward. */
readonly expectedDrawerMinor: number;
readonly printed: boolean; readonly printed: boolean;
} }
export class InvalidCashMovementError extends Error {
constructor(msg: string) {
super(msg);
this.name = "InvalidCashMovementError";
}
}
export class ShiftService { export class ShiftService {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
@@ -45,6 +95,12 @@ export class ShiftService {
this.#logger = logger; this.#logger = logger;
} }
/** Current physical drawer balance (cash payments + cash_movements, by time). For
* the UI to show "inherited / in the drawer now". */
drawerBalance(): { balanceMinor: number; currency: string | null } {
return this.#drawerBalanceAt(new Date().toISOString());
}
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */ /** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
openShiftFor(operator: string) { openShiftFor(operator: string) {
// Scan shift events for this operator; the shift is open if the most recent // Scan shift events for this operator; the shift is open if the most recent
@@ -60,19 +116,172 @@ export class ShiftService {
return last && last.type === "shift_open" ? last : null; return last && last.type === "shift_open" ? last : null;
} }
/** Open a shift for the operator (explicit start). */ /**
async open(operator: string): Promise<{ startedAt: string }> { * The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator); * period: at most ONE may be open at a time (so booth takings are unambiguously
* attributed to one operator). It's open iff the most recent shift event on the
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
* appended yet). Returns that row so callers can read its operator/startedAt.
*/
currentOpenShift() {
const rows = this.#db
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null;
}
/**
* COMPLETED shift history, newest first. Each closed shift is one signed
* `shift_z_report` whose payload already holds every figure, so this is a simple
* read of those rows (no re-summing). Optional filters:
* - operator: only this operator's shifts (the `identity` on the z_report).
* - from/to: ISO timestamps; keep shifts whose START falls in [from, to].
* The open shift (no z_report yet) is intentionally excluded — it's not a
* completed accountability period. Use `currentOpenShift()` for the live one.
*/
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "shift_z_report"))
.orderBy(ledgerEvents.index)
.all();
const out: ShiftSummary[] = [];
for (const r of rows) {
const pl = (r.payload ?? {}) as LedgerPayload & {
operator?: string;
startedAt?: string;
endedAt?: string;
cashTotalMinor?: number;
cardTotalMinor?: number;
paymentCount?: number;
openingFloatMinor?: number;
cashAddedMinor?: number;
cashRemovedMinor?: number;
expectedDrawerMinor?: number;
};
const operator = pl.operator ?? r.identity ?? "?";
const startedAt = pl.startedAt ?? r.occurredAt;
if (opts.operator && operator !== opts.operator) continue;
if (opts.from && startedAt < opts.from) continue;
if (opts.to && startedAt > opts.to) continue;
out.push({
id: r.id,
index: r.index,
operator,
startedAt,
endedAt: pl.endedAt ?? r.occurredAt,
cashTotalMinor: pl.cashTotalMinor ?? 0,
cardTotalMinor: pl.cardTotalMinor ?? 0,
currency: pl.currency ?? null,
paymentCount: pl.paymentCount ?? 0,
openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
expectedDrawerMinor: pl.expectedDrawerMinor ?? 0,
});
}
// Newest first for the history list.
return out.reverse();
}
/** Require an open shift for the booth money path; returns it or throws. */
requireOpenShift() {
const open = this.currentOpenShift();
if (!open) throw new NoShiftOpenError();
return open;
}
/**
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
* payments add to the drawer; card payments never touch it; cash_movement amounts
* (signed: + load, − removal) adjust it. This is what carries across shifts.
*/
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
const rows = this.#db
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement"));
let balanceMinor = 0;
let currency: string | null = null;
for (const r of rows) {
const pl = (r.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (r.type === "payment") {
// Only CASH enters the till; card settles to the bank.
if (pl.tender !== "card") balanceMinor += amt;
} else {
// cash_movement amount is signed (+ load, − removal).
balanceMinor += amt;
}
if (pl.currency) currency = pl.currency;
}
return { balanceMinor, currency };
}
/**
* Record an admin cash movement (load/remove drawer float). `amountMinor` is
* signed: positive = cash loaded IN, negative = cash taken OUT. Signed +
* attributed. Admin-only is enforced at the route. Returns the new drawer balance.
*/
async recordCashMovement(
operator: string,
amountMinor: number,
reason: string,
currency?: string,
): Promise<{ amountMinor: number; balanceMinor: number }> {
if (!Number.isInteger(amountMinor) || amountMinor === 0) {
throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)");
}
const now = new Date().toISOString();
await this.#log.append({
type: "cash_movement",
source: "manual",
identity: operator, // who moved the cash (admin)
payload: {
amountMinor,
...(reason ? { reason } : {}),
...(currency ? { currency } : {}),
operator,
},
occurredAt: now,
});
const { balanceMinor } = this.#drawerBalanceAt(now);
this.#logger.info(
`cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
);
return { amountMinor, balanceMinor };
}
/** Open a shift for the operator (explicit start). The opening float is auto-
* inherited from the chain = the drawer balance at the start instant. */
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
// operator's own (double-open) or another operator's (handover not done). Only
// one accountability period at a time.
const current = this.currentOpenShift();
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
await this.#log.append({ await this.#log.append({
type: "shift_open", type: "shift_open",
source: "manual", source: "manual",
identity: operator, // the shift's operator; `identity` keys the shift to them identity: operator, // the shift's operator; `identity` keys the shift to them
payload: { operator }, // Record the inherited opening float on the shift_open so it's reproducible
// and the next operator's handover figure is fixed in the chain.
payload: { operator, openingFloatMinor },
occurredAt: startedAt, occurredAt: startedAt,
}); });
this.#logger.info(`shift opened for ${operator}`); this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
return { startedAt }; return { startedAt, openingFloatMinor };
} }
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */ /** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
@@ -102,6 +311,50 @@ export class ShiftService {
if (pl.currency) currency = pl.currency; if (pl.currency) currency = pl.currency;
} }
// --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start);
// fall back to a fresh fold if an older shift_open lacks it.
const openPl = (open.payload ?? {}) as LedgerPayload & { openingFloatMinor?: number };
const openingFloatMinor =
typeof openPl.openingFloatMinor === "number"
? openPl.openingFloatMinor
: this.#drawerBalanceAt(startedAt).balanceMinor;
// Cash movements within the shift window, split into added (+) and removed (−).
const movements = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "cash_movement"))
.all()
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
let cashAddedMinor = 0;
let cashRemovedMinor = 0;
for (const m of movements) {
const pl = (m.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (amt >= 0) cashAddedMinor += amt;
else cashRemovedMinor += -amt; // store as a positive magnitude
if (pl.currency) currency = pl.currency;
}
// Expected drawer at close = opening + cash taken + added − removed. This is the
// figure the NEXT shift inherits as its opening float.
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
const report: Omit<ShiftReport, "printed"> = {
operator,
startedAt,
endedAt,
cashTotalMinor,
cardTotalMinor,
currency,
paymentCount: payments.length,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
expectedDrawerMinor,
};
await this.#log.append({ await this.#log.append({
type: "shift_z_report", type: "shift_z_report",
source: "manual", source: "manual",
@@ -114,23 +367,20 @@ export class ShiftService {
cardTotalMinor, cardTotalMinor,
currency: currency ?? undefined, currency: currency ?? undefined,
paymentCount: payments.length, paymentCount: payments.length,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
expectedDrawerMinor,
}, },
}); });
const printed = await this.#printZReport({ const printed = await this.#printZReport(report);
operator,
startedAt,
endedAt,
cashTotalMinor,
cardTotalMinor,
currency,
paymentCount: payments.length,
});
this.#logger.info( this.#logger.info(
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments)`, `shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` +
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
); );
return { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency, paymentCount: payments.length, printed }; return { ...report, printed };
} }
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event /** Print the Z-report on a booth-receipt printer (best-effort; the signed event
@@ -151,9 +401,16 @@ export class ShiftService {
`Payments: ${r.paymentCount}`, `Payments: ${r.paymentCount}`,
`Cash: ${money(r.cashTotalMinor)} ${cur}`, `Cash: ${money(r.cashTotalMinor)} ${cur}`,
`Card: ${money(r.cardTotalMinor)} ${cur}`, `Card: ${money(r.cardTotalMinor)} ${cur}`,
"",
"-- Drawer --",
`Opening float: ${money(r.openingFloatMinor)} ${cur}`,
`Cash taken: ${money(r.cashTotalMinor)} ${cur}`,
`Cash added: ${money(r.cashAddedMinor)} ${cur}`,
`Cash removed: ${money(r.cashRemovedMinor)} ${cur}`,
`Expected drawer: ${money(r.expectedDrawerMinor)} ${cur}`,
]; ];
try { try {
await printer.printReport({ title: "SHIFT Z-REPORT", lines }); await printer.printReport({ title: "RAPORT TURNI", lines });
return true; return true;
} catch (err) { } catch (err) {
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`); this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
+27
View File
@@ -58,3 +58,30 @@ export function buildSigner(log?: { warn: (msg: string) => void }): Signer {
"event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.", "event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.",
); );
} }
/**
* Resolve the signer that can VERIFY an existing event, by its stored `keyId`.
* Appends always use the one signer from buildSigner(), but a chain can contain
* events signed under different keys across a rotation (e.g. the JWT_SECRET
* fallback before a dedicated EVENT_SIGNING_KEY was set, or an ATECC608 swap).
* Each event stores its own `keyId`, so verifyChain() must check each row against
* the key that produced it — not the current append-signer. Returns undefined for
* an unknown keyId (the key is gone / not configured), which verifyChain surfaces
* as a distinct failure rather than a false "tampered" alarm.
*
* TODO(atecc608): add an "atecc608-slotN" case returning a public-key verifier.
*/
export function buildVerifier(keyId: string): Signer | undefined {
switch (keyId) {
case "sw-hmac-v2": {
const k = process.env.EVENT_SIGNING_KEY;
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-v2") : undefined;
}
case "sw-hmac-jwtfallback": {
const k = process.env.JWT_SECRET;
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-jwtfallback") : undefined;
}
default:
return undefined;
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
interface SnapshotJob { interface SnapshotJob {
readonly db: Db; readonly db: Db;
readonly direction: FlowDirection; readonly direction: FlowDirection;
/** Session/credential ref (ticket id, plate, permit car key) — links to the ledger. */ /** Session/credential ref (ticket id, plate, subscription car key) — links to the ledger. */
readonly identity: string; readonly identity: string;
readonly logger: FastifyBaseLogger; readonly logger: FastifyBaseLogger;
} }
+280
View File
@@ -0,0 +1,280 @@
import { randomUUID } from "node:crypto";
import {
eq,
ledgerEvents,
sessions,
subscriptionCredentials,
subscriptionPlates,
subscriptions,
type Db,
type DeviceRow,
} from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import { reasonPayload, type ReasonCode } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
// paying per stay (they're on a recurring plan). Reached from the read dispatcher
// when a read matches a subscription (not an open ticket). See
// wiki/entities/subscription.md.
//
// Two optional, independent bindings:
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
// subscription's cars may be inside at once; enforced over the session projection.
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
// too (card/QR OR plate). When unset, any car may use the subscription's card/QR.
//
// Direction is inferred from the SUBSCRIPTION's open-session state, NOT the specific
// credential read — so ANY of a subscription's credentials (QR / RFID / NFC / plate)
// may open or close a session. Entry mints a fresh per-occurrence session id (the
// ledger `identity`); a read with no open occurrence → ENTRY; with ≥1 open → EXIT the
// OLDEST open occurrence (FIFO). A fleet (maxConcurrent > 1) thus has several open
// occurrences at once; each read closes one. This decouples exit from the entry
// credential (you can enter with QR and leave with the card).
//
// NB: the SIGNED ledger payload still carries `permitId` (immutable history — see the
// schema note); the per-occurrence `identity` is the session key. The mutable master
// data / code is "subscription"; the on-chain field name is left as-is so historical
// events keep verifying.
export interface SubscriptionMatch {
readonly subscriptionId: string;
/** The specific credential/plate value read (for logging/anomalies). NOT the
* session key — sessions are keyed by subscription occurrence, so a different
* credential of the same subscription can close the session it opened. */
readonly carKey: string;
readonly via: "card" | "qr" | "plate";
}
export class SubscriptionFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** Resolve a read to a subscription (by card/QR credential, or a bound plate), or null. */
match(e: DeviceReadEvent): SubscriptionMatch | null {
// Card / QR / generic credential value.
const cred = this.#db
.select()
.from(subscriptionCredentials)
.where(eq(subscriptionCredentials.value, e.value))
.get();
if (cred) {
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
if (e.kind === "plate") {
const plate = this.#db.select().from(subscriptionPlates).where(eq(subscriptionPlates.plate, e.value)).get();
if (plate) return { subscriptionId: plate.subscriptionId, carKey: e.value, via: "plate" };
}
return null;
}
/** Run the subscription entry/exit for a matched read at a barrier. `resolved` is the
* reader's bound relay; its direction constrains, "both" defers to session state. */
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
const key = `${m.subscriptionId}:${m.carKey}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#run(resolved, e, m);
} catch (err) {
this.#logger.error(`subscription-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
if (!sub) return { accepted: false, reason: await this.#reject(m, "sub.refused.notFound") };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
const invalid =
sub.status !== "active" ||
(sub.validFrom != null && now < sub.validFrom) ||
(sub.validTo != null && now > sub.validTo);
if (invalid) {
const reason = await this.#reject(m, "sub.refused.outOfWindow", { status: sub.status });
return { accepted: false, reason };
}
// Direction: the BARRIER the reader sits at decides the verb — an entry-lane read
// is an ENTRY, an exit-lane read is an EXIT. (The credential is decoupled from the
// session, so we can't and needn't infer from "which credential".) A "both" barrier
// has no physical side, so there we infer from state: open occurrence → exit, else
// entry. This is what lets a FLEET admit several cars (each entry-lane read is an
// entry) yet exit any of them with ANY credential (FIFO).
const open = this.#openOccurrences(m.subscriptionId);
const verb: FlowDirection =
resolved.direction === "entry"
? "entry"
: resolved.direction === "exit"
? "exit"
: open.length > 0
? "exit"
: "entry";
const source = m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand";
if (verb === "exit") {
// EXIT: close the OLDEST open occurrence (FIFO). Its occurrence id is the session
// key; the credential just read may differ from the one that opened it. If the
// subscription has NOTHING open, an exit read is a no-op anti-passback signal.
const oldest = open[0];
if (!oldest) {
const reason = await this.#reject(m, "sub.refused.noSession");
return { accepted: false, direction: "exit", reason };
}
const occurrenceId = oldest.identity;
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source,
identity: occurrenceId,
// `permitId` carries the subscription id; `via` records which credential left.
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, via: m.via },
});
await this.#open(resolved, "exit", occurrenceId, "subscription exit");
this.#closeCache(occurrenceId);
return { accepted: true, direction: "exit" };
}
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a
// fresh per-occurrence id so a fleet can have several open at once.
if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) {
const reason = await this.#reject(m, "sub.refused.atCapacity", {
inUse: open.length,
max: sub.maxConcurrent,
});
return { accepted: false, direction: "entry", reason };
}
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
// the payload's `permitId` (which every fold matches on), so the key stays compact.
const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source,
identity: occurrenceId,
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
// `permitId`/`permit` are the on-chain field names (immutable).
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via },
occurredAt: now,
});
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
try {
this.#db
.insert(sessions)
.values({
id: occurrenceId,
identity: occurrenceId,
source: m.via === "plate" ? "lpr" : "wiegand",
subscriptionId: m.subscriptionId,
enteredAt: now,
state: "open",
})
.run();
} catch (err) {
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
}
return { accepted: true, direction: "entry" };
}
/**
* The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose
* `payload.permitId` is this subscription) with no later `vehicle_exit` on the same
* `identity`. Used to (a) infer entry vs. exit for ANY credential of the
* subscription, (b) pick which occurrence a read closes, and (c) enforce
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
*/
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
const net = new Map<string, number>();
const firstIndex = new Map<string, number>();
for (const r of rows) {
const id = r.identity;
if (!id) continue;
const pl = (r.payload ?? {}) as { permitId?: string };
if (r.type === "vehicle_entry") {
if (pl.permitId !== subscriptionId) continue;
net.set(id, (net.get(id) ?? 0) + 1);
if (!firstIndex.has(id)) firstIndex.set(id, r.index);
} else if (r.type === "vehicle_exit") {
if (!net.has(id)) continue; // not one of this subscription's occurrences
net.set(id, (net.get(id) ?? 0) - 1);
}
}
const open: { identity: string; index: number }[] = [];
for (const [id, n] of net) if (n > 0) open.push({ identity: id, index: firstIndex.get(id) ?? 0 });
open.sort((a, b) => a.index - b.index); // oldest first → FIFO
return open;
}
/** Sign a refused-subscription anomaly with a localizable reason code, and return
* the rendered English reason for the caller's ReadOutcome. */
async #reject(
m: SubscriptionMatch,
code: ReasonCode,
params?: Record<string, string | number>,
): Promise<string> {
const rp = reasonPayload(code, params);
await this.#log.append({
type: "anomaly",
identity: m.carKey,
// `permitId`/`permitRefused` are the on-chain field names (immutable).
payload: { ...rp, permitId: m.subscriptionId, permitRefused: true },
});
this.#logger.warn(`subscription refused (${m.carKey}): ${rp.reason}`);
return rp.reason;
}
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
void snapshotAsync({
db: this.#db,
direction: dir,
identity: carKey,
logger: this.#logger,
}).catch((err) => this.#logger.error(`subscription snapshot error: ${(err as Error).message}`));
}
#closeCache(carKey: string): void {
try {
this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`);
}
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
}
+12 -1
View File
@@ -12,13 +12,24 @@
}, },
"dependencies": { "dependencies": {
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16",
"i18next": "^26.3.1",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7" "react-dom": "19.2.7",
"react-i18next": "^17.0.8",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@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",
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
"tailwindcss": "^4.3.1",
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "8.0.16" "vite": "8.0.16"
} }
+132
View File
@@ -0,0 +1,132 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js";
// Active Sessions panel. A session is "active" while still inside OR exited-but-
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
// possibly-present until grace runs out). Lets the operator find a stuck car —
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
// - click a row → the pay/exit modal (pay an unpaid car, or review),
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
// No payment → no Open barrier button (the no-unpaid-bypass rule).
// See wiki/concepts/booth-exit-flow.md.
function statusBadge(s: ActiveSession): { key: string; cls: string } {
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
}
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
// The audited barrier re-open is a money-path action (server-gated on an open
// shift); disable it unless this operator's shift is open.
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
const shiftReady = shiftOpen && shiftMine;
const { data, isLoading } = useQuery({
queryKey: qk.activeSessions,
queryFn: fetchActiveSessions,
// Belt-and-braces refresh in case a grace window expires with no ledger event
// to invalidate the cache (the WS only pushes on appends).
refetchInterval: 15_000,
});
const reopen = useMutation({
mutationFn: (identity: string) => reopenBarrier(identity),
onSettled: () => {
void qc.invalidateQueries({ queryKey: qk.activeSessions });
void qc.invalidateQueries({ queryKey: qk.events });
},
});
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
const sessions = data?.sessions ?? [];
async function handleReopen(s: ActiveSession) {
setReopenMsg(null);
try {
const r = await reopen.mutateAsync(s.identity);
setReopenMsg({
id: s.identity,
ok: r.opened,
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
});
} catch (e) {
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
}
}
return (
<Panel
title={t("booth.activeSessions")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{sessions.length} {t("booth.insideCount")}
</span>
}
className="min-h-0"
>
<div className="h-full overflow-y-auto pr-1">
{sessions.length === 0 ? (
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
) : (
sessions.map((s) => {
const badge = statusBadge(s);
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
return (
<div
key={s.identity}
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
>
<button
type="button"
onClick={() => onPick(s.identity)}
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
title={t("booth.openPayExit")}
>
<span className="text-term-text">
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
</span>
<span className="text-term-muted">
{t("booth.inAt")} {formatTime(s.enteredAt)}
</span>
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
</button>
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An
unpaid transient has no button (no-unpaid-bypass). */}
{s.paidAt || s.subscription ? (
<button
type="button"
disabled={reopen.isPending || !shiftReady}
onClick={() => handleReopen(s)}
className="btn btn-pay btn-sm shrink-0"
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
>
{t("booth.openBarrier")}
</button>
) : (
<span className="w-[88px] shrink-0" />
)}
{msg && (
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</span>
)}
</div>
);
})
)}
</div>
</Panel>
);
}
+36 -39
View File
@@ -1,15 +1,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchMe, logout, type SessionUser } from "./api.js"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js"; import { Login } from "./Login.js";
import { PermitManager } from "./PermitManager.js"; import { queryClient } from "./lib/query.js";
import { SetupWizard } from "./SetupWizard.js"; import { setLanguage } from "./lib/i18n/index.js";
import { ShiftControl } from "./ShiftControl.js"; import { applyTheme } from "./lib/theme.js";
import { SiteSettings } from "./SiteSettings.js"; import { router } from "./router.js";
import { TariffComposer } from "./TariffComposer.js";
// Operator UI shell. Plain React (no admin framework) — the operator UI is // App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
// simple enough that a framework's abstractions cost more than they save. // off to TanStack Router inside the QueryClient provider. The router renders the
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me. // terminal chrome + screens; auth gating stays here (Login until signed in), and
// the signed-in user flows into the router context for role-based route guards.
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md. // See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
export function App() { export function App() {
@@ -22,37 +24,32 @@ export function App() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>; // Apply the signed-in user's preferred language + theme whenever they resolve/
if (!user) return <Login onLoggedIn={setUser} />; // change (login, bootstrap, or a toggle). Albanian + dark are the defaults before
// auth resolves; on logout, fall back to dark so the Login screen is consistent.
useEffect(() => {
if (user) {
setLanguage(user.language);
applyTheme(user.theme);
} else {
applyTheme("dark");
}
}, [user]);
if (loading) {
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
}
if (!user) {
return (
<QueryClientProvider client={queryClient}>
<Login onLoggedIn={setUser} />
</QueryClientProvider>
);
}
return ( return (
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}> <QueryClientProvider client={queryClient}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}> <RouterProvider router={router} context={{ user, setUser }} />
<h1 style={{ margin: 0 }}>Parking System</h1> </QueryClientProvider>
<span style={{ color: "#555" }}>
{user.username} ({user.role}){" "}
<button
type="button"
onClick={async () => {
await logout();
setUser(null);
}}
>
Log out
</button>
</span>
</header>
<SiteSettings canEdit={user.role === "admin"} />
{user.role !== "readonly" && <ShiftControl />}
{user.role === "admin" ? (
<>
<SetupWizard />
<TariffComposer />
<PermitManager />
</>
) : (
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
)}
</main>
); );
} }
+378
View File
@@ -0,0 +1,378 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
boothExit,
fetchSiteConfig,
lookupSession,
openShift,
paySession,
printReceipt,
printVoucher,
reopenBarrier,
type SessionLookup,
} from "./api.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
// payment, then EITHER prints an exit voucher (customer self-exits at a distant
// exit) OR fires the exit immediately (booth at/near the exit) — controlled by a
// checkbox defaulting from site_config.exitVoucherDefault. See booth-exit-flow.md.
type Phase = "review" | "paying" | "finishing" | "done" | "error";
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
// A shift must be open (and mine) before any pay/exit/voucher action — the booth
// money path is gated. The server enforces this too (409 no_shift); the modal
// surfaces it up front and offers a one-click open. See wiki/concepts/shift.md.
const { isOpen: shiftOpen, isMine: shiftMine, blockedByOther, heldBy } = useShift();
const shiftReady = shiftOpen && shiftMine;
const [tender, setTender] = useState<"cash" | "card">("cash");
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
const [phase, setPhase] = useState<Phase>("review");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const [openingShift, setOpeningShift] = useState(false);
const [reprinting, setReprinting] = useState(false);
const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle.
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
const alreadyPaid = s?.paidAt != null;
const isSubscription = s?.subscription === true;
// A subscription is prepaid: never charged. The only booth action is an audited
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
const canPay = shiftReady && s?.found && s.open && !alreadyPaid && !isSubscription;
async function handleOpenBarrier() {
if (!s) return;
setError(null);
setPhase("finishing");
try {
const r = await reopenBarrier(identity);
setResult(r.opened ? t("pay.subBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }));
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
async function handleOpenShift() {
setOpeningShift(true);
setError(null);
try {
await openShift();
void qc.invalidateQueries({ queryKey: qk.shift });
void qc.invalidateQueries({ queryKey: qk.events });
} catch (e) {
setError((e as Error).message);
} finally {
setOpeningShift(false);
}
}
async function handleReprintReceipt() {
setReprinting(true);
setError(null);
try {
const r = await printReceipt(identity);
setResult(t("pay.receiptReprinted", { printer: r.printedBy }));
} catch (e) {
setError((e as Error).message);
} finally {
setReprinting(false);
}
}
async function handlePayAndExit() {
if (!s) return;
setError(null);
try {
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
if (!alreadyPaid) {
setPhase("paying");
await paySession(identity, tender);
}
// 2. Voucher OR immediate exit.
setPhase("finishing");
if (voucher) {
// The voucher slip carries the payment detail + barcode + grace.
const r = await printVoucher(identity);
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
} else {
const r = await boothExit(identity);
// No voucher → auto-print a standalone payment receipt for transparency.
// Best-effort: a printer fault must NOT block the exit that already happened;
// the operator can reprint from the done screen.
let receiptNote = "";
try {
await printReceipt(identity);
} catch {
receiptNote = ` ${t("pay.receiptPrintFailed")}`;
}
setResult(
(r.opened
? t("pay.paidBarrierOpened")
: t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") })) +
receiptNote,
);
}
// Refresh the live views.
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-50 w-[560px] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl"
aria-describedby={undefined}
>
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{isSubscription
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
: `${t("pay.ticket")} ${identity}`}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
✕
</Dialog.Close>
</div>
<div className="flex flex-col gap-3 p-4">
{/* Shift gate — block all actions until THIS operator has a shift open.
Another operator's open shift can't be operated under (no shared
till); only an "open mine" path when no shift is open at all. */}
{!shiftReady && (
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
{blockedByOther ? (
<>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateOtherTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
</div>
</>
) : (
<>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
<button
type="button"
onClick={handleOpenShift}
disabled={openingShift}
className="btn btn-go btn-sm mt-2"
>
{openingShift ? t("shift.opening") : t("shift.openNow")}
</button>
</>
)}
</div>
)}
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
{s && !s.found && (
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
{t("pay.noSessionFound")}
</div>
)}
{s && s.found && !s.open && (
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
</div>
)}
{s && s.found && s.open && (
<>
{/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatTime(s.enteredAt)} />
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
<Row
label={t("pay.duration")}
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
/>
<Row
label={t("pay.statusLabel")}
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
/>
</div>
{/* Total — a subscription is prepaid (no amount); show a badge. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">
{isSubscription ? t("pay.plan") : t("pay.total")}
</span>
<span className="text-3xl font-bold text-term-cyan">
{isSubscription
? t("pay.prepaid")
: s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? t("booth.badgePaid")
: t("pay.noTariff")}
</span>
</div>
{/* For a subscription, explain the only available action. */}
{isSubscription && (
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.subAssistHint")}
</div>
)}
{/* Snapshots */}
<SnapshotStrip identity={identity} />
{phase !== "done" && !isSubscription && (
<>
{/* Tender */}
{canPay && (
<div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
{(["cash", "card"] as const).map((tn) => (
<button
key={tn}
type="button"
onClick={() => setTender(tn)}
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
>
{t(`pay.${tn}`)}
</button>
))}
</div>
)}
{/* Voucher checkbox (default from site config) */}
<label className="flex items-center gap-2 text-[12px]">
<input
type="checkbox"
className="accent-term-amber"
checked={voucher}
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
/>
{t("pay.printExitVoucher")}
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
</label>
</>
)}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
{result && (
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
{phase === "done" ? (
<>
{/* Reprint the payment receipt (slip jammed / customer asks).
Only for a charged session — a subscription has no payment. */}
{!isSubscription && (
<button
type="button"
onClick={handleReprintReceipt}
disabled={reprinting}
className="btn btn-sm"
>
{reprinting ? t("pay.reprinting") : t("pay.reprintReceipt")}
</button>
)}
<button
type="button"
onClick={onClose}
className="btn btn-primary btn-sm"
>
{t("common.close")}
</button>
</>
) : (
<>
<button
type="button"
onClick={onClose}
className="btn btn-ghost btn-sm"
>
{t("common.cancel")}
</button>
{isSubscription ? (
// Prepaid — the only action is the audited barrier open (assist
// a faulty exit reader / missing card). Gated on an open shift.
<button
type="button"
onClick={handleOpenBarrier}
disabled={!shiftReady || phase === "finishing"}
className="btn btn-pay btn-lg"
>
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
</button>
) : (
<button
type="button"
onClick={handlePayAndExit}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="btn btn-go btn-lg"
>
{phase === "paying"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
? voucher
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
)}
</>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
return (
<div className="flex items-baseline justify-between">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
<span className={`text-sm ${valueClass}`}>{value}</span>
</div>
);
}
+419
View File
@@ -0,0 +1,419 @@
import { useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
import { formatMoney } from "./lib/format.js";
import { qk } from "./lib/query.js";
import { useLiveStore } from "./lib/live-store.js";
import { useShift } from "./lib/use-shift.js";
import { Panel } from "./ui/Panel.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothPayModal } from "./BoothPayModal.js";
import { ActiveSessions } from "./ActiveSessions.js";
import { Modal } from "./ui/Modal.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
import { renderReason } from "./lib/reason.js";
// 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
// the authoritative numbers; the WS-fed live store overlays real-time updates so
// 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. */
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" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
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 }) {
const { t } = useTranslation();
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green";
return (
<div className="flex flex-col gap-3">
<div className="flex items-end gap-4">
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
<div className="pb-1 text-term-muted">
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
<div className="text-sm tabular-nums">
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
</div>
</div>
<div className="ml-auto text-right">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
{occ.free == null ? "∞" : occ.free}
</div>
</div>
</div>
{pct != null && (
<div className="h-2 w-full overflow-hidden rounded-term bg-term-panel-2">
<div className={`h-full ${barColor} transition-[width] duration-300`} style={{ width: `${pct}%` }} />
</div>
)}
{occ.full && (
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
{t("booth.lotFull")}
</div>
)}
</div>
);
}
/** 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.source === "manual") keys.push("booth.badgeManualOpen");
return keys;
}
/** 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 detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
const showDetail = detail != null || badges.length > 0;
// 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="truncate text-term-text">{displayIdentity(e)}</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>
))}
{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>}
{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
* operator types it. Either way, submit opens the pay/exit modal for that id. The
* input auto-focuses and re-focuses after a scan so the scanner always lands here. */
function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const ref = useRef<HTMLInputElement>(null);
return (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
const id = value.trim();
if (id) {
onSubmit(id);
setValue("");
ref.current?.focus();
}
}}
>
<input
ref={ref}
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t("booth.scanPlaceholder")}
inputMode="numeric"
className="input h-11 flex-1 px-3 text-lg tabular-nums"
/>
<button type="submit" className="btn btn-primary btn-lg">
{t("booth.open")}
</button>
</form>
);
}
export function BoothScreen() {
const { t } = useTranslation();
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
// window (per-shift logs, not all history). When no shift is open, the feed is
// empty and the operator is prompted to open one.
const { isOpen: shiftOpen, startedAt: shiftStart } = useShift();
// Initial load via Query (also the fallback if the WS is briefly down). The events
// query is scoped to the current shift's start so it never shows prior shifts.
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({
queryKey: [...qk.events, shiftStart ?? "none"],
queryFn: () => fetchEvents(100, shiftStart ?? undefined),
enabled: shiftOpen,
});
// The ticket currently open in the pay/exit modal (null = no modal).
const [activeTicket, setActiveTicket] = useState<string | null>(null);
// The ledger event open in the read-only detail modal (null = closed).
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
// Live overlays from the WS store.
const liveOcc = useLiveStore((s) => s.occupancy);
const liveFeed = useLiveStore((s) => s.feed);
// Prefer the live-pushed occupancy; fall back to the query.
const occ = liveOcc ?? occQuery.data ?? null;
// Merge: live events first (newest), then the queried history, de-duped by id —
// then clip to the current shift window (the live store spans shifts; the feed
// must not show events from before this shift's start). No shift → no feed.
const seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const merged = [...liveFeed, ...history].slice(0, 200);
const events =
shiftOpen && shiftStart
? merged.filter((e) => e.occurredAt >= shiftStart)
: [];
return (
<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. */}
<div className="lg:col-span-2">
<Panel title={t("booth.processTicket")}>
<TicketInput onSubmit={setActiveTicket} />
</Panel>
</div>
{/* Left column: occupancy gauge above the active-sessions list. */}
<div className="flex min-h-0 flex-col gap-3">
<Panel title={t("booth.occupancy")} right={<StatusDot />}>
{occ ? (
<OccupancyGauge occ={occ} />
) : (
<div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
)}
</Panel>
<div className="min-h-0 flex-1">
<ActiveSessions onPick={setActiveTicket} />
</div>
</div>
<Panel
title={t("booth.liveFeed")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{events.length} {t("booth.events")}
</span>
}
className="min-h-0"
>
<div className="h-full overflow-y-auto pr-1">
{!shiftOpen ? (
<div className="text-term-amber">{t("shift.gateTitle")}</div>
) : events.length === 0 ? (
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
) : (
events.map((e) => <EventRow key={e.id} e={e} onOpen={setDetailEvent} />)
)}
</div>
</Panel>
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
</div>
);
}
+14 -18
View File
@@ -1,7 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { login, type SessionUser } from "./api.js"; import { login, type SessionUser } from "./api.js";
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) { export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
const { t } = useTranslation();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -21,38 +23,32 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
} }
return ( return (
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}> <main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
<h1>Parking System</h1> <form onSubmit={submit} className="card w-full max-w-sm p-6">
<form onSubmit={submit}> <h1 className="mb-5 text-h5 font-semibold uppercase tracking-widest text-term-amber">{t("auth.title")}</h1>
<div style={{ margin: "0.5rem 0" }}> <div className="field mb-3">
<label> <label className="label">{t("auth.username")}</label>
Username
<br />
<input <input
className="input"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
autoFocus autoFocus
autoComplete="username" autoComplete="username"
style={{ width: "100%" }}
/> />
</label>
</div> </div>
<div style={{ margin: "0.5rem 0" }}> <div className="field mb-3">
<label> <label className="label">{t("auth.password")}</label>
Password
<br />
<input <input
className="input"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" autoComplete="current-password"
style={{ width: "100%" }}
/> />
</label>
</div> </div>
{error && <p style={{ color: "crimson" }}>{error}</p>} {error && <p className="mb-3 text-[12px] text-term-red">{error}</p>}
<button type="submit" disabled={busy || !username || !password}> <button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
{busy ? "Signing in…" : "Sign in"} {busy ? t("auth.signingIn") : t("auth.signIn")}
</button> </button>
</form> </form>
</main> </main>
-183
View File
@@ -1,183 +0,0 @@
import { useEffect, useState } from "react";
import {
ApiError,
createPermit,
deletePermit,
fetchPermits,
revokePermit,
updatePermit,
type Permit,
type PermitCredential,
type PermitInput,
} from "./api.js";
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
// credentials (card/QR) and bound plates. A permit is mutable master data; every
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
interface FormState {
holderName: string;
contact: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
validTo: string;
credentials: PermitCredential[];
platesText: string; // comma/space separated
}
function emptyForm(): FormState {
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
}
function formFrom(p: Permit): FormState {
return {
holderName: p.holderName ?? "",
contact: p.contact ?? "",
carBound: p.maxConcurrent != null,
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
validFrom: p.validFrom ?? "",
validTo: p.validTo ?? "",
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
platesText: p.plates.join(", "),
};
}
function toInput(f: FormState): PermitInput {
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
validTo: f.validTo.trim() || null,
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
export function PermitManager() {
const [permits, setPermits] = useState<Permit[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function reload() {
fetchPermits()
.then((r) => setPermits(r.permits))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(reload, []);
function startNew() {
setForm(emptyForm());
setEditing("new");
setMsg(null);
}
function startEdit(p: Permit) {
setForm(formFrom(p));
setEditing(p.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") await createPermit(toInput(form));
else if (editing) await updatePermit(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: "Permit saved." });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doRevoke(p: Permit) {
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(p: Permit) {
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<PermitCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
if (!permits) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>Permits</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{permits.map((p) => (
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{p.holderName ?? "(unnamed)"}</strong>
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
<span style={{ color: "#666" }}>
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
{p.credentials.length} cred · {p.plates.length} plate(s)
</span>
<span style={{ flex: 1 }} />
<button type="button" onClick={() => startEdit(p)}>Edit</button>
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
<button type="button" onClick={() => doDelete(p)}>Delete</button>
</li>
))}
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>+ Add permit</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>Holder name</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>Contact</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>Car limit</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>Valid from</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
<label>Valid to</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
<label>Bound plates</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
</div>
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
{form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="rf">RF card/tag</option>
<option value="qr">QR</option>
</select>
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} />
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div>
))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
A permit needs at least one credential OR one bound plate.
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>Save</button>
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ApiError,
can,
createRole,
deleteRole,
fetchRoles,
updateRole,
type ManagedRole,
type Permission,
type SessionUser,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
// Role management (admin). Compose a role from the permission grid (a checkbox
// matrix of resource × action) and name it; users are then assigned a role. The
// built-in `admin` role is shown read-only/locked (it always has every permission
// and can't be edited or deleted). The server enforces the same. See
// @parking/shared PERMISSIONS.
/** Group "resource:action" permissions by resource for the grid rows. */
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
const out: Record<string, Permission[]> = {};
for (const p of perms) {
const resource = p.split(":")[0]!;
(out[resource] ??= []).push(p);
}
return out;
}
export function RolesManager({ user }: { user: SessionUser | null }) {
const { t } = useTranslation();
const qc = useQueryClient();
const rolesQ = useQuery({ queryKey: ["roles"], queryFn: fetchRoles });
const canCreate = can(user, "role:create");
const canUpdate = can(user, "role:update");
const canDelete = can(user, "role:delete");
const catalog = rolesQ.data?.catalog ?? [];
const roles = rolesQ.data?.roles ?? [];
const grouped = useMemo(() => groupByResource(catalog), [catalog]);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<ManagedRole | "new" | null>(null);
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ["roles"] });
void qc.invalidateQueries({ queryKey: ["users"] });
};
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
return (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("roles.title")}</h1>
{canCreate && (
<button type="button" className="btn btn-go btn-sm" onClick={() => { setEditing("new"); setError(null); }}>
{t("roles.add")}
</button>
)}
</div>
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
<Modal
open={editing != null}
onClose={() => setEditing(null)}
title={editing && editing !== "new" ? t("roles.editTitle") : t("roles.new")}
width="max-w-2xl"
>
{editing && (
<RoleEditor
role={editing === "new" ? null : editing}
grouped={grouped}
onCancel={() => setEditing(null)}
onSubmit={async (v) => {
try {
if (editing === "new") await createRole(v);
else await updateRole(editing.id, v);
setEditing(null);
invalidate();
} catch (e) { onError(e); }
}}
/>
)}
</Modal>
<div className="flex flex-col gap-2">
{roles.map((r) => (
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-term-text">{r.name}</span>
{r.builtin && (
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-term-amber">
{t("roles.builtin")}
</span>
)}
<span className="text-[11px] text-term-muted">
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
</span>
</div>
<div className="flex gap-2">
{canUpdate && !r.builtin && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
)}
{canDelete && !r.builtin && (
<button type="button" className="btn btn-danger btn-sm"
onClick={() => { if (confirm(t("roles.confirmDelete", { name: r.name }))) deleteRoleSafe(r.id, invalidate, onError); }}>{t("roles.delete")}</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}
async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown) => void) {
try { await deleteRole(id); ok(); } catch (e) { onError(e); }
}
function RoleEditor({
role, grouped, onCancel, onSubmit,
}: {
role: ManagedRole | null;
grouped: Record<string, Permission[]>;
onCancel: () => void;
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(role?.name ?? "");
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? []));
const toggle = (p: Permission) =>
setPerms((prev) => {
const next = new Set(prev);
next.has(p) ? next.delete(p) : next.add(p);
return next;
});
const valid = name.trim().length > 0;
return (
<div>
<div className="field mb-3 w-64">
<span className="label">{t("roles.name")}</span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="label">{t("roles.permissions")}</div>
<div className="mt-1 grid grid-cols-1 gap-1">
{Object.entries(grouped).map(([resource, list]) => (
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
<span className="w-28 shrink-0 text-[12px] font-semibold text-term-text">{resource}</span>
{list.map((p) => {
const action = p.split(":")[1]!;
return (
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
{action}
</label>
);
})}
</div>
))}
</div>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}>{t("common.save")}</button>
</div>
</div>
);
}
+217 -176
View File
@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { import {
assignDevice, assignDevice,
editDevice,
discoverDevices, discoverDevices,
fetchBackendIps, fetchBackendIps,
fetchCatalog, fetchCatalog,
@@ -18,6 +20,7 @@ import {
type RelaySpec, type RelaySpec,
type TestResult, type TestResult,
} from "./api.js"; } from "./api.js";
import { Modal } from "./ui/Modal.js";
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with // First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each // a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
@@ -26,25 +29,30 @@ import {
// Direction is a property of the relay, inherited by bound devices. The data model // Direction is a property of the relay, inherited by bound devices. The data model
// is multi-instance — one `devices` row per instance. See entry-exit-points.md. // is multi-instance — one `devices` row per instance. See entry-exit-points.md.
const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = { // Categories carry i18n KEYS (resolved at render via t()), not literal copy.
// `titleKey` is the section heading; `nounKey` resolves to the singular noun used in
// the add/edit buttons, modal titles and confirm prompts.
const CONTROLLER: { key: DeviceCategory; titleKey: string; nounKey: string } = {
key: "access", key: "access",
title: "Controllers (barriers + entry button)", titleKey: "setup.catControllers",
noun: "controller", nounKey: "setup.nounController",
}; };
// Categories that BIND to a controller relay (direction inherited from the relay). // Categories that BIND to a controller relay (direction inherited from the relay).
const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [ const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
{ key: "reader", title: "Readers (QR / RFID)", noun: "reader" }, { key: "reader", titleKey: "setup.catReaders", nounKey: "setup.nounReader" },
{ key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" }, { key: "camera", titleKey: "setup.catCameras", nounKey: "setup.nounCamera" },
{ key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" }, { key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
]; ];
const DIRECTION_LABELS: Record<Direction, string> = { // Translated direction label (relay direction / inherited binding).
entry: "Entry", const DIRECTION_KEYS: Record<Direction, string> = {
exit: "Exit", entry: "setup.dirEntry",
both: "Both (entry + exit)", exit: "setup.dirExit",
both: "setup.dirBoth",
}; };
export function SetupWizard() { export function SetupWizard() {
const { t } = useTranslation();
const [catalog, setCatalog] = useState<Catalog | null>(null); const [catalog, setCatalog] = useState<Catalog | null>(null);
const [assignments, setAssignments] = useState<Assignment[] | null>(null); const [assignments, setAssignments] = useState<Assignment[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -60,25 +68,21 @@ export function SetupWizard() {
reloadState(); reloadState();
}, [reloadState]); }, [reloadState]);
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>; if (error) return <p className="px-4 py-6 text-term-red">{t("setup.failedToLoad", { error })}</p>;
if (!catalog || !assignments) return <p>Loading device catalog…</p>; if (!catalog || !assignments) return <p className="px-4 py-6 text-term-muted">{t("setup.loadingCatalog")}</p>;
// Controllers are needed before binding readers/cameras (they pick a controller relay). // Controllers are needed before binding readers/cameras (they pick a controller relay).
const controllers = assignments.filter((a) => a.category === "access"); const controllers = assignments.filter((a) => a.category === "access");
return ( return (
<section> <section className="mx-auto max-w-3xl px-4 py-6">
<h2>First-run setup</h2> <h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
<p style={{ color: "#666", fontSize: "0.9em" }}> <p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
Add your barrier controllers first — set which relay is entry/exit and which
terminal the entry button is wired to. Then add readers, cameras and printers
and point each at the barrier it serves.
</p>
<CategorySection <CategorySection
category={CONTROLLER.key} category={CONTROLLER.key}
title={CONTROLLER.title} title={t(CONTROLLER.titleKey)}
noun={CONTROLLER.noun} noun={t(CONTROLLER.nounKey)}
entries={catalog[CONTROLLER.key]} entries={catalog[CONTROLLER.key]}
discoverableIds={catalog.discoverable} discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable} pushCapableIds={catalog.pushCapable}
@@ -87,12 +91,12 @@ export function SetupWizard() {
onChanged={reloadState} onChanged={reloadState}
/> />
{BOUND.map(({ key, title, noun }) => ( {BOUND.map(({ key, titleKey, nounKey }) => (
<CategorySection <CategorySection
key={key} key={key}
category={key} category={key}
title={title} title={t(titleKey)}
noun={noun} noun={t(nounKey)}
entries={catalog[key]} entries={catalog[key]}
discoverableIds={catalog.discoverable} discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable} pushCapableIds={catalog.pushCapable}
@@ -126,69 +130,84 @@ function CategorySection({
assignments: Assignment[]; assignments: Assignment[];
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
}) { }) {
const [adding, setAdding] = useState(false); const { t } = useTranslation();
// The form is popped out in a Modal. `formFor` selects what it edits:
// - "new" → the add form
// - an Assignment → edit that device in place
// - null → closed.
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
const [warnings, setWarnings] = useState<string[]>([]); const [warnings, setWarnings] = useState<string[]>([]);
const showForm = adding || assignments.length === 0;
// Binding categories need a controller to point at first. // Binding categories need a controller to point at first.
const isBound = category !== "access"; const isBound = category !== "access";
const blockedNoController = isBound && controllers.length === 0; const blockedNoController = isBound && controllers.length === 0;
const editing = formFor && formFor !== "new" ? formFor : undefined;
return ( return (
<fieldset style={{ marginTop: "1rem" }}> <fieldset className="card mt-4 p-4">
<legend>{title}</legend> <legend className="px-1 text-h6 font-semibold uppercase tracking-wider text-term-text">{title}</legend>
{warnings.length > 0 && ( {warnings.length > 0 && (
<div <div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
style={{ <strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
margin: "0 0 0.75rem", <ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
padding: "0.5rem 0.75rem",
background: "#fef3c7",
border: "1px solid #f59e0b",
borderRadius: 6,
}}
>
<strong style={{ color: "#92400e" }}>⚠ Saved, but action needed:</strong>
<ul style={{ margin: "0.25rem 0 0", paddingLeft: "1.25rem", color: "#92400e" }}>
{warnings.map((w, i) => ( {warnings.map((w, i) => (
<li key={i}>{w}</li> <li key={i}>{w}</li>
))} ))}
</ul> </ul>
<button type="button" onClick={() => setWarnings([])} style={{ marginTop: "0.5rem" }}> <button type="button" className="btn btn-sm mt-2" onClick={() => setWarnings([])}>
Dismiss {t("setup.dismiss")}
</button> </button>
</div> </div>
)} )}
{assignments.length > 0 && ( {assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}> <ul className="mb-3 list-none p-0">
{assignments.map((a) => ( {assignments.map((a) => (
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} /> <AssignmentRow
key={a.id}
assignment={a}
controllers={controllers}
onChanged={onChanged}
onEdit={() => setFormFor(a)}
/>
))} ))}
</ul> </ul>
)} )}
{blockedNoController ? ( {blockedNoController ? (
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p> <p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
) : showForm ? ( ) : (
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
</button>
)}
{/* Add/edit form — popped out. One modal per category; the device list stays
in the page behind it. */}
<Modal
open={formFor != null}
onClose={() => setFormFor(null)}
title={editing ? t("setup.editTitle", { noun }) : t("setup.addTitle", { noun })}
width="max-w-2xl"
>
{formFor != null && (
<DeviceForm <DeviceForm
category={category} category={category}
entries={entries} entries={entries}
discoverableIds={discoverableIds} discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds} pushCapableIds={pushCapableIds}
controllers={controllers} controllers={controllers}
editing={editing}
onSaved={async (w) => { onSaved={async (w) => {
setWarnings(w); setWarnings(w);
await onChanged(); await onChanged();
setAdding(false); setFormFor(null);
}} }}
onCancel={assignments.length > 0 ? () => setAdding(false) : undefined} onCancel={() => setFormFor(null)}
/> />
) : (
<button type="button" onClick={() => setAdding(true)}>
+ Add another {noun}
</button>
)} )}
</Modal>
</fieldset> </fieldset>
); );
} }
@@ -197,11 +216,14 @@ function AssignmentRow({
assignment, assignment,
controllers, controllers,
onChanged, onChanged,
onEdit,
}: { }: {
assignment: Assignment; assignment: Assignment;
controllers: Assignment[]; controllers: Assignment[];
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
onEdit: () => void;
}) { }) {
const { t } = useTranslation();
const [removing, setRemoving] = useState(false); const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -209,7 +231,7 @@ function AssignmentRow({
const host = typeof cfg.host === "string" ? cfg.host : null; const host = typeof cfg.host === "string" ? cfg.host : null;
async function remove() { async function remove() {
if (!confirm(`Remove this ${assignment.driverId} device?`)) return; if (!confirm(t("setup.confirmRemove", { driver: assignment.driverId }))) return;
setRemoving(true); setRemoving(true);
setError(null); setError(null);
try { try {
@@ -222,23 +244,18 @@ function AssignmentRow({
} }
return ( return (
<li <li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
style={{ <strong className="text-term-text">{assignment.driverId}</strong>
display: "flex", {host && <span className="tabular-nums text-term-muted">{host}</span>}
alignItems: "center",
gap: "0.5rem",
padding: "0.4rem 0.5rem",
borderBottom: "1px solid #eee",
}}
>
<strong>{assignment.driverId}</strong>
{host && <span style={{ color: "#666" }}>{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} /> <DeviceSummary assignment={assignment} controllers={controllers} />
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>} {!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
<span style={{ flex: 1 }} /> <span className="flex-1" />
{error && <span style={{ color: "crimson" }}>{error}</span>} {error && <span className="text-term-red">{error}</span>}
<button type="button" onClick={remove} disabled={removing}> <button type="button" className="btn btn-ghost btn-sm" onClick={onEdit} disabled={removing}>
{removing ? "Removing…" : "Remove"} {t("setup.edit")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={remove} disabled={removing}>
{removing ? t("setup.removing") : t("setup.remove")}
</button> </button>
</li> </li>
); );
@@ -246,12 +263,13 @@ function AssignmentRow({
/** Inline summary of an assignment's direction/binding for the list. */ /** Inline summary of an assignment's direction/binding for the list. */
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) { function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
const { t } = useTranslation();
const cfg = assignment.config as Record<string, unknown>; const cfg = assignment.config as Record<string, unknown>;
if (assignment.category === "access") { if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : []; const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em style={{ color: "#b45309" }}>no relays set</em>; if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
return ( return (
<span style={{ display: "flex", gap: "0.35rem" }}> <span className="flex gap-1.5">
{relays.map((r) => ( {relays.map((r) => (
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} /> <DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
))} ))}
@@ -261,7 +279,7 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
// Bound device: show controller + relay it points at, with inherited direction. // Bound device: show controller + relay it points at, with inherited direction.
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null; const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
const relay = typeof cfg.relay === "number" ? cfg.relay : null; const relay = typeof cfg.relay === "number" ? cfg.relay : null;
if (!controllerId || relay == null) return <em style={{ color: "#b45309" }}>unbound</em>; if (!controllerId || relay == null) return <em className="text-term-amber">{t("setup.unbound")}</em>;
const controller = controllers.find((c) => c.id === controllerId); const controller = controllers.find((c) => c.id === controllerId);
const spec = controller const spec = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay) ? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
@@ -280,6 +298,7 @@ function DeviceForm({
discoverableIds, discoverableIds,
pushCapableIds, pushCapableIds,
controllers, controllers,
editing,
onSaved, onSaved,
onCancel, onCancel,
}: { }: {
@@ -288,21 +307,43 @@ function DeviceForm({
discoverableIds: string[]; discoverableIds: string[];
pushCapableIds: string[]; pushCapableIds: string[];
controllers: Assignment[]; controllers: Assignment[];
/** When set, the form edits this assignment in place (driver locked, config
* pre-filled) instead of adding a new device. */
editing?: Assignment;
onSaved: (warnings: string[]) => Promise<void> | void; onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void; onCancel?: () => void;
}) { }) {
const [selectedId, setSelectedId] = useState<string>(""); const { t } = useTranslation();
// On edit the driver is fixed (you can't change what KIND of device a slot is —
// that's a remove + re-add); pre-select it and lock the picker.
const editCfg = editing?.config as Record<string, unknown> | undefined;
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
const selected = entries.find((e) => e.id === selectedId); const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id); const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id);
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access"; const isController = category === "access";
const [config, setConfig] = useState<Record<string, string | number>>({}); // Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.)
const [config, setConfig] = useState<Record<string, string | number>>(() => {
if (!editCfg) return {};
const out: Record<string, string | number> = {};
for (const [k, v] of Object.entries(editCfg)) {
if (typeof v === "string" || typeof v === "number") out[k] = v;
}
return out;
});
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal). // Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]); const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
);
// Bound devices: which controller + relay this device sits at. // Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>(""); const [controllerId, setControllerId] = useState<string>(
const [boundRelay, setBoundRelay] = useState<number | "">(""); typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
);
const [boundRelay, setBoundRelay] = useState<number | "">(
typeof editCfg?.relay === "number" ? editCfg.relay : "",
);
const [tested, setTested] = useState<TestResult | null>(null); const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
@@ -420,7 +461,12 @@ function DeviceForm({
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
const result = await assignDevice({ const result = editing
? await editDevice(editing.id, {
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
})
: await assignDevice({
category, category,
driverId: selected.id, driverId: selected.id,
config: mergedConfig(), config: mergedConfig(),
@@ -435,13 +481,15 @@ function DeviceForm({
} }
return ( return (
<div style={{ padding: "0.5rem", background: "#fafafa", borderRadius: 6 }}> <div>
{entries.length === 0 ? ( {entries.length === 0 ? (
<em>No drivers registered.</em> <em className="text-term-muted">{t("setup.noDrivers")}</em>
) : ( ) : (
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}> // Driver is locked when editing — changing the kind of device is a
// remove + re-add, not an in-place edit.
<select className="select w-auto min-w-64" value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
<option value="" disabled> <option value="" disabled>
Choose a device… {t("setup.chooseDevice")}
</option> </option>
{entries.map((e) => ( {entries.map((e) => (
<option key={e.id} value={e.id}> <option key={e.id} value={e.id}>
@@ -452,26 +500,26 @@ function DeviceForm({
)} )}
{selected && ( {selected && (
<div style={{ marginTop: "0.5rem" }}> <div className="mt-3">
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p> <p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
{canDiscover && ( {canDiscover && (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" onClick={scan} disabled={scanning}> <button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
{scanning ? "Scanning…" : "Scan for controllers"} {scanning ? t("setup.scanning") : t("setup.scan")}
</button> </button>
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>} {scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>} {found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
{found && found.length > 0 && ( {found && found.length > 0 && (
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1rem" }}> <ul className="mt-2 list-none p-0">
{found.map((d) => ( {found.map((d) => (
<li key={d.id} style={{ margin: "0.25rem 0" }}> <li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
<button type="button" onClick={() => applyDiscovered(d)}> <button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
Use {t("setup.use")}
</button>{" "} </button>
<strong>{d.label}</strong>{" "} <strong className="text-term-text">{d.label}</strong>
<HealthBadge status={d.health.status} /> <HealthBadge status={d.health.status} />
{d.info?.firmware && <span style={{ color: "#666" }}> · fw {d.info.firmware}</span>} {d.info?.firmware && <span className="text-term-muted"> · fw {d.info.firmware}</span>}
</li> </li>
))} ))}
</ul> </ul>
@@ -480,12 +528,14 @@ function DeviceForm({
)} )}
{selected.configFields.map((f) => ( {selected.configFields.map((f) => (
<div key={f.key} style={{ margin: "0.25rem 0" }}> <div key={f.key} className="field my-2 max-w-sm">
<label> <label className="label">
{f.label} {f.label}
{f.required ? " *" : ""}{" "} {f.required ? " *" : ""}
</label>
{f.type === "select" ? ( {f.type === "select" ? (
<select <select
className="select"
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")} value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
@@ -501,6 +551,7 @@ function DeviceForm({
</select> </select>
) : ( ) : (
<input <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) ?? ""}
placeholder={f.help} placeholder={f.help}
@@ -511,7 +562,6 @@ function DeviceForm({
}} }}
/> />
)} )}
</label>
</div> </div>
))} ))}
@@ -533,34 +583,34 @@ function DeviceForm({
)} )}
{/* Test (no save/no device change) then Save (configures + persists). */} {/* Test (no save/no device change) then Save (configures + persists). */}
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}> <div className="mt-3 flex items-center gap-2">
<button type="button" onClick={test} disabled={testing}> <button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
{testing ? "Testing…" : "Test connection"} {testing ? t("setup.testing") : t("setup.test")}
</button> </button>
<button type="button" onClick={save} disabled={saving}> <button type="button" className="btn btn-primary btn-sm" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save & configure"} {saving ? t("setup.saving") : editing ? t("setup.saveChanges") : t("setup.saveConfigure")}
</button> </button>
{onCancel && ( {onCancel && (
<button type="button" onClick={onCancel} disabled={saving}> <button type="button" className="btn btn-ghost btn-sm" onClick={onCancel} disabled={saving}>
Cancel {t("setup.cancel")}
</button> </button>
)} )}
</div> </div>
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>} {testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
{tested && ( {tested && (
<div style={{ margin: "0.5rem 0 0" }}> <div className="mt-2 text-[12px]">
<div> <div className="text-term-text">
Device: <HealthBadge status={tested.health.status} /> {t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
{tested.health.detail && <span style={{ color: "#666" }}> — {tested.health.detail}</span>} {tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
</div> </div>
{tested.preconditions.ok ? ( {tested.preconditions.ok ? (
<div style={{ color: "#16a34a" }}>● preconditions OK</div> <div className="text-term-green">{t("setup.preconditionsOk")}</div>
) : ( ) : (
tested.preconditions.issues.map((i) => ( tested.preconditions.issues.map((i) => (
<div key={i.key} style={{ color: "#d97706" }}> <div key={i.key} className="text-term-amber">
⚠ {i.message} ⚠ {i.message}
{i.fixable && <span style={{ color: "#666" }}> (auto-fixed on save)</span>} {i.fixable && <span className="text-term-muted"> {t("setup.autoFixedOnSave")}</span>}
</div> </div>
)) ))
)} )}
@@ -568,33 +618,29 @@ function DeviceForm({
)} )}
{backendIps && backendIps.length > 0 && ( {backendIps && backendIps.length > 0 && (
<div style={{ margin: "0.5rem 0 0" }}> <div className="mt-3">
<label> <div className="field max-w-md">
Backend push IP{" "} <label className="label">{t("setup.backendPushIp")}</label>
<select value={backendIp} onChange={(e) => setBackendIp(e.target.value)}> <select className="select" value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
{!backendIps.some((c) => c.onDeviceSubnet) && ( {!backendIps.some((c) => c.onDeviceSubnet) && (
<option value="" disabled> <option value="" disabled>
Choose an address… {t("setup.chooseAddress")}
</option> </option>
)} )}
{backendIps.map((c) => ( {backendIps.map((c) => (
<option key={c.ip} value={c.ip}> <option key={c.ip} value={c.ip}>
{c.ip} ({c.iface}){c.onDeviceSubnet ? " — on device subnet" : ""} {c.ip} ({c.iface}){c.onDeviceSubnet ? ` ${t("setup.onDeviceSubnet")}` : ""}
</option> </option>
))} ))}
</select> </select>
</label> </div>
{!backendIps.some((c) => c.onDeviceSubnet) && ( {!backendIps.some((c) => c.onDeviceSubnet) && (
<span style={{ marginLeft: 8, color: "#d97706" }}> <span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
⚠ no NIC on the device's subnet — the device may not reach the backend
</span>
)} )}
<p style={{ margin: "0.25rem 0 0", color: "#666", fontSize: "0.85em" }}> <p className="hint mt-1">{t("setup.backendIpHint")}</p>
The address this device will POST input events to.
</p>
</div> </div>
)} )}
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>} {saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
</div> </div>
)} )}
</div> </div>
@@ -604,6 +650,7 @@ function DeviceForm({
/** Controller relay map editor: each row = a relay + its direction + (optional) /** Controller relay map editor: each row = a relay + its direction + (optional)
* the input terminal its entry button is wired to. */ * the input terminal its entry button is wired to. */
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) { function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
const { t } = useTranslation();
function update(i: number, patch: Partial<RelaySpec>) { function update(i: number, patch: Partial<RelaySpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
} }
@@ -616,53 +663,50 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
} }
return ( return (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong style={{ fontSize: "0.9em" }}>Relays on this controller</strong> <strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
<p style={{ margin: "0.15rem 0 0.5rem", color: "#666", fontSize: "0.8em" }}> <p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
Each relay opens one barrier. Set its direction; for transient entry, set which input
terminal the entry button is wired to.
</p>
{relays.map((r, i) => ( {relays.map((r, i) => (
<div key={i} style={{ display: "flex", gap: "0.5rem", alignItems: "center", margin: "0.25rem 0" }}> <div key={i} className="my-1 flex flex-wrap items-center gap-2">
<label> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
Relay{" "} {t("setup.relay")}
<input <input
type="number" type="number"
min={1} min={1}
value={r.relay} value={r.relay}
style={{ width: "3.5rem" }} className="input input-sm w-16"
onChange={(e) => update(i, { relay: Number(e.target.value) })} onChange={(e) => update(i, { relay: Number(e.target.value) })}
/> />
</label> </label>
<select value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}> <select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
{(["entry", "exit", "both"] as Direction[]).map((d) => ( {(["entry", "exit", "both"] as Direction[]).map((d) => (
<option key={d} value={d}> <option key={d} value={d}>
{DIRECTION_LABELS[d]} {t(DIRECTION_KEYS[d])}
</option> </option>
))} ))}
</select> </select>
{(r.direction === "entry" || r.direction === "both") && ( {(r.direction === "entry" || r.direction === "both") && (
<label> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
Entry button on terminal{" "} {t("setup.entryButtonTerminal")}
<input <input
type="number" type="number"
min={1} min={1}
value={r.button ?? ""} value={r.button ?? ""}
placeholder="—" placeholder="—"
style={{ width: "3.5rem" }} className="input input-sm w-16"
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })} onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
/> />
</label> </label>
)} )}
{relays.length > 1 && ( {relays.length > 1 && (
<button type="button" onClick={() => remove(i)}> <button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕ ✕
</button> </button>
)} )}
</div> </div>
))} ))}
<button type="button" onClick={add} style={{ marginTop: "0.25rem" }}> <button type="button" className="btn btn-sm mt-1" onClick={add}>
+ Add relay {t("setup.addRelay")}
</button> </button>
</div> </div>
); );
@@ -683,6 +727,7 @@ function BindingPicker({
onControllerChange: (id: string) => void; onControllerChange: (id: string) => void;
onRelayChange: (relay: number) => void; onRelayChange: (relay: number) => void;
}) { }) {
const { t } = useTranslation();
const controller = controllers.find((c) => c.id === controllerId); const controller = controllers.find((c) => c.id === controllerId);
const relays: RelaySpec[] = controller const relays: RelaySpec[] = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []) ? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
@@ -690,14 +735,14 @@ function BindingPicker({
const chosen = relays.find((r) => r.relay === relay); const chosen = relays.find((r) => r.relay === relay);
return ( return (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong style={{ fontSize: "0.9em" }}>Which barrier does this device serve?</strong> <strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.35rem", flexWrap: "wrap" }}> <div className="mt-1.5 flex flex-wrap items-center gap-2">
<label> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
Controller{" "} {t("setup.controller")}
<select value={controllerId} onChange={(e) => onControllerChange(e.target.value)}> <select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
<option value="" disabled> <option value="" disabled>
Choose… {t("setup.choose")}
</option> </option>
{controllers.map((c) => { {controllers.map((c) => {
const host = (c.config as Record<string, unknown>).host; const host = (c.config as Record<string, unknown>).host;
@@ -710,53 +755,49 @@ function BindingPicker({
})} })}
</select> </select>
</label> </label>
<label> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
Relay{" "} {t("setup.relay")}
<select <select
className="select input-sm w-auto"
value={relay === "" ? "" : String(relay)} value={relay === "" ? "" : String(relay)}
disabled={!controller} disabled={!controller}
onChange={(e) => onRelayChange(Number(e.target.value))} onChange={(e) => onRelayChange(Number(e.target.value))}
> >
<option value="" disabled> <option value="" disabled>
Choose… {t("setup.choose")}
</option> </option>
{relays.map((r) => ( {relays.map((r) => (
<option key={r.relay} value={r.relay}> <option key={r.relay} value={r.relay}>
Relay {r.relay} ({DIRECTION_LABELS[r.direction]}) {t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option> </option>
))} ))}
</select> </select>
</label> </label>
{chosen && <DirectionBadge direction={chosen.direction} label={`inherits ${chosen.direction}`} />} {chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
</div> </div>
{controller && relays.length === 0 && ( {controller && relays.length === 0 && (
<p style={{ margin: "0.35rem 0 0", color: "#b45309", fontSize: "0.85em" }}> <p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
This controller has no relays configured.
</p>
)} )}
</div> </div>
); );
} }
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) { function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
const color = direction === "entry" ? "#15803d" : direction === "exit" ? "#b45309" : "#6b7280"; // entry=green, exit=amber, both=muted — aligned to the terminal accent palette.
const cls =
direction === "entry"
? "border-term-green text-term-green"
: direction === "exit"
? "border-term-amber text-term-amber"
: "border-term-muted text-term-muted";
return ( return (
<span <span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
style={{
color,
border: `1px solid ${color}`,
borderRadius: 4,
padding: "0 0.35rem",
fontSize: "0.75em",
fontWeight: 600,
}}
>
{label ?? direction} {label ?? direction}
</span> </span>
); );
} }
function HealthBadge({ status }: { status: string }) { function HealthBadge({ status }: { status: string }) {
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626"; const cls = status === "ready" ? "text-term-green" : status === "degraded" ? "text-term-amber" : "text-term-red";
return <span style={{ color, fontWeight: 600 }}>● {status}</span>; return <span className={`font-semibold ${cls}`}>● {status}</span>;
} }
+105 -22
View File
@@ -1,25 +1,41 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { closeShift, fetchShift, openShift, type ShiftReport } from "./api.js"; import { useTranslation } from "react-i18next";
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
// Manned-mode shift control. Start/End are explicit (not time-based — see // Manned-mode shift control. Start/End are explicit (not time-based — see
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the // wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
// totals. Available to cashier/operator/admin (readonly has no shift). // totals + the DRAWER picture (opening float carried from the prior shift, cash
// taken/added/removed, expected drawer). Admins can load/remove drawer cash.
// Available to cashier/operator/admin (readonly has no shift).
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim(); const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
export function ShiftControl() { export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
const { t } = useTranslation();
const [startedAt, setStartedAt] = useState<string | null>(null); const [startedAt, setStartedAt] = useState<string | null>(null);
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
const [currency, setCurrency] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [report, setReport] = useState<ShiftReport | null>(null); const [report, setReport] = useState<ShiftReport | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
useEffect(() => { // Cash-movement form (admin only).
const [moveAmount, setMoveAmount] = useState("");
const [moveReason, setMoveReason] = useState("");
const [moveMsg, setMoveMsg] = useState<string | null>(null);
function refresh() {
fetchShift() fetchShift()
.then((s) => setStartedAt(s.open?.startedAt ?? null)) .then((s) => {
setStartedAt(s.open?.startedAt ?? null);
setDrawerMinor(s.drawerMinor);
setCurrency(s.currency);
})
.catch(() => { .catch(() => {
/* readonly / not permitted — hide control */ /* readonly / not permitted — hide control */
}); });
}, []); }
useEffect(refresh, []);
async function start() { async function start() {
setBusy(true); setBusy(true);
@@ -28,6 +44,7 @@ export function ShiftControl() {
try { try {
const { startedAt } = await openShift(); const { startedAt } = await openShift();
setStartedAt(startedAt); setStartedAt(startedAt);
refresh();
} catch (e) { } catch (e) {
setErr((e as Error).message); setErr((e as Error).message);
} finally { } finally {
@@ -41,6 +58,7 @@ export function ShiftControl() {
const z = await closeShift(); const z = await closeShift();
setReport(z); setReport(z);
setStartedAt(null); setStartedAt(null);
refresh();
} catch (e) { } catch (e) {
setErr((e as Error).message); setErr((e as Error).message);
} finally { } finally {
@@ -48,33 +66,98 @@ export function ShiftControl() {
} }
} }
async function move(sign: 1 | -1) {
setMoveMsg(null);
const major = Number(moveAmount);
if (!Number.isFinite(major) || major <= 0) {
setMoveMsg(t("shift.enterPositive"));
return;
}
try {
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
setMoveAmount("");
setMoveReason("");
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
refresh();
} catch (e) {
setMoveMsg((e as Error).message);
}
}
return ( return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}> <section className="card mt-6 max-w-md p-4">
<strong>Shift:</strong>{" "} <div className="flex flex-wrap items-center gap-2 text-[13px]">
<strong className="uppercase tracking-wider text-term-muted">{t("shift.label")}</strong>
{startedAt ? ( {startedAt ? (
<> <>
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "} <span className="font-semibold text-term-green">{t("shift.open")}</span>
<button type="button" onClick={end} disabled={busy}> <span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
{busy ? "Ending…" : "End shift"} <button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
{busy ? t("shift.ending") : t("shift.endShift")}
</button> </button>
</> </>
) : ( ) : (
<> <>
<span style={{ color: "#777" }}>not started</span>{" "} <span className="text-term-muted">{t("shift.notStarted")}</span>
<button type="button" onClick={start} disabled={busy}> <button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
{busy ? "Starting…" : "Start shift"} {busy ? t("shift.starting") : t("shift.startShift")}
</button> </button>
</> </>
)} )}
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>} </div>
{/* Live drawer balance (what's in the till right now / inherited). */}
{drawerMinor != null && (
<div className="mt-2 text-[12px] text-term-text">
{t("shift.drawer")} <strong className="tabular-nums">{money(drawerMinor, currency)}</strong>
{startedAt && <span className="text-term-muted"> {t("shift.openingFloatInherited")}</span>}
</div>
)}
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
{isAdmin && (
<div className="mt-4 border-t border-term-border pt-3">
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
{t("shift.drawerCashAdmin")}
</div>
<div className="flex flex-wrap items-center gap-2">
<input
className="input w-28"
value={moveAmount}
onChange={(e) => setMoveAmount(e.target.value)}
placeholder={t("shift.amount")}
inputMode="decimal"
/>
<input
className="input min-w-36 flex-1"
value={moveReason}
onChange={(e) => setMoveReason(e.target.value)}
placeholder={t("shift.reasonPlaceholder")}
/>
<button type="button" className="btn btn-go btn-sm" onClick={() => move(1)}>{t("shift.load")}</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => move(-1)}>{t("shift.remove")}</button>
</div>
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
</div>
)}
{report && ( {report && (
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}> <div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div> <div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
<div>Payments: {report.paymentCount}</div> <div className="text-term-text">{t("shift.payments")} {report.paymentCount}</div>
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div> <div className="text-term-text">{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
<div>Card: {money(report.cardTotalMinor, report.currency)}</div> <div className="text-term-text">{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}> <div className="mt-2 text-[11px] uppercase tracking-wider text-term-muted">{t("shift.drawerSection")}</div>
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."} <div className="text-term-text">{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
<div className="text-term-text">{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
<div className="text-term-text">{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
<div className="text-term-text">{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
<div className="font-semibold text-term-text">
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
</div>
<div className={report.printed ? "mt-1 text-term-green" : "mt-1 text-term-amber"}>
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
</div> </div>
</div> </div>
)} )}
+183
View File
@@ -0,0 +1,183 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchShifts, type ShiftSummary, type SessionUser } from "./api.js";
import { formatMoney, formatDuration } from "./lib/format.js";
// Completed shift history. Scope is enforced SERVER-SIDE by permission: an operator
// gets only their own shifts; an admin (shift:cash) gets all + a date/operator
// filter. The screen mirrors that — it shows the filter only when the server
// reports scope:"all". Each row is one signed shift_z_report; expanding it shows the
// drawer reconciliation. See wiki/concepts/shift.md.
/** Local date + time (history spans days, so not just time-of-day). */
function fmtDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function money(minor: number, currency: string | null): string {
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
}
export function ShiftsHistory({ user }: { user: SessionUser | null }) {
const { t } = useTranslation();
// Admin filter inputs (only sent when the server grants the "all" scope; for an
// operator the server ignores them anyway).
const [operator, setOperator] = useState("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
// The applied filter (separate from the inputs, so typing doesn't refetch).
const [applied, setApplied] = useState<{ operator?: string; from?: string; to?: string }>({});
const q = useQuery({
queryKey: ["shifts", applied],
queryFn: () => fetchShifts(applied),
});
const isAdmin = q.data?.scope === "all";
const shifts = q.data?.shifts ?? [];
function apply() {
setApplied({
operator: operator.trim() || undefined,
// A date input gives yyyy-mm-dd; widen `to` to the end of that day.
from: from ? new Date(`${from}T00:00:00`).toISOString() : undefined,
to: to ? new Date(`${to}T23:59:59`).toISOString() : undefined,
});
}
function clear() {
setOperator("");
setFrom("");
setTo("");
setApplied({});
}
return (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
</h1>
</div>
{/* Admin-only filter: by operator + a date window over the shift start. */}
{isAdmin && (
<div className="card mb-3 flex flex-wrap items-end gap-3 p-3">
<div className="field">
<span className="label">{t("shifts.operator")}</span>
<input
className="input w-44"
value={operator}
onChange={(e) => setOperator(e.target.value)}
placeholder={t("shifts.allOperators")}
/>
</div>
<div className="field">
<span className="label">{t("shifts.filterFrom")}</span>
<input type="date" className="input w-44" value={from} onChange={(e) => setFrom(e.target.value)} />
</div>
<div className="field">
<span className="label">{t("shifts.filterTo")}</span>
<input type="date" className="input w-44" value={to} onChange={(e) => setTo(e.target.value)} />
</div>
<button type="button" className="btn btn-primary btn-sm" onClick={apply}>
{t("shifts.apply")}
</button>
<button type="button" className="btn btn-sm" onClick={clear}>
{t("shifts.clear")}
</button>
</div>
)}
{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="overflow-hidden rounded-term border border-term-border">
<table className="w-full text-[12px] tabular-nums">
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
<tr>
{isAdmin && <th className="px-3 py-1.5 text-left">{t("shifts.operator")}</th>}
<th className="px-3 py-1.5 text-left">{t("shifts.started")}</th>
<th className="px-3 py-1.5 text-left">{t("shifts.ended")}</th>
<th className="px-3 py-1.5 text-right">{t("shifts.payments")}</th>
<th className="px-3 py-1.5 text-right">{t("shifts.cash")}</th>
<th className="px-3 py-1.5 text-right">{t("shifts.card")}</th>
<th className="px-3 py-1.5 text-right">{t("shifts.expectedDrawer")}</th>
</tr>
</thead>
<tbody>
{shifts.map((s) => (
<ShiftRow key={s.id} s={s} showOperator={isAdmin} colSpan={isAdmin ? 7 : 6} />
))}
{!q.isLoading && shifts.length === 0 && (
<tr>
<td colSpan={isAdmin ? 7 : 6} className="px-3 py-3 text-term-muted">
{t("shifts.none")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator: boolean; colSpan: number }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const cur = s.currency;
return (
<>
<tr
className="cursor-pointer border-t border-term-border hover:bg-term-panel-2"
onClick={() => setOpen((o) => !o)}
>
{showOperator && <td className="px-3 py-1.5 text-term-text">{s.operator}</td>}
<td className="px-3 py-1.5">{fmtDateTime(s.startedAt)}</td>
<td className="px-3 py-1.5">
{fmtDateTime(s.endedAt)}
<span className="ml-2 text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
</td>
<td className="px-3 py-1.5 text-right">{s.paymentCount}</td>
<td className="px-3 py-1.5 text-right text-term-green">{money(s.cashTotalMinor, cur)}</td>
<td className="px-3 py-1.5 text-right text-term-cyan">{money(s.cardTotalMinor, cur)}</td>
<td className="px-3 py-1.5 text-right font-semibold">{money(s.expectedDrawerMinor, cur)}</td>
</tr>
{open && (
<tr className="border-t border-term-border/50 bg-term-bg">
<td colSpan={colSpan} className="px-3 py-2">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("shifts.drawerSection")}</div>
<div className="mt-1 grid grid-cols-2 gap-x-8 gap-y-0.5 sm:grid-cols-4">
<Figure label={t("shifts.openingFloat")} value={money(s.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(s.cashTotalMinor, cur)} />
<Figure label={t("shifts.cashAdded")} value={money(s.cashAddedMinor, cur)} />
<Figure label={t("shifts.cashRemoved")} value={money(s.cashRemovedMinor, cur)} />
</div>
</td>
</tr>
)}
</>
);
}
function Figure({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-2">
<span className="text-term-muted">{label}</span>
<span className="text-term-text">{value}</span>
</div>
);
}
+91 -24
View File
@@ -1,14 +1,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js"; import { useTranslation } from "react-i18next";
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the // Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse // fold over the signed ledger); capacity and the metadata fields are admin-editable.
// transient entry at capacity) is enforced server-side in the entry flow. // The FULL gate (refuse transient entry at capacity) is enforced server-side in the
// See wiki/concepts/capacity-occupancy.md. // entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
// (resolved at render); only `address` is multiline.
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
{ key: "phone", labelKey: "site.fieldPhone" },
{ key: "email", labelKey: "site.fieldEmail" },
];
export function SiteSettings({ canEdit }: { canEdit: boolean }) { export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const { t } = useTranslation();
const [occ, setOcc] = useState<Occupancy | null>(null); const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState(""); const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({});
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
function reload() { function reload() {
@@ -17,47 +33,98 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
useEffect(() => { useEffect(() => {
reload(); reload();
fetchSiteConfig() fetchSiteConfig()
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity))) .then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity));
setExitVoucherDefault(c.exitVoucherDefault);
const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m);
})
.catch(() => {}); .catch(() => {});
}, []); }, []);
async function save() { async function save() {
setMsg(null); setMsg(null);
const raw = capInput.trim(); const raw = capInput.trim();
const capacity = raw === "" ? null : Math.round(Number(raw)); const patch: Partial<SiteConfig> = {
capacity: raw === "" ? null : Math.round(Number(raw)),
exitVoucherDefault,
};
// Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
try { try {
await setCapacity(capacity); await saveSiteConfig(patch);
reload(); reload();
setMsg("Capacity saved."); setMsg(t("site.saved"));
} catch (e) { } catch (e) {
setMsg((e as Error).message); setMsg((e as Error).message);
} }
} }
return ( return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}> <section className="card mt-6 max-w-md p-4">
<strong>Occupancy:</strong>{" "} <div className="flex flex-wrap items-center gap-1.5 text-[13px]">
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
{occ == null ? ( {occ == null ? (
"…" <span className="text-term-muted">…</span>
) : ( ) : (
<> <>
<span style={{ fontWeight: 600 }}>{occ.count}</span> <span className="text-h5 font-semibold tabular-nums text-term-text">{occ.count}</span>
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"} <span className="tabular-nums text-term-muted">
{occ.capacity != null ? `/ ${occ.capacity}` : t("site.noCapacitySet")}
</span>
{occ.capacity != null && ( {occ.capacity != null && (
<span style={{ color: "#666" }}> · {occ.free} free</span> <span className="tabular-nums text-term-muted">· {occ.free} {t("site.free")}</span>
)} )}
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "} {occ.full && <span className="font-semibold text-term-red">{t("site.full")}</span>}
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button> <button type="button" className="btn btn-ghost btn-sm" onClick={reload}>↻</button>
</> </>
)} )}
</div>
{canEdit && ( {canEdit && (
<div style={{ marginTop: "0.6rem" }}> <div className="mt-4 grid gap-3">
<label> <div className="field">
Capacity (blank = no limit):{" "} <span className="label">{t("site.capacityLabel")}</span>
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" /> <input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
</label>{" "} </div>
<button type="button" onClick={save}>Save</button> <label className="flex items-center gap-2 text-[12px] text-term-text">
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>} <input
type="checkbox"
className="accent-term-amber"
checked={exitVoucherDefault}
onChange={(e) => setExitVoucherDefault(e.target.checked)}
/>
{t("site.printExitDefault")}
<span className="hint">{t("site.printExitHint")}</span>
</label>
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")}
</div>
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
<div key={key} className="field">
<span className="label">{t(labelKey)}</span>
{multiline ? (
<textarea
className="textarea"
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
rows={2}
placeholder={phKey ? t(phKey) : undefined}
/>
) : (
<input
className="input"
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
placeholder={phKey ? t(phKey) : undefined}
/>
)}
</div>
))}
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
{msg && <span className="text-[12px] text-term-muted">{msg}</span>}
</div>
</div> </div>
)} )}
</section> </section>
+435
View File
@@ -0,0 +1,435 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
armCapture,
cancelCapture,
createSubscription,
deleteSubscription,
fetchReaders,
fetchSiteConfig,
fetchSubscriptions,
pollCapture,
printSubscription,
revokeSubscription,
updateSubscription,
type ReaderInfo,
type Subscription,
type SubscriptionCredential,
type SubscriptionInput,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
// subscription is mutable master data; every USE of it is a signed ledger event
// elsewhere. See wiki/entities/subscription.md.
const DEFAULT_CURRENCY = "ALL";
interface FormState {
holderName: string;
contact: string;
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
currency: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
validTo: string;
credentials: SubscriptionCredential[];
platesText: string; // comma/space separated
}
/** Today (UTC date, yyyy-mm-dd) for a sensible default validFrom on new subs. */
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
return {
holderName: "",
contact: "",
priceMajor: defaultPriceMajor,
currency,
carBound: true,
maxConcurrent: "1",
validFrom: todayISODate(),
months: "1",
validTo: "",
credentials: [{ kind: "qr", value: "" }],
platesText: "",
};
}
function formFrom(s: Subscription): FormState {
return {
holderName: s.holderName ?? "",
contact: s.contact ?? "",
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
currency: s.currency ?? DEFAULT_CURRENCY,
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
validFrom: s.validFrom ?? "",
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
validTo: s.validTo ?? "",
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
platesText: s.plates.join(", "),
};
}
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
* server's addMonths so the form can preview the coverage end. */
function addMonthsDate(date: string, months: number): string | null {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return null;
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString().slice(0, 10);
}
const STATUS_KEY: Record<Subscription["status"], string> = {
active: "subs.statusActive",
suspended: "subs.statusSuspended",
revoked: "subs.statusRevoked",
};
function toInput(f: FormState): SubscriptionInput {
const major = Number(f.priceMajor);
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
priceMinor: priceSet ? Math.round(major * 100) : null,
period: "monthly",
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
// months (with validFrom) drives validTo server-side; else send the explicit end.
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
validTo: f.validTo.trim() || null,
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
// server auto-generates the code. RF (and pre-existing QR) keep their value.
credentials: f.credentials
.filter((c) => c.kind === "qr" || c.value.trim())
.map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
function priceLabel(s: Subscription, t: (k: string) => string): string {
if (s.priceMinor == null) return t("subs.noPrice");
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
}
export function SubscriptionManager() {
const { t } = useTranslation();
const [subs, setSubs] = useState<Subscription[] | null>(null);
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(() => emptyForm());
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
// Credential capture ("Read card"): which credential index is being captured, the
// reader picker list, and a live status line. null = no capture in progress.
const [capture, setCapture] = useState<{ credIndex: number; phase: "pick" | "waiting"; status?: string } | null>(null);
const [readers, setReaders] = useState<ReaderInfo[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
function reload() {
fetchSubscriptions()
.then((r) => setSubs(r.subscriptions))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(() => {
reload();
// Pull the site default monthly price to pre-fill new subscriptions.
fetchSiteConfig()
.then((c) => {
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
})
.catch(() => {
/* non-fatal — the form just won't pre-fill */
});
}, []);
function startNew() {
setForm(emptyForm(defaultPriceMajor));
setEditing("new");
setMsg(null);
}
function startEdit(s: Subscription) {
setForm(formFrom(s));
setEditing(s.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") {
const created = await createSubscription(toInput(form));
setEditing(null);
reload();
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
// operator can use "Print code" to retry).
if (created.printed) {
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
} else if (created.printError) {
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
} else {
setMsg({ kind: "ok", text: t("subs.saved") });
}
return;
}
if (editing) await updateSubscription(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: t("subs.saved") });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doPrint(s: Subscription) {
setMsg(null);
try {
const r = await printSubscription(s.id);
setMsg({ kind: "ok", text: t("subs.printedOn", { printer: r.printedBy }) });
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
async function doRevoke(s: Subscription) {
if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return;
await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(s: Subscription) {
if (!confirm(t("subs.confirmDelete", { name: s.holderName ?? s.id }))) return;
await deleteSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<SubscriptionCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
function clearPoll() {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}
// Stop a capture in progress (cancel on the server + clear local state).
function stopCapture() {
clearPoll();
void cancelCapture().catch(() => {});
setCapture(null);
}
// "Read card" on credential i → load readers + show the picker.
async function startCapture(i: number) {
setMsg(null);
try {
const r = await fetchReaders();
setReaders(r.readers);
setCapture({ credIndex: i, phase: "pick" });
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
// Operator picked a reader → arm it and poll until captured / expired.
async function pickReader(deviceId: string) {
const cap = capture;
if (!cap) return;
try {
await armCapture(deviceId);
setCapture({ ...cap, phase: "waiting", status: t("subs.captureWaiting") });
clearPoll();
pollRef.current = setInterval(async () => {
try {
const st = await pollCapture();
if (st.status === "captured") {
clearPoll();
setCred(cap.credIndex, { value: st.value });
void cancelCapture().catch(() => {}); // clear the server-side result
setCapture(null);
setMsg({ kind: "ok", text: t("subs.captured", { value: st.value }) });
} else if (st.status === "expired" || st.status === "idle") {
clearPoll();
setCapture({ ...cap, phase: "waiting", status: t("subs.captureTimeout") });
}
} catch {
/* transient poll error — keep polling */
}
}, 700);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
setCapture(null);
}
}
// Stop polling if the form closes or the component unmounts.
useEffect(() => clearPoll, []);
// Live coverage preview: when months + validFrom are set, show the end date and
// (if priced) the N×monthly total the operator should collect.
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
const totalDue =
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
: null;
const coverageHint = coverageEnd
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
: null;
if (!subs) return null;
return (
<section className="mx-auto max-w-3xl px-4 py-6">
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
<ul className="mb-3 list-none p-0">
{subs.map((s) => (
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]">
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
<span className="text-term-muted">
{s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "}
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
</span>
<span className="flex-1" />
{/* Print code — only when the subscription has a QR credential to encode. */}
{s.credentials.some((c) => c.kind === "qr") && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
{s.status !== "revoked" && <button type="button" className="btn btn-ghost btn-sm" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
<button type="button" className="btn btn-danger btn-sm" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
</li>
))}
{subs.length === 0 && <li className="py-2 text-term-muted">{t("subs.noneYet")}</li>}
</ul>
<button type="button" className="btn btn-go btn-sm" onClick={startNew}>{t("subs.add")}</button>
<Modal
open={editing != null}
onClose={() => setEditing(null)}
title={editing === "new" ? t("subs.new") : t("subs.editTitle")}
width="max-w-2xl"
>
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("subs.holderName")}</label>
<input className="input" value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label className="label">{t("subs.contact")}</label>
<input className="input" value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label className="label">{t("subs.monthlyPrice")}</label>
<span className="flex items-center gap-2">
<input
className="input w-28"
value={form.priceMajor}
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
inputMode="decimal"
placeholder={t("subs.pricePlaceholder")}
/>
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} />
<span className="text-[12px] text-term-muted">/ {t("subs.perMonth")}</span>
</span>
<label className="label">{t("subs.carLimit")}</label>
<span className="flex items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label>
{form.carBound && (
<input className="input w-16" value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} />
)}
</span>
<label className="label">{t("subs.validFrom")}</label>
<input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
<label className="label">{t("subs.months")}</label>
<span className="flex flex-wrap items-center gap-2">
<input
className="input w-16"
value={form.months}
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
inputMode="numeric"
placeholder="1"
/>
<span className="text-[12px] text-term-muted">{t("subs.monthsHint")}</span>
{/* Live preview of the coverage end + the N×price total. */}
{coverageHint && <span className="text-[12px] text-term-cyan">{coverageHint}</span>}
</span>
<label className="label">{t("subs.validToOverride")}</label>
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
<label className="label">{t("subs.boundPlates")}</label>
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div>
<h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
{form.credentials.map((c, i) => (
<div key={i} className="mb-1.5 flex items-center gap-2">
{/* Operator chooses the credential type: QR (auto-generated) or RFID
(read off a card via "Read card"). */}
<select className="select input-sm w-auto" value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="qr">{t("subs.qr")}</option>
<option value="rf">{t("subs.rfCardTag")}</option>
</select>
{c.kind === "qr" ? (
// QR codes are server-generated. Blank → "will be generated"; an
// existing code is shown read-only (it can be printed; never typed).
c.value.trim() ? (
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
) : (
<span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span>
)
) : (
// RFID: the value is read off a physical card (or typed). "Read card"
// arms a chosen reader and fills the captured value.
<input className="input input-sm flex-1" value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} />
)}
{c.kind === "rf" && (
<button type="button" className="btn btn-sm" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div>
))}
<button type="button" className="btn btn-sm" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
{/* Capture panel: pick a reader, present the card; the captured value fills
the credential. The OTHER reader keeps serving the live flow. */}
{capture && (
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]">
{capture.phase === "pick" ? (
<>
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
<div className="flex flex-wrap gap-2">
{readers.length === 0 && <span className="text-term-red">{t("subs.captureNoReaders")}</span>}
{readers.map((r) => (
<button key={r.id} type="button" className="btn btn-pay btn-sm" onClick={() => pickReader(r.id)}>
{t(`devices.role.${r.direction}`)} ({r.driverId})
</button>
))}
<button type="button" className="btn btn-ghost btn-sm" onClick={stopCapture}>{t("subs.cancel")}</button>
</div>
</>
) : (
<div className="flex items-center gap-3">
<span className="text-term-text">{capture.status ?? t("subs.captureWaiting")}</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={stopCapture}>{t("subs.cancel")}</button>
</div>
)}
</div>
)}
<p className="hint mt-3">{t("subs.needCredentialOrPlate")}</p>
<div className="mt-4 flex items-center gap-2">
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("subs.save")}</button>
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div>
</Modal>
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>}
</section>
);
}
+366 -80
View File
@@ -1,9 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
ApiError, ApiError,
fetchTariff, fetchTariff,
isTariffV2,
publishTariffVersion, publishTariffVersion,
type TariffBlock, type TariffBlock,
type TariffCard,
type TariffStructure, type TariffStructure,
type TariffState, type TariffState,
} from "./api.js"; } from "./api.js";
@@ -14,32 +17,106 @@ import {
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md. // usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
// Editable form mirror of TariffStructure, but money in major-unit strings. // Editable form mirror of TariffStructure, but money in major-unit strings.
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
// The LAST block is always open-ended ("thereafter"): its hours field is unused
// and it has no bound. On submit, per-block hours accumulate into the engine's
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
interface BlockForm { interface BlockForm {
uptoMin: string; // "" = open-ended (last block) hours: string; // duration of THIS band, in hours (ignored for the last block)
price: string; // major units, e.g. "2.00" price: string; // major units, e.g. "2.00"
} }
// A pricing body the form edits: either a flat rate or a block ladder.
interface PricingForm {
mode: "ladder" | "flat";
flat: string; // major units (used when mode==="flat")
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
dailyCap: string; // "" = no cap (ladder only)
}
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
interface TierForm {
name: string;
priority: string;
category: string; // "" = applies to all categories
dow: number[]; // selected days 0..6; empty = every day
fromHour: string; // "" = all day
toHour: string;
dateFrom: string; // "" = unbounded
dateTo: string;
pricing: PricingForm;
}
interface FormState { interface FormState {
currency: string; currency: string;
gracePeriodEntryMin: string; gracePeriodEntryMin: string;
incrementMin: string; incrementMin: string;
dailyCap: string; // "" = no cap
lostTicket: string; lostTicket: string;
gracePeriodExitMin: string; gracePeriodExitMin: string;
blocks: BlockForm[]; // The default (always-active) card — its own flat/ladder body + daily cap.
base: PricingForm;
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
tiers: TierForm[];
} }
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100); const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
const toMajor = (minor: number): string => (minor / 100).toFixed(2); const toMajor = (minor: number): string => (minor / 100).toFixed(2);
function emptyLadder(): PricingForm {
return { mode: "ladder", flat: "0.00", dailyCap: "", blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }] };
}
function emptyTier(): TierForm {
return { name: "", priority: "10", category: "", dow: [], fromHour: "", toHour: "", dateFrom: "", dateTo: "", pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] } };
}
function emptyForm(): FormState { function emptyForm(): FormState {
return { return {
currency: "EUR", currency: "EUR",
gracePeriodEntryMin: "15", gracePeriodEntryMin: "15",
incrementMin: "60", incrementMin: "60",
dailyCap: "",
lostTicket: "20.00", lostTicket: "20.00",
gracePeriodExitMin: "15", gracePeriodExitMin: "15",
blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }], base: emptyLadder(),
tiers: [],
};
}
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
// load (shown as their own band).
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
let prev = 0;
return blocks.map((b) => {
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
const hours = (b.uptoMin - prev) / 60;
prev = b.uptoMin;
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
});
}
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat or ladder).
function pricingFromCard(c: { flatMinor?: number; blocks?: TariffBlock[]; dailyCapMinor?: number | null }): PricingForm {
if (c.flatMinor != null) {
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks };
}
return {
mode: "ladder",
flat: "0.00",
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
blocks: blocksToForm(c.blocks ?? []),
};
}
function tierFromCard(c: TariffCard): TierForm {
const w = c.window ?? {};
return {
name: c.name,
priority: String(c.priority),
category: c.category ?? "",
dow: w.dow ? [...w.dow] : [],
fromHour: w.fromHour ?? "",
toHour: w.toHour ?? "",
dateFrom: w.dateFrom ?? "",
dateTo: w.dateTo ?? "",
pricing: pricingFromCard(c),
}; };
} }
@@ -47,37 +124,85 @@ function formFromActive(s: TariffState): FormState {
const v = s.active; const v = s.active;
if (!v) return emptyForm(); if (!v) return emptyForm();
const st = v.structure; const st = v.structure;
return { const common = {
currency: v.currency, currency: v.currency,
gracePeriodEntryMin: String(st.gracePeriodEntryMin), gracePeriodEntryMin: String(st.gracePeriodEntryMin),
incrementMin: String(st.incrementMin), incrementMin: String(st.incrementMin),
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
lostTicket: toMajor(st.lostTicketMinor), lostTicket: toMajor(st.lostTicketMinor),
gracePeriodExitMin: String(st.gracePeriodExitMin), gracePeriodExitMin: String(st.gracePeriodExitMin),
blocks: st.blocks.map((b) => ({
uptoMin: b.uptoMin == null ? "" : String(b.uptoMin),
price: toMajor(b.priceMinorPerIncrement),
})),
}; };
if (isTariffV2(st)) {
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
}
// V1: the bare ladder becomes the default card body; no tiers.
return { ...common, base: pricingFromCard(st), tiers: [] };
}
// Build a tariff card's pricing body (flat XOR ladder) from a PricingForm.
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "dailyCapMinor"> {
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
const last = p.blocks.length - 1;
let cum = 0;
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
cum += Math.round(Number(b.hours || "0") * 60);
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
});
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
}
function tierToCard(tr: TierForm): TariffCard {
const window: TariffCard["window"] = {};
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
if (tr.fromHour && tr.toHour) {
window.fromHour = tr.fromHour;
window.toHour = tr.toHour;
}
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
if (tr.dateTo) window.dateTo = tr.dateTo;
const card: TariffCard = {
name: tr.name.trim() || "tier",
priority: Math.round(Number(tr.priority || "0")),
...pricingToCardBody(tr.pricing),
};
if (tr.category.trim()) card.category = tr.category.trim();
if (Object.keys(window).length > 0) card.window = window;
return card;
} }
function toStructure(f: FormState): TariffStructure { function toStructure(f: FormState): TariffStructure {
const blocks: TariffBlock[] = f.blocks.map((b) => ({ const common = {
uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)),
priceMinorPerIncrement: toMinor(b.price),
}));
return {
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)), gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
incrementMin: Math.round(Number(f.incrementMin)), incrementMin: Math.round(Number(f.incrementMin)),
blocks,
dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap),
lostTicketMinor: toMinor(f.lostTicket), lostTicketMinor: toMinor(f.lostTicket),
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)), gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
overstay: "reprice", overstay: "reprice" as const,
};
const baseBody = pricingToCardBody(f.base);
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
// tiers gets exactly today's shape; the server leaves it untouched).
if (f.tiers.length === 0) {
if (f.base.mode === "flat") {
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
}
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
}
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
return {
...common,
version: 2,
tz: "",
defaultCard: { name: "default", priority: 0, ...baseBody },
windowedCards: f.tiers.map(tierToCard),
}; };
} }
export function TariffComposer() { export function TariffComposer() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null); const [state, setState] = useState<TariffState | null>(null);
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -95,14 +220,48 @@ export function TariffComposer() {
function set<K extends keyof FormState>(key: K, value: FormState[K]) { function set<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value })); setForm((f) => ({ ...f, [key]: value }));
} }
function setBlock(i: number, patch: Partial<BlockForm>) {
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) })); // --- pricing-body editing (used by the default card AND each tier) ---
// `update` maps the old PricingForm to a new one; `target` selects which body:
// the base card, or tier index N.
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
setForm((f) => {
if (target === "base") return { ...f, base: update(f.base) };
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
});
} }
function addBlock() { function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] })); updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
} }
function removeBlock(i: number) { // Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) })); function addBlock(target: "base" | number) {
updatePricing(target, (p) => {
const next = [...p.blocks];
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
return { ...p, blocks: next };
});
}
function removeBlock(target: "base" | number, i: number) {
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
}
// --- tier editing ---
function setTier(i: number, patch: Partial<TierForm>) {
setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
}
function addTier() {
setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
}
function removeTier(i: number) {
setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
}
function toggleDow(i: number, d: number) {
setForm((f) => ({
...f,
tiers: f.tiers.map((tr, j) =>
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
),
}));
} }
async function publish() { async function publish() {
@@ -112,7 +271,7 @@ export function TariffComposer() {
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) }); await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
const fresh = await fetchTariff(); const fresh = await fetchTariff();
setState(fresh); setState(fresh);
setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." }); setMsg({ kind: "ok", text: t("tariff.publishedOk") });
} catch (e) { } catch (e) {
const text = const text =
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
@@ -125,83 +284,210 @@ export function TariffComposer() {
} }
return ( return (
<section style={{ marginTop: "2rem" }}> <section className="mx-auto max-w-3xl px-4 py-6">
<h2>Tariff</h2> <h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
{!state?.active ? ( {!state?.active ? (
<p style={{ color: "#b45309" }}> <p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
No rate card published yet — the pay station can't charge until you publish one. {t("tariff.noRateCard")}
</p> </p>
) : ( ) : (
<p style={{ color: "#555" }}> <p className="mb-4 text-[12px] text-term-muted">
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "} {t("tariff.activeSince", {
{state.versions.length} version(s) in history. Publishing creates a new version; past date: new Date(state.active.effectiveFrom).toLocaleString(),
sessions keep their original pricing. count: state.versions.length,
})}
</p> </p>
)} )}
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}> <div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label>Currency</label> <label className="label">{t("tariff.currency")}</label>
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} /> <input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} />
<label>Free entry grace (min)</label> <label className="label">{t("tariff.freeEntryGrace")}</label>
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} /> <input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label>Billing increment (min)</label> <label className="label">{t("tariff.billingIncrement")}</label>
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} /> <input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label>Daily cap (blank = none)</label> <label className="label">{t("tariff.lostTicketFee")}</label>
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" /> <input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label>Lost-ticket fee</label> <label className="label">{t("tariff.exitGrace")}</label>
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} /> <input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
<label>Exit walk-back grace (min)</label>
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div> </div>
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3> {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}> wants tiers just edits this and publishes a bare V1 structure. */}
Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last <h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
block's bound blank for "thereafter". Price is per billing increment. <p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
</p> <div className="card card-body">
<table style={{ borderCollapse: "collapse" }}> <PricingEditor
t={t}
pricing={form.base}
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
onBlock={(i, patch) => setBlock("base", i, patch)}
onAddBlock={() => addBlock("base")}
onRemoveBlock={(i) => removeBlock("base", i)}
/>
</div>
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
<details className="mt-6" open={form.tiers.length > 0}>
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
{form.tiers.map((tr, i) => (
<fieldset key={i} className="card mb-3 p-4">
<legend className="flex items-center gap-2 px-1">
<input
className="input w-40"
value={tr.name}
onChange={(e) => setTier(i, { name: e.target.value })}
placeholder={t("tariff.tierName")}
/>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
{t("tariff.remove")}
</button>
</legend>
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("tariff.tierPriority")}</label>
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
<label className="label">{t("tariff.tierCategory")}</label>
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
<label className="label">{t("tariff.tierDays")}</label>
<span className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)}
</label>
))}
</span>
<label className="label">{t("tariff.tierHours")}</label>
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
<span className="text-term-muted">–</span>
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span>
)}
</span>
<label className="label">{t("tariff.tierDates")}</label>
<span className="inline-flex items-center gap-2">
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
<span className="text-term-muted">–</span>
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
</span>
</div>
<div className="mt-3 border-t border-term-border pt-3">
<PricingEditor
t={t}
pricing={tr.pricing}
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
onBlock={(bi, patch) => setBlock(i, bi, patch)}
onAddBlock={() => addBlock(i)}
onRemoveBlock={(bi) => removeBlock(i, bi)}
/>
</div>
</fieldset>
))}
<button type="button" className="btn btn-sm" onClick={addTier}>
{t("tariff.addTier")}
</button>
</details>
<div className="mt-6 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button>
{msg && (
<span className={msg.kind === "ok" ? "text-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span>
)}
</div>
</section>
);
}
// A reusable flat/ladder pricing-body editor — used by the default card and each tier.
function PricingEditor(props: {
t: (k: string) => string;
pricing: PricingForm;
onMode: (m: "ladder" | "flat") => void;
onFlat: (v: string) => void;
onCap: (v: string) => void;
onBlock: (i: number, patch: Partial<BlockForm>) => void;
onAddBlock: () => void;
onRemoveBlock: (i: number) => void;
}) {
const { t, pricing: p } = props;
return (
<div>
<div className="mb-3 flex gap-4 text-[12px]">
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
{t("tariff.modeLadder")}
</label>
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
{t("tariff.modeFlat")}
</label>
</div>
{p.mode === "flat" ? (
<div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.pricePerIncrement")}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
</div>
) : (
<>
<table className="w-full border-collapse">
<thead> <thead>
<tr style={{ textAlign: "left", color: "#555" }}> <tr className="text-left">
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th> <th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
<th style={{ padding: "0 0.5rem" }}>Price / increment</th> <th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{form.blocks.map((b, i) => ( {p.blocks.map((b, i) => {
const isTail = i === p.blocks.length - 1;
return (
<tr key={i}> <tr key={i}>
<td style={{ padding: "0.15rem 0.5rem" }}> <td className="px-2 py-1">
<input {isTail ? (
value={b.uptoMin} <span className="italic text-term-muted">{t("tariff.thereafter")}</span>
onChange={(e) => setBlock(i, { uptoMin: e.target.value })} ) : (
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"} <span className="inline-flex items-center gap-2">
style={{ width: 110 }} <input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
/> <span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
)}
</td> </td>
<td style={{ padding: "0.15rem 0.5rem" }}> <td className="px-2 py-1">
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} /> <input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
</td> </td>
<td> <td className="px-2">
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}> {!isTail && (
Remove <button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
{t("tariff.remove")}
</button> </button>
)}
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}> <div className="mt-3 flex items-center gap-4">
+ Add block <button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
</button> {t("tariff.addBlock")}
<div style={{ marginTop: "1rem" }}>
<button type="button" onClick={publish} disabled={saving}>
{saving ? "Publishing…" : "Publish new version"}
</button> </button>
<span className="inline-flex items-center gap-2">
<span className="label">{t("tariff.dailyCap")}</span>
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
</span>
</div> </div>
{msg && ( </>
<p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson", marginTop: "0.5rem" }}>{msg.text}</p>
)} )}
</section> </div>
); );
} }
+320
View File
@@ -0,0 +1,320 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ApiError,
can,
createUser,
deleteUser,
fetchRoles,
fetchUsers,
resetUserPassword,
updateUser,
type ManagedRole,
type ManagedUser,
type SessionUser,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
// User management (admin). List users, create one (username + password + role),
// change a user's role, reset a password, delete. The server enforces the same
// permissions and the no-lockout rule (the last admin can't be removed). See
// wiki/entities/local-jwt-auth.md.
export function UsersManager({ user }: { user: SessionUser | null }) {
const { t } = useTranslation();
const qc = useQueryClient();
const usersQ = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
const rolesQ = useQuery({ queryKey: ["roles"], queryFn: fetchRoles });
const canCreate = can(user, "user:create");
const canUpdate = can(user, "user:update");
const canDelete = can(user, "user:delete");
const roles: ManagedRole[] = rolesQ.data?.roles ?? [];
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
const invalidate = () => void qc.invalidateQueries({ queryKey: ["users"] });
const onError = (e: unknown) =>
setError(e instanceof ApiError ? e.message : (e as Error).message);
return (
<div className="mx-auto max-w-3xl">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("users.title")}</h1>
{canCreate && roles.length > 0 && (
<button type="button" className="btn btn-go btn-sm" onClick={() => { setAdding(true); setError(null); }}>
{t("users.add")}
</button>
)}
</div>
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
<UserForm
roles={roles}
onCancel={() => setAdding(false)}
onSubmit={async (v) => {
try {
await createUser({
username: v.username,
password: v.password!,
roleId: v.roleId,
fullName: v.fullName,
phone: v.phone,
email: v.email,
address: v.address,
});
setAdding(false);
invalidate();
} catch (e) { onError(e); }
}}
/>
</Modal>
<Modal open={editingUser != null} onClose={() => setEditingUser(null)} title={t("users.editTitle")} width="max-w-2xl">
{editingUser && (
<UserForm
roles={roles}
editing={editingUser}
onCancel={() => setEditingUser(null)}
onSubmit={async (v) => {
try {
await updateUser(editingUser.id, {
username: v.username,
roleId: v.roleId,
fullName: v.fullName,
phone: v.phone,
email: v.email,
address: v.address,
});
setEditingUser(null);
invalidate();
} catch (e) { onError(e); }
}}
/>
)}
</Modal>
<div className="overflow-hidden rounded-term border border-term-border">
<table className="w-full text-[12px]">
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
<tr>
<th className="px-3 py-1.5 text-left">{t("users.username")}</th>
<th className="px-3 py-1.5 text-left">{t("users.role")}</th>
<th className="px-3 py-1.5 text-right">{t("common.none")}</th>
</tr>
</thead>
<tbody>
{(usersQ.data?.users ?? []).map((u) => (
<UserRow
key={u.id}
u={u}
roles={roles}
canUpdate={canUpdate}
canDelete={canDelete}
onEdit={() => { setEditingUser(u); setError(null); }}
onChanged={invalidate}
onError={onError}
/>
))}
{usersQ.data?.users.length === 0 && (
<tr><td colSpan={3} className="px-3 py-3 text-term-muted">{t("users.none")}</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function UserRow({
u, roles, canUpdate, canDelete, onEdit, onChanged, onError,
}: {
u: ManagedUser;
roles: ManagedRole[];
canUpdate: boolean;
canDelete: boolean;
onEdit: () => void;
onChanged: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [resetting, setResetting] = useState(false);
const [pw, setPw] = useState("");
const roleMut = useMutation({
mutationFn: (roleId: string) => updateUser(u.id, { roleId }),
onSuccess: onChanged,
onError,
});
const pwMut = useMutation({
mutationFn: () => resetUserPassword(u.id, pw),
onSuccess: () => { setResetting(false); setPw(""); },
onError,
});
const delMut = useMutation({
mutationFn: () => deleteUser(u.id),
onSuccess: onChanged,
onError,
});
return (
<tr className="border-t border-term-border">
<td className="px-3 py-1.5">
{u.username}
{u.fullName && <span className="ml-2 text-term-muted">{u.fullName}</span>}
</td>
<td className="px-3 py-1.5">
{canUpdate ? (
<select
value={u.roleId}
onChange={(e) => roleMut.mutate(e.target.value)}
className="select input-sm w-auto"
>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
) : (
u.roleName
)}
</td>
<td className="px-3 py-1.5 text-right">
<div className="flex justify-end gap-2">
{canUpdate && !resetting && (
<button type="button" className="btn btn-ghost btn-sm" onClick={onEdit}>
{t("users.edit")}
</button>
)}
{canUpdate && !resetting && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setResetting(true)}>
{t("users.resetPassword")}
</button>
)}
{canUpdate && resetting && (
<span className="flex items-center gap-1">
<input
type="password" value={pw} autoFocus
onChange={(e) => setPw(e.target.value)}
placeholder={t("users.newPassword")}
className="input input-sm w-32"
/>
<button type="button" className="btn btn-go btn-sm" disabled={pw.length < 8 || pwMut.isPending} onClick={() => pwMut.mutate()}>
{t("common.save")}
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setResetting(false); setPw(""); }}>✕</button>
</span>
)}
{canDelete && (
<button
type="button"
className="btn btn-danger btn-sm"
onClick={() => { if (confirm(t("users.confirmDelete", { name: u.username }))) delMut.mutate(); }}
>
{t("users.delete")}
</button>
)}
</div>
</td>
</tr>
);
}
/** Submitted form value. `password` is omitted entirely on edit (a blank field must
* not blank the password — that's the separate "reset password" flow). */
interface UserFormValue {
username: string;
password?: string;
roleId: string;
fullName: string;
phone: string;
email: string;
address: string;
}
function UserForm({
roles, editing, onCancel, onSubmit,
}: {
roles: ManagedRole[];
/** When set, the form edits this user (username/role/details; NOT the password). */
editing?: ManagedUser;
onCancel: () => void;
onSubmit: (v: UserFormValue) => void;
}) {
const { t } = useTranslation();
const isEdit = editing != null;
const [username, setUsername] = useState(editing?.username ?? "");
const [password, setPassword] = useState("");
const [roleId, setRoleId] = useState(editing?.roleId ?? roles[0]?.id ?? "");
const [fullName, setFullName] = useState(editing?.fullName ?? "");
const [phone, setPhone] = useState(editing?.phone ?? "");
const [email, setEmail] = useState(editing?.email ?? "");
const [address, setAddress] = useState(editing?.address ?? "");
// On create, a >=8 char password is required; on edit it's left untouched.
const valid = username.trim().length > 0 && roleId && (isEdit || password.length >= 8);
function submit() {
onSubmit({
username: username.trim(),
...(isEdit ? {} : { password }),
roleId,
fullName,
phone,
email,
address,
});
}
return (
<div>
<div className="grid grid-cols-3 gap-3">
<div className="field">
<span className="label">{t("users.username")}</span>
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
{!isEdit && (
<div className="field">
<span className="label">{t("users.password")}</span>
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
)}
<div className="field">
<span className="label">{t("users.role")}</span>
<select className="select" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
</div>
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
{/* Optional profile metadata. */}
<div className="mt-4 mb-2 text-[11px] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
<div className="grid grid-cols-2 gap-3">
<div className="field">
<span className="label">{t("users.fullName")}</span>
<input className="input" value={fullName} onChange={(e) => setFullName(e.target.value)} />
</div>
<div className="field">
<span className="label">{t("users.phone")}</span>
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="field">
<span className="label">{t("users.email")}</span>
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="field">
<span className="label">{t("users.address")}</span>
<input className="input" value={address} onChange={(e) => setAddress(e.target.value)} />
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={submit}>{t("common.save")}</button>
</div>
</div>
);
}
+460 -24
View File
@@ -44,11 +44,29 @@ export class ApiError extends Error {
// --- Auth ----------------------------------------------------------------- // --- Auth -----------------------------------------------------------------
export type Role = "admin" | "operator" | "cashier" | "readonly"; export type Lang = "sq" | "en";
export type Theme = "dark" | "light";
/** A `resource:action` permission string (the server is the source of truth for
* the full grid; the role composer fetches it via /api/roles). */
export type Permission = string;
export interface SessionUser { export interface SessionUser {
id: string; id: string;
username: string; username: string;
role: Role; roleId: string;
roleName: string;
/** The permissions this user's role grants — the UI gates nav/routes on these. */
permissions: Permission[];
/** Preferred UI language (loaded from the server on login). */
language: Lang;
/** Preferred UI theme (loaded from the server on login). */
theme: Theme;
/** Optional display name (profile metadata); null if unset. */
fullName: string | null;
}
/** Does this session grant the permission? Central authz check for the SPA. */
export function can(user: SessionUser | null, perm: Permission): boolean {
return !!user && user.permissions.includes(perm);
} }
export function login(username: string, password: string): Promise<SessionUser> { export function login(username: string, password: string): Promise<SessionUser> {
@@ -62,6 +80,16 @@ export function logout(): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/logout", { method: "POST" }); return apiFetch("/api/auth/logout", { method: "POST" });
} }
/** Persist the current user's UI language preference (restored on next login). */
export function setLanguagePref(language: Lang): Promise<{ language: Lang }> {
return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) });
}
/** Persist the current user's UI theme preference (restored on next login). */
export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
}
/** 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 {
@@ -72,6 +100,66 @@ export async function fetchMe(): Promise<SessionUser | null> {
} }
} }
// --- User & role management (RBAC) ----------------------------------------
/** Optional profile metadata on a managed user (all nullable). */
export interface UserProfile {
fullName: string | null;
phone: string | null;
email: string | null;
address: string | null;
}
export interface ManagedUser extends UserProfile {
id: string;
username: string;
roleId: string;
roleName: string;
language: Lang;
createdAt: string;
}
export interface ManagedRole {
id: string;
name: string;
builtin: boolean;
permissions: Permission[];
userCount: number;
}
export function fetchUsers(): Promise<{ users: ManagedUser[] }> {
return apiFetch("/api/users");
}
export function createUser(
body: { username: string; password: string; roleId: string } & Partial<UserProfile>,
): Promise<ManagedUser> {
return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) });
}
export function updateUser(
id: string,
body: { username?: string; roleId?: string } & Partial<UserProfile>,
): Promise<ManagedUser> {
return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
export function resetUserPassword(id: string, password: string): Promise<{ ok: boolean }> {
return apiFetch(`/api/users/${id}/password`, { method: "PUT", body: JSON.stringify({ password }) });
}
export function deleteUser(id: string): Promise<{ ok: boolean }> {
return apiFetch(`/api/users/${id}`, { method: "DELETE" });
}
/** Roles + the full permission catalog (for the composer checkbox grid). */
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
return apiFetch("/api/roles");
}
export function createRole(body: { name: string; permissions: Permission[] }): Promise<ManagedRole> {
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
}
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> {
return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
export function deleteRole(id: string): Promise<{ ok: boolean }> {
return apiFetch(`/api/roles/${id}`, { method: "DELETE" });
}
// --- Device setup --------------------------------------------------------- // --- Device setup ---------------------------------------------------------
export interface ConfigField { export interface ConfigField {
@@ -186,6 +274,15 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
} }
/** Re-configure an existing device in place, keeping its id (and so its push
* URL). Category/driver are fixed at create time, so only config changes. */
export function editDevice(
id: string,
body: Omit<AssignBody, "category" | "driverId">,
): Promise<AssignResult> {
return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", body: JSON.stringify(body) });
}
/** A persisted device assignment (one per instance; machine-only secrets stripped). */ /** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment { export interface Assignment {
id: string; id: string;
@@ -223,7 +320,10 @@ export interface TariffBlock {
uptoMin: number | null; uptoMin: number | null;
priceMinorPerIncrement: number; priceMinorPerIncrement: number;
} }
export interface TariffStructure { // Mirrors @parking/shared. Two shapes: V1 (bare ladder) and V2 (default + windowed
// cards by time-of-day / dow / date / category, flat or laddered). The discriminant
// is the presence of `defaultCard`. See wiki/concepts/tariff-time-tiers.md.
export interface TariffStructureV1 {
gracePeriodEntryMin: number; gracePeriodEntryMin: number;
incrementMin: number; incrementMin: number;
blocks: TariffBlock[]; blocks: TariffBlock[];
@@ -232,6 +332,39 @@ export interface TariffStructure {
gracePeriodExitMin: number; gracePeriodExitMin: number;
overstay: "reprice"; overstay: "reprice";
} }
export interface TariffWindow {
dow?: number[];
dateFrom?: string;
dateTo?: string;
fromHour?: string;
toHour?: string;
}
export interface TariffCard {
name: string;
priority: number;
category?: string;
window?: TariffWindow;
flatMinor?: number;
blocks?: TariffBlock[];
dailyCapMinor?: number | null;
}
export interface TariffStructureV2 {
version: 2;
tz: string;
gracePeriodEntryMin: number;
incrementMin: number;
lostTicketMinor: number;
gracePeriodExitMin: number;
overstay: "reprice";
defaultCard: TariffCard;
windowedCards?: TariffCard[];
}
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
/** True when a structure is the windowed V2 shape (mirrors @parking/shared isTariffV2). */
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
return (t as TariffStructureV2).defaultCard != null;
}
export interface TariffVersion { export interface TariffVersion {
id: string; id: string;
tariffId: string; tariffId: string;
@@ -260,48 +393,114 @@ export function publishTariffVersion(body: {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
} }
// --- Permits -------------------------------------------------------------- // --- Subscriptions --------------------------------------------------------
export interface PermitCredential { export interface SubscriptionCredential {
kind: "rf" | "qr"; kind: "rf" | "qr";
value: string; value: string;
} }
export interface Permit { export interface Subscription {
id: string; id: string;
holderName: string | null; holderName: string | null;
contact: string | null; contact: string | null;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null; maxConcurrent: number | null;
validFrom: string | null; validFrom: string | null;
validTo: string | null; validTo: string | null;
status: "active" | "suspended" | "revoked"; status: "active" | "suspended" | "revoked";
credentials: PermitCredential[]; credentials: SubscriptionCredential[];
plates: string[]; plates: string[];
} }
export type PermitInput = Omit<Permit, "id" | "status"> & { /** A credential as SENT to the server: a QR value may be omitted/blank → the server
status?: Permit["status"]; * auto-generates an unguessable code. RF must carry the card id. */
export interface SubscriptionCredentialInput {
kind: "rf" | "qr";
value?: string;
}
export type SubscriptionInput = {
holderName: string | null;
contact: string | null;
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
months?: number | null;
status?: Subscription["status"];
credentials: SubscriptionCredentialInput[];
plates: string[];
}; };
export function fetchPermits(): Promise<{ permits: Permit[] }> { /** The create response = the saved subscription + the auto-print outcome. */
return apiFetch("/api/permits"); export type SubscriptionCreated = Subscription & {
printed: boolean;
printedBy?: string;
printError?: string;
};
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
return apiFetch("/api/subscriptions");
} }
export function createPermit(body: PermitInput): Promise<Permit> { export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
} }
export function updatePermit(id: string, body: PermitInput): Promise<Permit> { /** Re-print a subscription's QR card (failed auto-print / lost card). */
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) }); export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
} }
export function revokePermit(id: string): Promise<Permit> {
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" }); // --- Credential capture ("enroll a card" on a chosen reader) ---------------
export interface ReaderInfo {
id: string;
driverId: string;
direction: "entry" | "exit" | "both";
} }
export function deletePermit(id: string): Promise<void> { export type CaptureState =
return apiFetch(`/api/permits/${id}`, { method: "DELETE" }); | { status: "idle" }
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
| { status: "expired"; deviceId: string };
export function fetchReaders(): Promise<{ readers: ReaderInfo[] }> {
return apiFetch("/api/subscriptions/readers");
}
export function armCapture(deviceId: string): Promise<{ expiresAt: number }> {
return apiFetch("/api/subscriptions/capture/arm", { method: "POST", body: JSON.stringify({ deviceId }) });
}
export function pollCapture(): Promise<CaptureState> {
return apiFetch("/api/subscriptions/capture");
}
export function cancelCapture(): Promise<{ ok: boolean }> {
return apiFetch("/api/subscriptions/capture/cancel", { method: "POST" });
}
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
export function revokeSubscription(id: string): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" });
}
export function deleteSubscription(id: string): Promise<void> {
return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" });
} }
// --- Shifts --------------------------------------------------------------- // --- Shifts ---------------------------------------------------------------
export interface ShiftStatus { export interface ShiftStatus {
/** The requesting (logged-in) operator. */
operator: string; operator: string;
open: { startedAt: string } | null; /** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
open: { startedAt: string; operator: string | null } | null;
/** True iff the open shift belongs to the requesting operator (can close it). */
isMine: boolean;
/** Live physical drawer balance (cash payments + cash movements). */
drawerMinor: number;
currency: string | null;
} }
export interface ShiftReport { export interface ShiftReport {
operator: string; operator: string;
@@ -311,19 +510,68 @@ export interface ShiftReport {
cardTotalMinor: number; cardTotalMinor: number;
currency: string | null; currency: string | null;
paymentCount: number; paymentCount: number;
// Drawer (carries across shifts).
openingFloatMinor: number;
cashAddedMinor: number;
cashRemovedMinor: number;
expectedDrawerMinor: number;
printed: boolean; printed: boolean;
} }
export function fetchShift(): Promise<ShiftStatus> { export function fetchShift(): Promise<ShiftStatus> {
return apiFetch("/api/shift/current"); return apiFetch("/api/shift/current");
} }
export function openShift(): Promise<{ startedAt: string }> { export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
return apiFetch("/api/shift/open", { method: "POST" }); return apiFetch("/api/shift/open", { method: "POST" });
} }
export function closeShift(): Promise<ShiftReport> { export function closeShift(): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST" }); return apiFetch("/api/shift/close", { method: "POST" });
} }
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
export function recordCashMovement(
amountMinor: number,
reason: string,
): Promise<{ amountMinor: number; balanceMinor: number }> {
return apiFetch("/api/cash-movement", {
method: "POST",
body: JSON.stringify({ amountMinor, reason }),
});
}
/** A completed shift (reconstructed from its signed Z-report). */
export interface ShiftSummary {
id: string;
index: number;
operator: string;
startedAt: string;
endedAt: string;
cashTotalMinor: number;
cardTotalMinor: number;
currency: string | null;
paymentCount: number;
openingFloatMinor: number;
cashAddedMinor: number;
cashRemovedMinor: number;
expectedDrawerMinor: number;
}
/** Completed shift history. The server scopes by permission: operators get their
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
* filtered by operator + a from/to window over the shift start. `scope` echoes
* which the server applied, so the UI can show/hide the filter. */
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
shifts: ShiftSummary[];
scope: "all" | "self";
}> {
const qs = new URLSearchParams();
if (params.operator) qs.set("operator", params.operator);
if (params.from) qs.set("from", params.from);
if (params.to) qs.set("to", params.to);
const q = qs.toString();
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
}
// --- Site config / occupancy ---------------------------------------------- // --- Site config / occupancy ----------------------------------------------
export interface Occupancy { export interface Occupancy {
@@ -333,12 +581,200 @@ export interface Occupancy {
full: boolean; full: boolean;
} }
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
export interface SiteConfig {
capacity: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault: boolean;
/** Site default monthly subscription price (minor units); pre-fills the form. */
subscriptionMonthlyPriceMinor: number | null;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
nius: string | null;
address: string | null;
phone: string | null;
email: string | null;
/** IANA timezone for tariff wall-clock windows (e.g. "Europe/Tirane"). Copied into
* each published tariff version so its windows are frozen. */
timezone: string | null;
/** Default vehicle/customer category frozen onto each transient entry (V2 pricing). */
defaultVehicleCategory: string | null;
}
export function fetchOccupancy(): Promise<Occupancy> { export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy"); return apiFetch("/api/occupancy");
} }
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
// --- Device status (the booth footer) -------------------------------------
/** Live status of one configured device — mirrors the server's DeviceStatusEvent.
* Every enabled device is polled (printers via rich readStatus, the rest via
* healthCheck) and flattened to one traffic-light. Pushed over the WS; the REST
* snapshot below is the initial load / fallback. */
export interface DeviceStatus {
deviceId: string;
driverId: string;
category: "access" | "reader" | "camera" | "printer";
/** Role/direction token for the footer label (NOT the vendor) — the client
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
state: "ready" | "degraded" | "offline";
detail?: string;
checkedAt: string;
}
export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
return apiFetch("/api/devices/status");
}
// --- Ledger events (the signed audit trail; read-only) --------------------
/** A persisted ledger row. Re-exported from shared so UI code has one source of
* truth for the event shape (the same type the WS pushes). */
export type { LedgerEvent } from "@parking/shared";
/** Recent ledger events, newest first (default 100, max 1000). Used for the
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
* scopes to events at/after that instant — the booth passes the current shift's
* start so the feed shows ONLY this shift's activity. */
export function fetchEvents(
limit = 100,
since?: string,
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
const qs = new URLSearchParams({ limit: String(limit) });
if (since) qs.set("since", since);
return apiFetch(`/api/events?${qs.toString()}`);
}
// --- Booth: session lookup, payment, exit ---------------------------------
/** One-read session view for the booth pay/exit modal (mirrors server SessionLookup). */
export interface SessionLookup {
identity: string;
found: boolean;
open: boolean;
enteredAt: string | null;
exitedAt: string | null;
paidAt: string | null;
amountMinor: number | null;
currency: string | null;
withinGrace: boolean;
graceExpiresAt: string | null;
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
subscription: boolean;
subscriptionId: string | null;
subscriptionHolder: string | null;
}
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
export function lookupSession(identity: string): Promise<SessionLookup> {
return apiFetch(`/api/session/${encodeURIComponent(identity)}`);
}
/** One row in the booth Active Sessions list (mirrors server ActiveSession). */
export interface ActiveSession {
identity: string;
source: string | null;
enteredAt: string;
exitedAt: string | null;
open: boolean;
paidAt: string | null;
amountMinor: number | null;
currency: string | null;
withinGrace: boolean;
graceExpiresAt: string | null;
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
subscription: boolean;
subscriptionId: string | null;
subscriptionHolder: string | null;
}
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
export function fetchActiveSessions(): Promise<{ sessions: ActiveSession[] }> {
return apiFetch("/api/sessions/active");
}
/** Human-intervention barrier re-open for a paid active session (damaged ticket /
* phantom re-close). Signs an audited anomaly; never a 2nd exit. */
export function reopenBarrier(identity: string): Promise<{ ok: true; opened: boolean; reason?: string }> {
return apiFetch("/api/barrier/reopen", { method: "POST", body: JSON.stringify({ identity }) });
}
/** Take payment for a session → signed payment event. `overrideMinor` sets an
* operator amount (lost ticket / dispute). */
export function paySession(
identity: string,
tender: "cash" | "card",
overrideMinor?: number,
): Promise<{ amountMinor: number; currency: string }> {
return apiFetch("/api/pay", {
method: "POST",
body: JSON.stringify({ identity, tender, ...(overrideMinor != null ? { overrideMinor } : {}) }),
});
}
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
* open (payment stands; operator opens manually). */
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
/** Validate + open the barrier for a session from the booth (when near the exit). */
export function boothExit(identity: string): Promise<BoothExitResult> {
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
}
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
* for self-exit at a distant exit. Requires the session to be paid. */
export function printVoucher(identity: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch("/api/voucher", { method: "POST", body: JSON.stringify({ identity }) });
}
/** Print a standalone PAYMENT RECEIPT (entry/paid/duration/amount, no barcode).
* Auto-printed after a payment when no voucher is issued; also the "reprint"
* action. Requires the session to be paid. */
export function printReceipt(identity: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch("/api/receipt", { method: "POST", body: JSON.stringify({ identity }) });
}
// --- Snapshots (entry/exit evidence images) -------------------------------
export interface SnapshotMeta {
id: string;
direction: "entry" | "exit" | null;
deviceId: string;
identity: string;
contentType: string;
capturedAt: string;
}
/** A capture that was ATTEMPTED but failed (camera offline, config) — surfaced so a
* missing image isn't a silent gap. From snapshot telemetry, not the image store. */
export interface SnapshotFailure {
direction: "entry" | "exit" | null;
deviceId: string;
error: string;
occurredAt: string;
}
/** Snapshot metadata for a session identity (newest first) PLUS failed capture
* attempts. Image bytes are at `/api/snapshots/:id` — use that as an <img src>. */
export function fetchSnapshots(
identity: string,
): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[] }> {
return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`);
}
/** URL for a snapshot's image bytes (cookie-authed; usable as <img src>). */
export function snapshotImageUrl(id: string): string {
return `/api/snapshots/${encodeURIComponent(id)}`;
}
export function fetchSiteConfig(): Promise<SiteConfig> {
return apiFetch("/api/site-config"); return apiFetch("/api/site-config");
} }
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> { /** PUT a partial config — only the fields supplied are changed. */
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) }); export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
}
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
return saveSiteConfig({ capacity });
} }
+411
View File
@@ -0,0 +1,411 @@
@import "tailwindcss";
/* Bloomberg-terminal aesthetic: dense, dark, monospace, keyboard-first.
Tailwind v4 — design tokens live here in @theme (no tailwind.config.js).
The booth runs on a fixed appliance display; we optimise for a dark room,
glanceable status colour, and high information density over whitespace.
Token vocabulary adopted from the "TRM" design system (Tracking & Race
Management) — TOKENS ONLY: colours, type scale, spacing, radii, shadows.
None of TRM's race-timing components are used. The existing `term-*` accents
are aligned onto TRM's exact values so the whole booth UI inherits the TRM
palette without renaming a single class. TRM's full vocabulary is also
exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the
spacing/type/shadow scales) for new work.
Offline appliance: NO webfont @import (no network at runtime). Goldplay (the
TRM display face) is not self-hosted yet — display/heading text falls back to
a clean sans stack; wire local Goldplay @font-face here if it's wanted. */
@theme {
/* ============================================================
TERMINAL ACCENTS — aligned onto TRM's exact values.
These keep their `term-*` names (used across every screen),
but now resolve to TRM colours so the palette is unified.
============================================================ */
/* Surfaces — TRM "night" (trackside dark) scale. */
--color-term-bg: #0b0d10; /* TRM --night */
--color-term-panel: #14171c; /* TRM --night-2 */
--color-term-panel-2: #1e222a; /* TRM --night-3 */
--color-term-border: #2a2f38; /* TRM --night-line */
--color-term-muted: #8a8a82; /* TRM --night-fg-3 / --ink-4 */
--color-term-text: #f2f2ee; /* TRM --night-fg */
/* Status accents — TRM semantic colours. */
--color-term-amber: #f2a516; /* TRM --amber (caution / accent / focus) */
--color-term-green: #2e8c4a; /* TRM --green (entry / ok / free) */
--color-term-red: #e8412b; /* TRM --flag (exit / fault / full) */
--color-term-cyan: #2563c8; /* TRM --blue (payment / info / live) */
/* ============================================================
TRM FULL VOCABULARY — exposed as Tailwind utilities for new work.
============================================================ */
/* Ink & paper (light surfaces — for any light-on-dark inversions). */
--color-paper: #fafaf7;
--color-paper-2: #f2f2ee;
--color-paper-3: #e8e8e2;
--color-ink: #0e0e0c;
--color-ink-2: #2a2a26;
--color-ink-3: #5a5a53;
--color-ink-4: #8a8a82;
--color-ink-5: #b8b8b0;
--color-ink-6: #dcdcd4;
/* Night scale (the booth's working surfaces). */
--color-night: #0b0d10;
--color-night-2: #14171c;
--color-night-3: #1e222a;
--color-night-line: #2a2f38;
--color-night-fg: #f2f2ee;
--color-night-fg-2: #b8b8b0;
--color-night-fg-3: #8a8a82;
/* Race accents + semantic. */
--color-flag: #e8412b;
--color-flag-2: #c8331f;
--color-flag-tint: #fbe3de;
--color-amber: #f2a516;
--color-amber-2: #c88500;
--color-amber-tint: #fbefd0;
--color-green: #2e8c4a;
--color-green-2: #1f6a36;
--color-green-tint: #ddefe2;
--color-blue: #2563c8;
--color-blue-2: #1a4fa8;
--color-blue-tint: #dce6f8;
--color-violet: #6b46c1;
--color-magenta: #c9296f;
--color-teal: #188c8a;
--color-ok: #2e8c4a;
--color-warn: #f2a516;
--color-danger: #e8412b;
--color-info: #2563c8;
/* Data-viz categorical (8). */
--color-viz-1: #e8412b;
--color-viz-2: #2563c8;
--color-viz-3: #2e8c4a;
--color-viz-4: #f2a516;
--color-viz-5: #6b46c1;
--color-viz-6: #188c8a;
--color-viz-7: #c9296f;
--color-viz-8: #5a5a53;
/* ---------- TYPE — families ---------- */
/* Mono is the booth's primary face (data-dense, tabular). Display/UI fall
back to a clean sans (Goldplay not self-hosted — see header note). */
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
"Menlo", "Consolas", monospace;
--font-display: "Goldplay", "Helvetica Neue", Arial, sans-serif;
--font-ui: "Goldplay", "Helvetica Neue", Arial, sans-serif;
--font-body: "Inter", "Helvetica Neue", Arial, sans-serif;
/* ---------- TYPE — scale (TRM, optimised for data density) ---------- */
--text-overline: 11px;
--text-micro: 12px;
--text-small: 13px;
--text-body: 15px;
--text-lead: 17px;
--text-h6: 14px;
--text-h5: 16px;
--text-h4: 20px;
--text-h3: 26px;
--text-h2: 34px;
--text-h1: 48px;
--text-display: 72px;
--text-jumbo: 120px;
/* ---------- SPACING (TRM 4px base) ---------- */
--spacing-s0: 0;
--spacing-s1: 2px;
--spacing-s2: 4px;
--spacing-s3: 8px;
--spacing-s4: 12px;
--spacing-s5: 16px;
--spacing-s6: 20px;
--spacing-s7: 24px;
--spacing-s8: 32px;
--spacing-s9: 40px;
--spacing-s10: 48px;
--spacing-s11: 64px;
--spacing-s12: 80px;
--spacing-s13: 96px;
/* ---------- RADIUS — TRM is square-edged ---------- */
--radius-term: 2px; /* existing alias, kept */
--radius-r0: 0;
--radius-r1: 2px;
--radius-r2: 4px;
--radius-r3: 6px;
--radius-r4: 10px;
/* ---------- ELEVATION — TRM sharp "printed" offset shadows ---------- */
--shadow-term-1: 0 1px 0 0 #0e0e0c;
--shadow-term-2: 2px 2px 0 0 #0e0e0c;
--shadow-term-3: 4px 4px 0 0 #0e0e0c;
--shadow-soft: 0 1px 2px rgba(14, 14, 12, 0.06), 0 4px 12px rgba(14, 14, 12, 0.04);
--shadow-pop: 0 8px 24px rgba(14, 14, 12, 0.12);
/* ---------- MOTION (TRM) ---------- */
--ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
/* ---------- COMPONENT TOKENS (TRM control heights, table rows) ---------- */
--control-h-sm: 28px;
--control-h-md: 36px;
--control-h-lg: 44px;
--table-row-h: 36px;
--table-row-h-dense: 28px;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--color-term-bg);
color: var(--color-term-text);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
/* Crisp text and no rubber-banding on the fixed appliance display. */
overscroll-behavior: none;
}
/* Tabular numerics everywhere — counts, money, clocks must not jitter. */
.num,
.tabular {
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
/* Terminal scrollbars — thin, dark, unobtrusive. */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-term-border) transparent;
}
/* A visible keyboard-focus ring in the amber accent (keyboard-first UI). */
:focus-visible {
outline: 1px solid var(--color-term-amber);
outline-offset: 1px;
}
/* ============================================================
COMPONENT LAYER
The TRM tokens are good, but every screen hand-rolled inputs and
buttons as bare outlines on near-black panels, so a field, a card,
and a button were visually indistinguishable. These classes give
each control a real identity:
- inputs read as RECESSED slots (lighter fill + inset shadow)
- the primary button is FILLED (accent body, dark text) — the
one obvious action; neutrals are filled grey, not bare outlines
- explicit hover / active / focus / disabled states everywhere
Use @apply so the classes compose with Tailwind utilities.
============================================================ */
@layer components {
/* ---- Form fields: a recessed slot, clearly an input ---- */
.input,
.select,
.textarea {
@apply w-full rounded-term border bg-term-bg px-2.5 text-term-text
placeholder:text-term-muted;
border-color: #3a414c; /* lighter than panel borders */
height: var(--control-h-md);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45);
transition: border-color 120ms var(--ease-snap), box-shadow 120ms var(--ease-snap);
}
.textarea {
height: auto;
@apply py-2 leading-snug;
}
.input::placeholder,
.textarea::placeholder {
@apply text-term-muted;
}
.input:hover,
.select:hover,
.textarea:hover {
border-color: #4a525f;
}
.input:focus,
.select:focus,
.textarea:focus {
outline: none;
border-color: var(--color-term-amber);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45), 0 0 0 1px var(--color-term-amber);
}
.input:disabled,
.select:disabled,
.textarea:disabled {
@apply cursor-not-allowed opacity-50;
}
/* Small / dense variant for inline table cells */
.input-sm {
height: var(--control-h-sm);
@apply px-2 text-[12px];
}
.field {
@apply flex flex-col gap-1;
}
.label {
@apply text-[11px] uppercase tracking-wider text-term-muted;
}
.hint {
@apply text-[11px] leading-snug text-term-muted;
}
/* ---- Buttons: a button must look pressable, never like a field ---- */
.btn {
@apply inline-flex items-center justify-center gap-1.5 rounded-term border
px-3 text-[12px] font-semibold uppercase tracking-wider
transition-colors select-none;
height: var(--control-h-md);
/* Neutral default: a filled grey body, not a bare outline. */
background: var(--color-term-panel-2);
border-color: #3a414c;
color: var(--color-term-text);
}
.btn:hover:not(:disabled) {
background: #2a313b;
border-color: #4a525f;
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn:disabled {
@apply cursor-not-allowed opacity-40;
}
.btn-sm {
height: var(--control-h-sm);
@apply px-2.5 text-[11px];
}
.btn-lg {
height: var(--control-h-lg);
@apply px-5 text-[13px];
}
/* Primary: FILLED amber, dark text — the unmistakable main action. */
.btn-primary {
background: var(--color-term-amber);
border-color: var(--color-term-amber);
color: #0b0d10;
}
.btn-primary:hover:not(:disabled) {
background: #ffb733;
border-color: #ffb733;
}
/* Semantic filled variants (entry / payment / destructive). */
.btn-go {
background: var(--color-term-green);
border-color: var(--color-term-green);
color: #f2f2ee;
}
.btn-go:hover:not(:disabled) {
background: #38a85a;
border-color: #38a85a;
}
.btn-pay {
background: var(--color-term-cyan);
border-color: var(--color-term-cyan);
color: #f2f2ee;
}
.btn-pay:hover:not(:disabled) {
background: #2f74e0;
border-color: #2f74e0;
}
.btn-danger {
background: transparent;
border-color: var(--color-term-red);
color: var(--color-term-red);
}
.btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-term-red) 14%, transparent);
}
/* Ghost: lowest-emphasis (cancel, secondary nav) — text + hover only. */
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--color-term-muted);
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-term-panel-2);
color: var(--color-term-text);
border-color: transparent;
}
/* ---- Card: a panel that is clearly a container, not a field ---- */
.card {
@apply rounded-term border border-term-border bg-term-panel;
}
.card-head {
@apply flex items-center justify-between border-b border-term-border
bg-term-panel-2 px-4 py-2 text-[12px] uppercase tracking-wider text-term-muted;
}
.card-body {
@apply p-4;
}
}
/* ============================================================
LIGHT THEME
The booth defaults to dark (dark room), but a user may prefer light; the
choice is saved to their profile (users.theme) and applied on <html> as
`.theme-light`. Every screen reads colour through the --color-term-* tokens,
so re-pointing them here re-skins the whole app. The TRM "paper/ink" scale
supplies the surfaces; accents are tuned a shade darker for contrast on white.
A few component-layer values are literal hex (input borders, the inset
"recessed" shadow, primary-button text) — those are overridden too so fields
and buttons keep their affordance on a light background.
============================================================ */
html.theme-light {
/* Surfaces — TRM paper scale (light → slightly darker for layering). */
--color-term-bg: #fafaf7; /* paper */
--color-term-panel: #f2f2ee; /* paper-2 */
--color-term-panel-2: #e8e8e2; /* paper-3 */
--color-term-border: #d2d2c8;
--color-term-muted: #5a5a53; /* ink-3 — readable secondary text */
--color-term-text: #14171c; /* near-black ink */
/* Accents — a step darker than the dark-theme values for white-bg contrast. */
--color-term-amber: #b8740a;
--color-term-green: #1f6a36;
--color-term-red: #c8331f;
--color-term-cyan: #1a4fa8;
}
/* Component-layer literals that must flip for light (the rest read tokens). */
html.theme-light .input,
html.theme-light .select,
html.theme-light .textarea {
border-color: #c2c2b8;
box-shadow: inset 0 1px 2px rgba(20, 23, 28, 0.08);
}
html.theme-light .input:hover,
html.theme-light .select:hover,
html.theme-light .textarea:hover {
border-color: #a8a89e;
}
html.theme-light .input:focus,
html.theme-light .select:focus,
html.theme-light .textarea:focus {
box-shadow: inset 0 1px 2px rgba(20, 23, 28, 0.08), 0 0 0 1px var(--color-term-amber);
}
html.theme-light .btn {
border-color: #c2c2b8;
}
html.theme-light .btn:hover:not(:disabled) {
background: #dcdcd4;
border-color: #a8a89e;
}
/* Filled buttons keep light text; primary uses dark-on-amber, kept legible. */
html.theme-light .btn-primary {
color: #fafaf7;
}
+30
View File
@@ -0,0 +1,30 @@
// Small formatting helpers for the booth. Money is integer MINOR units (never a
// float — matches the tariff/ledger model); duration is whole minutes.
/** Format integer minor units + ISO-4217 currency as a major-unit string. */
export function formatMoney(amountMinor: number, currency: string): string {
const major = amountMinor / 100;
try {
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(major);
} catch {
// Unknown/garbled currency code — fall back to a plain number + the code.
return `${major.toFixed(2)} ${currency}`;
}
}
/** Human duration between two ISO times, e.g. "2h 14m" / "47m" / "0m". */
export function formatDuration(fromIso: string, toIso: string): string {
const ms = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(ms) || ms < 0) return "—";
const mins = Math.floor(ms / 60_000);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
/** Local time-of-day HH:MM:SS from an ISO string. */
export function formatTime(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
}
+514
View File
@@ -0,0 +1,514 @@
// English (en). Mirrors the key structure of sq.ts (the default/fallback). Any key
// missing here falls back to Albanian. See wiki/concepts/i18n.md.
import type { Catalog } from "./sq.js";
export const en: Catalog = {
common: {
loading: "Loading…",
logout: "Log out",
cancel: "Cancel",
close: "Close",
save: "Save",
none: "—",
themeDark: "dark",
themeLight: "light",
theme: "Theme",
},
auth: {
title: "Parking System",
username: "Username",
password: "Password",
signIn: "Sign in",
signingIn: "Signing in…",
},
nav: {
booth: "Booth",
shift: "Shift",
setup: "Setup",
devices: "Devices",
tariff: "Tariff",
subscriptions: "Subscriptions",
site: "Site",
users: "Users",
roles: "Roles",
shifts: "Shifts",
},
status: {
live: "LIVE",
connecting: "CONNECTING",
offline: "OFFLINE",
},
devices: {
footerTitle: "Devices",
none: "No devices configured.",
catAccess: "Barrier",
catReader: "Reader",
catCamera: "Camera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Reader entry").
role: {
entry: "entry",
exit: "exit",
both: "entry/exit",
mixed: "entry/exit",
lane: "at lane",
booth: "at booth",
},
state: {
ready: "ready",
degraded: "degraded",
offline: "offline",
},
allOk: "all ready",
issuesCount: "{{count}} with issues",
issuesTitle: "Device issues",
clickForIssues: "Click for details",
checkedAt: "checked {{time}}",
},
booth: {
processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…",
open: "Open",
occupancy: "Occupancy",
occUnavailable: "occupancy unavailable",
inside: "inside",
of: "of",
uncapped: "uncapped",
free: "free",
lotFull: "● lot full",
liveFeed: "Live feed",
events: "events",
noEventsYet: "No events yet — entries and exits will stream here.",
activeSessions: "Active sessions",
insideCount: "inside",
noActiveSessions: "No active sessions.",
inAt: "in",
openPayExit: "Open pay / exit",
openBarrier: "Open barrier",
openBarrierTitle: "Human-intervention barrier open (audited)",
barrierOpened: "barrier opened",
openManually: "open manually",
badgeExiting: "exiting",
badgePaid: "paid",
badgeUnpaid: "unpaid",
badgeSubscription: "subscription",
evtEntry: "ENTRY",
evtExit: "EXIT",
evtPay: "PAY",
evtVoid: "VOID",
evtOpenCmd: "OPEN→",
evtOpenObserved: "OPEN✓",
evtShiftOpen: "SHIFT+",
evtShiftZ: "SHIFT Z",
evtCashMovement: "CASH",
evtAnomaly: "ANOMALY",
// live-feed event detail line + classification badges (computed from payload)
evtNoReason: "no reason recorded",
badgeEntryRefused: "entry refused",
badgeExitRefused: "exit refused",
badgeLotFull: "lot full",
badgeBarrierFailed: "barrier did not open",
badgeManualOpen: "manual open",
badgeSubRefused: "subscription refused",
badgeNoTicket: "ticket not printed",
feedSourceBooth: "booth",
feedSourceReader: "reader",
// event-detail modal
eventDetail: "Event detail",
edType: "Type",
edTime: "Time",
edIndex: "Ledger index",
edDirection: "Direction",
edSource: "Source",
edIdentity: "Identity",
edReason: "Reason",
edSnapshots: "Snapshots",
edPayload: "Signed payload",
edChain: "Chain",
edSignature: "Signature",
edKeyId: "Key",
edPrevHash: "Prev hash",
edNoPayload: "No payload on this event.",
edCopy: "Copy",
edCopied: "Copied",
edDetails: "Details",
edAuditData: "Audit data (signature & chain)",
edAmount: "Amount",
edTender: "Tender",
edSession: "Session",
edPlate: "Plate",
edCategory: "Category",
edOperator: "Operator",
edTariffVersion: "Tariff version",
edRawPayload: "Raw signed payload",
edOccurrence: "Occurrence id",
subscriber: "Subscriber",
},
// Localized messages for the signed REASON_CODES (see @parking/shared). Keys MUST
// match the codes 1:1; {{param}} placeholders are filled from the event's
// reasonParams. Legacy events with no code fall back to the signed English `reason`.
reason: {
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
"exit.refused.closed": "Exit refused — session already closed",
"exit.refused.noSession": "Exit refused — no open session for ticket",
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
"exit.refused.graceExpired": "Exit refused — walk-back grace expired (top-up required)",
"exit.open.noBarrier": "Exit recorded, but no exit barrier is configured — open manually",
"exit.open.unavailable": "Exit recorded, but the barrier is unavailable — open manually",
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
"exit.freeGrace": "Free entry-grace (no charge)",
"exit.manualOpen": "Manual barrier open (human intervention)",
"sub.refused.notFound": "Subscription refused — not found",
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
},
tariff: {
title: "Tariff",
noRateCard: "No tariff published yet — the pay station can't charge until you publish one.",
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
currency: "Currency",
freeEntryGrace: "Free entry grace (min)",
billingIncrement: "Billing increment (min)",
dailyCap: "Daily cap (blank = none)",
dailyCapPh: "e.g. 12.00",
lostTicketFee: "Lost-ticket fee",
exitGrace: "Exit walk-back grace (min)",
rateBlocks: "Rate blocks",
rateBlocksHint: "Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.",
bandDuration: "Band duration",
hoursUnit: "hours",
egHours: "e.g. 2",
pricePerIncrement: "Price / increment",
thereafter: "thereafter (open-ended)",
remove: "Remove",
addBlock: "+ Add block",
publishNewVersion: "Publish new version",
publishing: "Publishing…",
publishedOk: "New tariff version published — it's now the active rate.",
defaultCard: "Base rate (always active)",
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
modeLadder: "Hourly ladder",
modeFlat: "Flat price",
tiersAdvanced: "Advanced: time & seasonal tiers",
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
tierName: "Name",
tierPriority: "Priority",
tierCategory: "Category",
tierCategoryPh: "e.g. bus",
tierDays: "Days",
tierHours: "Hours",
tierDates: "Dates",
tierOvernight: "(crosses midnight)",
addTier: "+ Add tier",
dow1: "Mon",
dow2: "Tue",
dow3: "Wed",
dow4: "Thu",
dow5: "Fri",
dow6: "Sat",
dow0: "Sun",
},
setup: {
title: "Setup",
intro:
"Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.",
catControllers: "Controllers (barriers + entry button)",
catReaders: "Readers (QR / RFID)",
catCameras: "Cameras (snapshot + plate)",
catPrinters: "Printers (tickets / vouchers)",
nounController: "controller",
nounReader: "reader",
nounCamera: "camera",
nounPrinter: "printer",
add: "+ Add {{noun}}",
addAnother: "+ Add another {{noun}}",
addTitle: "Add {{noun}}",
editTitle: "Edit {{noun}}",
needControllerFirst: "Add a controller first — a {{noun}} points at one of its relays.",
failedToLoad: "Failed to load setup: {{error}}",
loadingCatalog: "Loading device catalog…",
dirEntry: "Entry",
dirExit: "Exit",
dirBoth: "Both (entry + exit)",
inherits: "inherits {{direction}}",
warnTitle: "⚠ Saved, but action needed:",
dismiss: "Dismiss",
disabled: "(disabled)",
edit: "Edit",
remove: "Remove",
removing: "Removing…",
confirmRemove: "Remove this {{driver}} device?",
noRelaysSet: "no relays set",
unbound: "unbound",
noDrivers: "No drivers registered.",
chooseDevice: "Choose a device…",
scan: "Scan for controllers",
scanning: "Scanning…",
noControllersFound: "No controllers found on the LAN.",
use: "Use",
test: "Test connection",
testing: "Testing…",
saveConfigure: "Save & configure",
saveChanges: "Save changes",
saving: "Saving…",
cancel: "Cancel",
testFailed: "Test failed: {{error}}",
saveFailed: "Save failed: {{error}}",
deviceLabel: "Device:",
preconditionsOk: "● preconditions OK",
autoFixedOnSave: "(auto-fixed on save)",
backendPushIp: "Backend push IP",
chooseAddress: "Choose an address…",
onDeviceSubnet: "— on device subnet",
noNicOnSubnet: "⚠ no NIC on the device's subnet — the device may not reach the backend",
backendIpHint: "The address this device will POST input events to.",
relaysTitle: "Relays on this controller",
relaysHint:
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
relay: "Relay",
entryButtonTerminal: "Entry button on terminal",
addRelay: "+ Add relay",
whichBarrier: "Which barrier does this device serve?",
controller: "Controller",
choose: "Choose…",
relayLabel: "Relay {{relay}} ({{direction}})",
noRelaysConfigured: "This controller has no relays configured.",
},
subs: {
title: "Subscriptions",
unnamed: "(unnamed)",
unbound: "unbound",
car_one: "{{count}} car",
car_other: "{{count}} cars",
cred: "cred",
plates: "{{count}} plate(s)",
noPrice: "no price",
perMonth: "month",
monthlyPrice: "Monthly price",
pricePlaceholder: "e.g. 10000",
edit: "Edit",
revoke: "Revoke",
delete: "Delete",
noneYet: "No subscriptions yet.",
add: "+ Add subscription",
new: "New subscription",
editTitle: "Edit subscription",
holderName: "Holder name",
contact: "Contact",
carLimit: "Car limit",
limitCarsInAtOnce: "limit cars in at once",
validFrom: "Valid from",
validTo: "Valid to",
months: "Months",
monthsHint: "months paid",
coverageHint: "until {{end}}",
totalDue: "total {{total}}",
validToOverride: "Valid to (manual)",
isoDateOptional: "ISO date (optional)",
boundPlates: "Bound plates",
commaSeparatedOptional: "comma-separated (optional)",
credentials: "Credentials",
credentialsCardQr: "Credentials (card / QR)",
rfCardTag: "RF card/tag",
rfCardTagSoon: "RF card/tag (soon)",
rfPlaceholder: "card number (or read the card)",
readCard: "Read card",
captureChooseReader: "Choose a reader, then present the card:",
captureNoReaders: "No readers configured.",
captureWaiting: "Present the card to the reader…",
captureTimeout: "Timed out with no card read. Try again.",
captured: "Card read: {{value}}",
qr: "QR",
qrAutoGen: "QR code is auto-generated on save",
credentialValue: "credential value",
addCredential: "+ credential",
needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.",
save: "Save",
cancel: "Cancel",
saved: "Subscription saved.",
savedPrinted: "Subscription saved — QR code printed.",
savedPrintFailed: "Subscription saved, but printing failed ({{error}}). Use \"Print code\".",
printCode: "Print code",
printedOn: "Code printed on {{printer}}.",
confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
statusActive: "active",
statusSuspended: "suspended",
statusRevoked: "revoked",
},
site: {
occupancy: "Occupancy:",
noCapacitySet: "(no capacity set)",
free: "free",
full: "FULL",
capacityLabel: "Capacity (blank = no limit):",
capacityPlaceholder: "e.g. 120",
printExitDefault: "Print exit ticket by default",
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save",
saved: "Saved.",
fieldParkName: "Park name",
fieldParkNamePh: "e.g. Acme Parking",
fieldOperator: "Operator (legal name)",
fieldOperatorPh: "operating company",
fieldNius: "NIUS",
fieldNiusPh: "e.g. L01234567A",
fieldAddress: "Address",
fieldPhone: "Phone",
fieldEmail: "Email",
},
users: {
title: "Users",
add: "+ Add user",
new: "New user",
none: "No users.",
username: "Username",
password: "Password",
passwordHint: "At least 8 characters.",
newPassword: "new password",
role: "Role",
resetPassword: "Reset password",
edit: "Edit",
editTitle: "Edit user",
save: "Save",
delete: "Delete",
confirmDelete: "Delete user \"{{name}}\"?",
detailsSection: "Details (optional)",
fullName: "Full name",
phone: "Phone",
email: "Email",
address: "Address",
},
roles: {
title: "Roles",
add: "+ Add role",
new: "New role",
editTitle: "Edit role",
name: "Name",
permissions: "Permissions",
builtin: "built-in",
edit: "Edit",
delete: "Delete",
confirmDelete: "Delete role \"{{name}}\"?",
permCount_one: "{{count}} permission",
permCount_other: "{{count}} permissions",
userCount_one: "{{count}} user",
userCount_other: "{{count}} users",
},
shift: {
label: "Shift:",
open: "open",
notStarted: "not started",
since: "since",
startShift: "Start shift",
starting: "Starting…",
endShift: "End shift",
ending: "Ending…",
drawer: "Drawer:",
openingFloatInherited: "(opening float inherited from the prior shift)",
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
amount: "amount",
reasonPlaceholder: "reason (e.g. opening float)",
load: "Load +",
remove: "Remove −",
enterPositive: "Enter a positive amount.",
drawerNow: "Drawer now {{amount}}.",
zReport: "Z-REPORT",
payments: "Payments:",
cash: "Cash:",
card: "Card:",
drawerSection: "— Drawer —",
openingFloat: "Opening float:",
cashTaken: "Cash taken:",
cashAdded: "Cash added:",
cashRemoved: "Cash removed:",
expectedDrawer: "Expected drawer:",
printedToReceipt: "Printed to booth receipt.",
recordedNoPrinter: "Recorded (no printer to print to).",
// Header shift control + the booth shift gate.
headerNoShift: "No shift",
headerOpen: "Open shift",
headerClose: "Close shift",
headerHeldBy: "Shift open — {{operator}}",
headerHeldByShort: "Shift: {{operator}}",
gateTitle: "Open a shift to process tickets",
gateBody:
"No shift is open. Open your shift so payments and exits are recorded against it.",
gateOtherTitle: "The open shift belongs to another operator",
gateOtherBody:
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
openNow: "Open shift now",
opening: "Opening…",
},
shifts: {
title: "Shift history",
myTitle: "My shifts",
none: "No closed shifts.",
operator: "Operator",
started: "Started",
ended: "Ended",
payments: "Payments",
cash: "Cash",
card: "Card",
expectedDrawer: "Expected drawer",
filterFrom: "From",
filterTo: "To",
allOperators: "All operators",
apply: "Apply",
clear: "Clear",
drawerSection: "Drawer",
openingFloat: "Opening float",
cashTaken: "Cash taken",
cashAdded: "Cash added",
cashRemoved: "Cash removed",
loadFailed: "Failed to load shifts.",
},
pay: {
ticket: "Ticket",
entry: "Entry",
now: "Now",
duration: "Duration",
statusLabel: "Status",
paid: "PAID",
unpaid: "UNPAID",
total: "Total",
noTariff: "no tariff",
tender: "Tender",
cash: "Cash",
card: "Card",
printExitVoucher: "Print exit ticket",
selfExitHint: "(customer self-exits at the exit)",
payAndOpen: "Pay + open barrier",
payAndVoucher: "Pay + print voucher",
openBarrier: "Open barrier",
printVoucher: "Print voucher",
takingPayment: "taking payment…",
printingVoucher: "printing voucher…",
opening: "opening…",
noSessionFound: "No session found for this ticket.",
alreadyClosed: "This session is already closed (exited {{time}}).",
lookingUp: "looking up…",
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
subscription: "SUBSCRIPTION",
plan: "Plan",
prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip)
receiptPrintFailed: "(receipt didn't print — use \"Reprint receipt\".)",
receiptReprinted: "Receipt reprinted on {{printer}}.",
reprintReceipt: "Reprint receipt",
reprinting: "printing…",
noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…",
snapEntry: "entry",
snapExit: "exit",
snapFailed: "camera unreachable",
},
};
+33
View File
@@ -0,0 +1,33 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { sq } from "./sq.js";
import { en } from "./en.js";
// i18next setup for the operator UI. Albanian (sq) is the DEFAULT and the fallback;
// English (en) is the second language. The active language is the LOGGED-IN USER's
// stored preference (users.language), applied via setLanguage() after auth resolves
// — not localStorage, not the browser. Printed tickets are NOT governed by this
// (always Albanian, customer-facing). See wiki/concepts/i18n.md.
export type Lang = "sq" | "en";
// Single flat namespace; keys are dot-paths (e.g. "booth.processTicket"). Nested
// objects in the catalogs are walked by i18next's keySeparator.
void i18n.use(initReactI18next).init({
resources: {
sq: { translation: sq },
en: { translation: en },
},
lng: "sq",
fallbackLng: "sq",
interpolation: { escapeValue: false }, // React already escapes
returnNull: false,
});
/** Apply a language (e.g. after login resolves the user's preference). No-op if
* already active. */
export function setLanguage(lang: Lang): void {
if (i18n.language !== lang) void i18n.changeLanguage(lang);
}
export default i18n;
+532
View File
@@ -0,0 +1,532 @@
// Albanian (sq) — the DEFAULT and fallback language. Customer/operator-facing copy.
// Keys are dot-namespaced by area (common, nav, booth, …). When adding a string,
// add it here first (the fallback), then mirror the key in en.ts.
// See wiki/concepts/i18n.md.
export const sq = {
common: {
loading: "Duke u ngarkuar…",
logout: "Dil",
cancel: "Anulo",
close: "Mbyll",
save: "Ruaj",
none: "—",
themeDark: "errët",
themeLight: "çelët",
theme: "Tema",
},
auth: {
title: "Sistemi i Parkimit",
username: "Përdoruesi",
password: "Fjalëkalimi",
signIn: "Hyr",
signingIn: "Duke hyrë…",
},
nav: {
booth: "Kabina",
shift: "Turni",
setup: "Konfigurimi",
devices: "Pajisjet",
tariff: "Tarifa",
subscriptions: "Abonimet",
site: "Park",
users: "Përdoruesit",
roles: "Rolet",
shifts: "Turnet",
},
status: {
live: "LIVE",
connecting: "DUKE U LIDHUR",
offline: "JASHTË LINJE",
},
devices: {
footerTitle: "Pajisjet",
none: "Asnjë pajisje e konfiguruar.",
catAccess: "Barriera",
catReader: "Lexuesi",
catCamera: "Kamera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
role: {
entry: "hyrje",
exit: "dalje",
both: "hyrje/dalje",
mixed: "hyrje/dalje",
lane: "në korsi",
booth: "në kabinë",
},
state: {
ready: "gati",
degraded: "i dëmtuar",
offline: "jashtë linje",
},
allOk: "të gjitha gati",
issuesCount: "{{count}} me probleme",
issuesTitle: "Problemet e pajisjeve",
clickForIssues: "Kliko për detajet",
checkedAt: "kontrolluar {{time}}",
},
booth: {
processTicket: "Proceso biletën",
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
open: "Hap",
occupancy: "Prania",
occUnavailable: "zënia e padisponueshme",
inside: "brenda",
of: "nga",
uncapped: "pa kufi",
free: "Vende të lira",
lotFull: "● parkimi plot",
liveFeed: "Aktiviteti live",
events: "Evente",
noEventsYet: "Asnjë event ende — hyrjet dhe daljet do të shfaqen këtu.",
activeSessions: "Sesionet aktive",
insideCount: "brenda",
noActiveSessions: "Asnjë sesion aktiv.",
inAt: "në",
openPayExit: "Hap pagesën / daljen",
openBarrier: "Hap barrierën",
openBarrierTitle: "Hap barrierën manualisht",
barrierOpened: "barriera u hap",
openManually: "hape me dorë",
// session row badges
badgeExiting: "duke dalë",
badgePaid: "paguar",
badgeUnpaid: "papaguar",
badgeSubscription: "abonim",
// event types (live feed labels)
evtEntry: "HYRJE",
evtExit: "DALJE",
evtPay: "PAGESË",
evtVoid: "ANULIM",
evtOpenCmd: "HAP→",
evtOpenObserved: "HAP✓",
evtShiftOpen: "TURN+",
evtShiftZ: "TURN Z",
evtCashMovement: "ARKË",
evtAnomaly: "ANOMALI",
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
evtNoReason: "pa arsye të regjistruar",
badgeEntryRefused: "hyrje e refuzuar",
badgeExitRefused: "dalje e refuzuar",
badgeLotFull: "parkimi plot",
badgeBarrierFailed: "barriera nuk u hap",
badgeManualOpen: "hapje manuale",
badgeSubRefused: "abonimi u refuzua",
badgeNoTicket: "bileta nuk u printua",
feedSourceBooth: "kabinë",
feedSourceReader: "lexues",
// dritarja e detajeve të eventit
eventDetail: "Detajet e eventit",
edType: "Lloji",
edTime: "Ora",
edIndex: "Indeksi në regjistër",
edDirection: "Drejtimi",
edSource: "Burimi",
edIdentity: "Identiteti",
edReason: "Arsyeja",
edSnapshots: "Fotot",
edPayload: "Të dhënat e nënshkruara",
edChain: "Zinxhiri",
edSignature: "Nënshkrimi",
edKeyId: "Çelësi",
edPrevHash: "Hash-i i mëparshëm",
edNoPayload: "Ky event nuk ka të dhëna shtesë.",
edCopy: "Kopjo",
edCopied: "U kopjua",
edDetails: "Detajet",
edAuditData: "Të dhënat e auditimit (nënshkrimi & zinxhiri)",
edAmount: "Shuma",
edTender: "Mënyra e pagesës",
edSession: "Sesioni",
edPlate: "Targa",
edCategory: "Kategoria",
edOperator: "Operatori",
edTariffVersion: "Versioni i tarifës",
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
edOccurrence: "ID e hyrjes",
subscriber: "Abonent",
},
// Mesazhet e përkthyera për REASON_CODES e nënshkruara (shih @parking/shared).
// Çelësat përputhen 1:1 me kodet; {{param}} mbushet nga reasonParams i eventit.
reason: {
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
"exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)",
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape me dorë",
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape me dorë",
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape me dorë",
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
"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)",
},
tariff: {
title: "Tarifa",
noRateCard: "Asnjë tarifë e publikuar — arka nuk mund të faturojë derisa të publikoni një.",
activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.",
currency: "Monedha",
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
billingIncrement: "Intervali i faturimit (min)",
dailyCap: "Kufiri ditor (bosh = pa kufi)",
dailyCapPh: "p.sh. 12.00",
lostTicketFee: "Tarifa për biletë të humbur",
exitGrace: "Periudha e kthimit në dalje (min)",
rateBlocks: "Blloqet tarifore",
rateBlocksHint: "Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.",
bandDuration: "Kohëzgjatja e brezit",
hoursUnit: "orë",
egHours: "p.sh. 2",
pricePerIncrement: "Çmimi / interval",
thereafter: "më pas (i hapur)",
remove: "Hiq",
addBlock: "+ Shto bllok",
publishNewVersion: "Publiko version të ri",
publishing: "Duke publikuar…",
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
defaultCard: "Tarifa bazë (gjithmonë aktive)",
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
modeLadder: "Shkallë orësh",
modeFlat: "Çmim fiks",
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
tierName: "Emri",
tierPriority: "Përparësia",
tierCategory: "Kategoria",
tierCategoryPh: "p.sh. autobus",
tierDays: "Ditët",
tierHours: "Orët",
tierDates: "Datat",
tierOvernight: "(kalon mesnatën)",
addTier: "+ Shto nivel",
dow1: "Hën",
dow2: "Mar",
dow3: "Mër",
dow4: "Enj",
dow5: "Pre",
dow6: "Sht",
dow0: "Die",
},
setup: {
title: "Konfigurimi",
intro:
"Shto fillimisht kontrolluesit e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
// Category titles + the singular noun used in buttons/modal titles.
catControllers: "Kontrolluesit (barrierat + butoni i hyrjes)",
catReaders: "Lexuesit (QR / RFID)",
catCameras: "Kamerat (foto + targë)",
catPrinters: "Printerat (bileta / vouchera)",
nounController: "kontrollues",
nounReader: "lexues",
nounCamera: "kamerë",
nounPrinter: "printer",
add: "+ Shto {{noun}}",
addAnother: "+ Shto edhe një {{noun}}",
addTitle: "Shto {{noun}}",
editTitle: "Ndrysho {{noun}}",
needControllerFirst: "Shto fillimisht një kontrollues — {{noun}} drejtohet te një prej releve të tij.",
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
// Direction labels (relay direction + inherited binding).
dirEntry: "Hyrje",
dirExit: "Dalje",
dirBoth: "Hyrje + dalje",
inherits: "trashëgon {{direction}}",
// Warnings panel.
warnTitle: "⚠ U ruajt, por nevojitet veprim:",
dismiss: "Mbyll",
// Assignment row.
disabled: "(çaktivizuar)",
edit: "Ndrysho",
remove: "Hiq",
removing: "Duke hequr…",
confirmRemove: "Të hiqet kjo pajisje {{driver}}?",
noRelaysSet: "asnjë rele e caktuar",
unbound: "e palidhur",
// Device form.
noDrivers: "Asnjë drejtues i regjistruar.",
chooseDevice: "Zgjidh një pajisje…",
scan: "Skano për kontrollues",
scanning: "Duke skanuar…",
noControllersFound: "Asnjë kontrollues në LAN.",
use: "Përdor",
test: "Testo lidhjen",
testing: "Duke testuar…",
saveConfigure: "Ruaj & konfiguro",
saveChanges: "Ruaj ndryshimet",
saving: "Duke ruajtur…",
cancel: "Anulo",
testFailed: "Testi dështoi: {{error}}",
saveFailed: "Ruajtja dështoi: {{error}}",
deviceLabel: "Pajisja:",
preconditionsOk: "● parakushtet OK",
autoFixedOnSave: "(rregullohet vetë në ruajtje)",
backendPushIp: "IP-ja e backend-it",
chooseAddress: "Zgjidh një adresë…",
onDeviceSubnet: "— në subnetin e pajisjes",
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
// Relay editor.
relaysTitle: "Relet në këtë kontrollues",
relaysHint:
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
relay: "Rele",
entryButtonTerminal: "Butoni i hyrjes në terminalin",
addRelay: "+ Shto rele",
// Binding picker.
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
controller: "Kontrolluesi",
choose: "Zgjidh…",
relayLabel: "Rele {{relay}} ({{direction}})",
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
},
subs: {
title: "Abonimet",
unnamed: "(pa emër)",
unbound: "pa kufizim",
car_one: "{{count}} makinë",
car_other: "{{count}} makina",
cred: "kredencial",
plates: "{{count}} targë(a)",
noPrice: "pa çmim",
perMonth: "muaj",
monthlyPrice: "Çmimi mujor",
pricePlaceholder: "p.sh. 10000",
edit: "Ndrysho",
revoke: "Anulo",
delete: "Fshij",
noneYet: "Asnjë abonim ende.",
add: "+ Shto abonim",
new: "Abonim i ri",
editTitle: "Ndrysho abonimin",
holderName: "Emri i mbajtësit",
contact: "Kontakti",
carLimit: "Kufiri i makinave",
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
validFrom: "Vlen nga",
validTo: "Vlen deri",
months: "Muaj",
monthsHint: "muaj të paguar",
coverageHint: "deri më {{end}}",
totalDue: "gjithsej {{total}}",
validToOverride: "Vlen deri (manual)",
isoDateOptional: "Datë ISO (opsionale)",
boundPlates: "Targat e lidhura",
commaSeparatedOptional: "të ndara me presje (opsionale)",
credentials: "Kredencialet",
credentialsCardQr: "Kredencialet (kartë / QR)",
rfCardTag: "Kartë/Tag RF",
rfCardTagSoon: "Kartë/Tag RF (së shpejti)",
rfPlaceholder: "numri i kartës (ose lexo kartën)",
readCard: "Lexo kartën",
captureChooseReader: "Zgjidh lexuesin, pastaj afro kartën:",
captureNoReaders: "Asnjë lexues i konfiguruar.",
captureWaiting: "Afro kartën te lexuesi…",
captureTimeout: "Skadoi pa lexuar kartë. Provo sërish.",
captured: "Karta u lexua: {{value}}",
qr: "QR",
qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje",
credentialValue: "vlera e kredencialit",
addCredential: "+ kredencial",
needCredentialOrPlate: "Një abonim kërkon të paktën një kredencial OSE një targë të lidhur.",
save: "Ruaj",
cancel: "Anulo",
saved: "Abonimi u ruajt.",
savedPrinted: "Abonimi u ruajt — kodi QR u printua.",
savedPrintFailed: "Abonimi u ruajt, por printimi dështoi ({{error}}). Përdor \"Printo kodin\".",
printCode: "Printo kodin",
printedOn: "Kodi u printua te {{printer}}.",
confirmRevoke: "Të anulohet abonimi për {{name}}? Do të refuzohet te barriera.",
confirmDelete: "Të fshihet abonimi për {{name}}? (t e kaluara ruhen.)",
statusActive: "aktiv",
statusSuspended: "pezulluar",
statusRevoked: "anuluar",
},
site: {
occupancy: "Prania:",
noCapacitySet: "(pa kapacitet të caktuar)",
free: "lirë",
full: "PLOT",
capacityLabel: "Kapaciteti (bosh = pa kufi):",
capacityPlaceholder: "p.sh. 120",
printExitDefault: "Printo biletën e daljes si parazgjedhje",
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj",
saved: "U ruajt.",
fieldParkName: "Emri i parkimit",
fieldParkNamePh: "p.sh. Acme Parking",
fieldOperator: "Operatori (emri ligjor)",
fieldOperatorPh: "kompania operuese",
fieldNius: "NIUS",
fieldNiusPh: "p.sh. L01234567A",
fieldAddress: "Adresa",
fieldPhone: "Telefoni",
fieldEmail: "Email",
},
users: {
title: "Përdoruesit",
add: "+ Shto përdorues",
new: "Përdorues i ri",
none: "Asnjë përdorues.",
username: "Përdoruesi",
password: "Fjalëkalimi",
passwordHint: "Të paktën 8 karaktere.",
newPassword: "fjalëkalim i ri",
role: "Roli",
resetPassword: "Rivendos fjalëkalimin",
edit: "Ndrysho",
editTitle: "Ndrysho përdoruesin",
save: "Ruaj",
delete: "Fshi",
confirmDelete: "Të fshihet përdoruesi \"{{name}}\"?",
// Optional profile metadata.
detailsSection: "Të dhënat (opsionale)",
fullName: "Emri i plotë",
phone: "Telefoni",
email: "Email",
address: "Adresa",
},
roles: {
title: "Rolet",
add: "+ Shto rol",
new: "Rol i ri",
editTitle: "Ndrysho rolin",
name: "Emri",
permissions: "Lejet",
builtin: "i integruar",
edit: "Ndrysho",
delete: "Fshi",
confirmDelete: "Të fshihet roli \"{{name}}\"?",
permCount_one: "{{count}} leje",
permCount_other: "{{count}} leje",
userCount_one: "{{count}} përdorues",
userCount_other: "{{count}} përdorues",
},
shift: {
label: "Turni:",
open: "hapur",
notStarted: "i panisur",
since: "që nga",
startShift: "Fillo turnin",
starting: "Duke filluar…",
endShift: "Mbyll turnin",
ending: "Duke mbyllur…",
drawer: "Arka:",
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
amount: "shuma",
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
load: "Shto +",
remove: "Hiq −",
enterPositive: "Shkruaj një shumë pozitive.",
drawerNow: "Arka tani {{amount}}.",
zReport: "RAPORT Z",
payments: "Pagesa:",
cash: "Para:",
card: "Kartë:",
drawerSection: "— Arka —",
openingFloat: "Bilanci fillestar:",
cashTaken: "Para të marra:",
cashAdded: "Para të shtuara:",
cashRemoved: "Para të hequra:",
expectedDrawer: "Arka e pritshme:",
printedToReceipt: "Printuar te printeri i kabinës.",
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
// Header shift control + the booth shift gate.
headerNoShift: "Asnjë turn",
headerOpen: "Hap turnin",
headerClose: "Mbyll turnin",
headerHeldBy: "Turn i hapur nga {{operator}}",
headerHeldByShort: "Turni: {{operator}}",
gateTitle: "Hap një turn për të proceduar biletat",
gateBody:
"Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.",
gateOtherTitle: "Turni i hapur i përket një operatori tjetër",
gateOtherBody:
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
openNow: "Hap turnin tani",
opening: "Duke hapur…",
},
shifts: {
title: "Historiku i turneve",
myTitle: "Turnet e mia",
none: "Asnjë turn i mbyllur.",
operator: "Operatori",
started: "Filloi",
ended: "Mbaroi",
payments: "Pagesa",
cash: "Para",
card: "Kartë",
expectedDrawer: "Arka e pritshme",
// Filter (admin only).
filterFrom: "Nga",
filterTo: "Deri",
allOperators: "Të gjithë operatorët",
apply: "Apliko",
clear: "Pastro",
// Expanded drawer detail.
drawerSection: "Arka",
openingFloat: "Bilanci fillestar",
cashTaken: "Para të marra",
cashAdded: "Para të shtuara",
cashRemoved: "Para të hequra",
loadFailed: "Ngarkimi i turneve dështoi.",
},
pay: {
ticket: "Bileta",
entry: "Hyrja",
now: "Tani",
duration: "Kohëzgjatja",
statusLabel: "Statusi",
paid: "PAGUAR",
unpaid: "PAPAGUAR",
total: "Totali",
noTariff: "pa tarifë",
tender: "Mënyra",
cash: "Para",
card: "Kartë",
printExitVoucher: "Printo biletë dalje",
selfExitHint: "(klienti del vetë te dalja)",
payAndOpen: "Paguaj + hap barrierën",
payAndVoucher: "Paguaj + printo biletën",
openBarrier: "Hap barrierën",
printVoucher: "Printo biletën",
takingPayment: "Duke marrë pagesën…",
printingVoucher: "Duke printuar biletën…",
opening: "Duke hapur…",
noSessionFound: "Nuk u gjet asnjë sesion për këtë biletë.",
alreadyClosed: "Ky sesion është mbyllur tashmë (doli {{time}}).",
lookingUp: "Duke kërkuar…",
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
subscription: "ABONIM",
plan: "Plani",
prepaid: "I PARAPAGUAR",
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
// payment receipt (transparency slip)
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
receiptReprinted: "Fatura u riprintua në {{printer}}.",
reprintReceipt: "Riprinto faturën",
reprinting: "duke printuar…",
// snapshots
noSnapshots: "asnjë foto",
loadingSnapshots: "duke ngarkuar fotot…",
snapEntry: "hyrje",
snapExit: "dalje",
snapFailed: "kamera e paarritshme",
},
};
// The catalog SHAPE (keys + nesting), with string-typed values — so en.ts must
// supply every key but may differ in value. (Not `typeof sq` with `as const`, which
// would pin en.ts to the Albanian literals.)
type Stringify<T> = { [K in keyof T]: T[K] extends object ? Stringify<T[K]> : string };
export type Catalog = Stringify<typeof sq>;
+58
View File
@@ -0,0 +1,58 @@
import { create } from "zustand";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
// CLIENT state for the live booth feed — deliberately small. Server data (the
// authoritative event list, occupancy totals) is owned by TanStack Query; this
// store holds only what Query shouldn't: the WS connection status, the latest
// pushed occupancy snapshot, and a rolling in-memory tail of recent events for the
// live ticker. Anything durable is re-fetched via Query. See lib/query.ts.
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
export type WsStatus = "connecting" | "open" | "closed";
/** 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. */
const MAX_FEED = 200;
interface LiveState {
status: WsStatus;
/** Most recent occupancy pushed by the server (rides on every ledger event). */
occupancy: Occupancy | null;
/** Newest-first tail of recently pushed ledger events (for the live ticker). */
feed: LedgerEvent[];
/** 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. */
devices: Record<string, DeviceStatus>;
setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void;
/** Replace the whole device-status set (WS hello / reconnect snapshot). */
setDevices: (list: DeviceStatus[]) => void;
/** Upsert one device's status (a device-status push). */
upsertDevice: (d: DeviceStatus) => void;
reset: () => void;
}
/** Index a device-status list by device id. */
function byId(list: DeviceStatus[]): Record<string, DeviceStatus> {
const m: Record<string, DeviceStatus> = {};
for (const d of list) m[d.deviceId] = d;
return m;
}
export const useLiveStore = create<LiveState>((set) => ({
status: "connecting",
occupancy: null,
feed: [],
devices: {},
setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) =>
set((s) => ({
// Newest first; de-dupe by id (a reconnect can replay) and cap the length.
feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED),
})),
setDevices: (list) => set({ devices: byId(list) }),
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }),
}));
+30
View File
@@ -0,0 +1,30 @@
import { QueryClient } from "@tanstack/react-query";
// Single QueryClient for the app. TanStack Query owns SERVER state (fetch, cache,
// refetch, loading/error) — wrapping the existing thin api.ts fetchers. Client/UI
// state (live feed, WS status) lives in Zustand, not here. The WS layer invalidates
// these caches on live events so Query stays the source of truth for server data.
//
// Defaults tuned for a single-appliance booth: no window-focus refetch (it's a
// kiosk, not a tab someone switches to), and a short staleTime since the WS is the
// real freshness mechanism — queries are the fallback/initial load.
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 5_000,
retry: 1,
},
},
});
/** Stable query keys — referenced by both the screens and the WS invalidator. */
export const qk = {
me: ["me"] as const,
occupancy: ["occupancy"] as const,
events: ["events"] as const,
activeSessions: ["active-sessions"] as const,
siteConfig: ["site-config"] as const,
shift: ["shift"] as const,
deviceStatus: ["device-status"] as const,
} as const;
+22
View File
@@ -0,0 +1,22 @@
import type { TFunction } from "i18next";
import { REASON_CODES, type LedgerEvent } from "@parking/shared";
// Localize a signed event's reason. The ledger signs a STABLE `reasonCode` (+ params)
// plus an English `reason` fallback (see @parking/shared REASON_CODES). We translate
// the code via the `reason.<code>` catalog so an Albanian operator reads Albanian and
// an English operator reads English — from the SAME immutable event. Legacy events
// (signed before reason codes existed) carry only `reason`, so we show that verbatim.
const CODE_SET = new Set<string>(REASON_CODES);
/** The localized reason sentence for an event, or null if it has no reason at all. */
export function renderReason(payload: LedgerEvent["payload"], t: TFunction): string | null {
if (!payload) return null;
const code = typeof payload.reasonCode === "string" ? payload.reasonCode : null;
if (code && CODE_SET.has(code)) {
// i18next fills {{param}} from reasonParams; an absent key renders the raw token.
return t(`reason.${code}`, (payload.reasonParams ?? {}) as Record<string, unknown>);
}
// No code (legacy) or an unknown code (forward-compat) → the signed English fallback.
return typeof payload.reason === "string" ? payload.reason : null;
}
+14
View File
@@ -0,0 +1,14 @@
import type { Theme } from "../api.js";
// Theme application. The whole UI reads colour through the --color-term-* tokens;
// the light palette lives in index.css under `html.theme-light`. Applying a theme is
// just toggling that class on <html>. The active theme is the LOGGED-IN USER's stored
// preference (users.theme), applied via applyTheme() after auth resolves — mirroring
// how language works. Dark is the default before auth resolves. Printed tickets are
// unaffected (always Albanian, dark-agnostic).
/** Apply a theme by toggling `theme-light` on <html>. Dark is the absence of the
* class (the base tokens). No-op-safe to call repeatedly. */
export function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle("theme-light", theme === "light");
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js";
import { useLiveStore } from "./live-store.js";
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
// so TanStack Query remains the source of truth for durable server data. The browser
// attaches the auth cookie automatically; the backend gates by cookie + Origin
// (see routes/ws.ts). Auto-reconnects with capped backoff so a booth left running
// recovers from a server restart without a manual refresh.
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage =
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus };
/** Build the ws:// or wss:// URL for the same origin the SPA is served from. */
function wsUrl(): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/api/ws`;
}
export function useLiveFeed(): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null);
const retryRef = useRef(0);
const closedRef = useRef(false);
useEffect(() => {
closedRef.current = false;
const connect = () => {
if (closedRef.current) return;
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
const sock = new WebSocket(wsUrl());
sockRef.current = sock;
sock.onopen = () => {
retryRef.current = 0;
setStatus("open");
};
sock.onmessage = (ev) => {
let msg: WsMessage;
try {
msg = JSON.parse(ev.data as string) as WsMessage;
} catch {
return; // ignore malformed frames
}
if (msg.kind === "hello") {
setOccupancy(msg.occupancy);
// Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices);
} else if (msg.kind === "device-status") {
upsertDevice(msg.event);
} else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy);
pushEvent(msg.event);
// Keep Query authoritative: the durable event list, occupancy totals,
// and active-sessions list refetch on the next read instead of trusting
// the pushed copy alone.
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
// A shift open/close (or a drawer movement) changes the header control
// state and the per-shift log window — refresh the shift status too.
if (
msg.event.type === "shift_open" ||
msg.event.type === "shift_z_report" ||
msg.event.type === "cash_movement"
) {
void qc.invalidateQueries({ queryKey: qk.shift });
}
} else if (msg.kind === "printer-status") {
void qc.invalidateQueries({ queryKey: ["printers"] });
}
};
const scheduleReconnect = () => {
if (closedRef.current) return;
setStatus("closed");
// Capped exponential backoff: 0.5s, 1s, 2s, … up to 10s.
const delay = Math.min(500 * 2 ** retryRef.current, 10_000);
retryRef.current += 1;
window.setTimeout(connect, delay);
};
sock.onclose = scheduleReconnect;
// onerror fires before onclose; let onclose own the reconnect to avoid double.
sock.onerror = () => sock.close();
};
connect();
return () => {
closedRef.current = true;
sockRef.current?.close();
sockRef.current = null;
};
// qc / store setters are stable; run once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
+42
View File
@@ -0,0 +1,42 @@
import { useQuery } from "@tanstack/react-query";
import { fetchShift, type ShiftStatus } from "../api.js";
import { qk } from "./query.js";
// Shared shift status for the whole app — the header control, the booth screen's
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
// they never disagree about whether a shift is open and whose it is. A shift is a
// SITE-WIDE single-open accountability period (at most one open at a time). The WS
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays
// live without polling. See wiki/concepts/shift.md.
export interface ShiftState {
/** Raw status from the server (null while loading / on error). */
status: ShiftStatus | undefined;
/** Is ANY shift open site-wide? */
isOpen: boolean;
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
isMine: boolean;
/** A shift is open but belongs to someone else — this operator is blocked. */
blockedByOther: boolean;
/** ISO start of the open shift, for scoping the per-shift log. */
startedAt: string | null;
/** Whoever holds the open shift (for "held by X" messaging). */
heldBy: string | null;
isLoading: boolean;
}
export function useShift(): ShiftState {
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
const s = q.data;
const isOpen = s?.open != null;
const isMine = s?.isMine ?? false;
return {
status: s,
isOpen,
isMine,
blockedByOther: isOpen && !isMine,
startedAt: s?.open?.startedAt ?? null,
heldBy: s?.open?.operator ?? null,
isLoading: q.isLoading,
};
}
+2
View File
@@ -1,5 +1,7 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./index.css";
import "./lib/i18n/index.js"; // initialize i18next before the app renders
import { App } from "./App.js"; import { App } from "./App.js";
const rootEl = document.getElementById("root"); const rootEl = document.getElementById("root");
+452
View File
@@ -0,0 +1,452 @@
import {
createRootRouteWithContext,
createRoute,
createRouter,
Link,
Outlet,
redirect,
} from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
import { can, closeShift, logout, openShift, setLanguagePref, setThemePref } from "./api.js";
import { qk, queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js";
import { SubscriptionManager } from "./SubscriptionManager.js";
import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js";
import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
// 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
// a setter so route guards can redirect by role. The root renders the terminal
// chrome (nav + user + live status) and opens the booth WebSocket once, app-wide.
export interface RouterContext {
user: SessionUser | null;
setUser: (u: SessionUser | null) => void;
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
});
function NavLink({ to, label }: { to: string; label: string }) {
return (
<Link
to={to}
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
>
{label}
</Link>
);
}
/** A tab inside the Setup layout. `exact` (activeOptions) so the Devices tab at
* `/setup` doesn't stay highlighted on the child tabs. */
function SetupTab({ to, label, exact = false }: { to: string; label: string; exact?: boolean }) {
return (
<Link
to={to}
activeOptions={{ exact }}
className="border-b-2 border-transparent px-3 py-2 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
>
{label}
</Link>
);
}
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
* deep links and the back button work and a denied tab redirects to the booth. */
function SetupLayout() {
const { user } = rootRoute.useRouteContext();
const { t } = useTranslation();
const show = (perm: Permission) => can(user, perm);
return (
<div className="mx-auto max-w-4xl">
<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("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
{show("shift:read") && <SetupTab to="/setup/shifts" label={t("nav.shifts")} />}
</nav>
<Outlet />
</div>
);
}
/** 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. */
function LanguageToggle({
user,
setUser,
}: {
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
async function pick(lang: Lang) {
if (lang === user.language) return;
setLanguage(lang); // instant UI
setUser({ ...user, language: lang });
try {
await setLanguagePref(lang); // persist
} catch {
/* non-fatal — the choice still applies this session */
}
}
return (
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
{(["sq", "en"] as const).map((l) => (
<button
key={l}
type="button"
onClick={() => pick(l)}
className={`rounded-term px-1.5 py-0.5 ${
user.language === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`}
>
{l}
</button>
))}
</div>
);
}
/** Dark/light theme toggle. Same shape as the language toggle: applies instantly,
* persists to the user's profile, and updates the router-context user so App
* re-syncs. Restored on the next login from any booth. */
function ThemeToggle({
user,
setUser,
}: {
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
const { t } = useTranslation();
async function pick(theme: Theme) {
if (theme === user.theme) return;
applyTheme(theme); // instant UI
setUser({ ...user, theme });
try {
await setThemePref(theme); // persist
} catch {
/* non-fatal — the choice still applies this session */
}
}
return (
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
{(["dark", "light"] as const).map((th) => (
<button
key={th}
type="button"
onClick={() => pick(th)}
className={`rounded-term px-1.5 py-0.5 ${
user.theme === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`}
>
{t(th === "dark" ? "common.themeDark" : "common.themeLight")}
</button>
))}
</div>
);
}
/**
* Header shift control — the site-wide single-open shift expressed as one button:
* - no shift open → "Open shift" (enabled; opens this operator's shift)
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
* - another's shift open → disabled, labelled with who holds it (you can neither
* open yours nor close theirs until they hand over).
* On open/close it invalidates the shift status, the per-shift log, and occupancy.
*/
function ShiftButton() {
const { t } = useTranslation();
const qc = useQueryClient();
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function act(kind: "open" | "close") {
setBusy(true);
setErr(null);
try {
if (kind === "open") await openShift();
else await closeShift();
// The shift boundary moves: refresh status, the per-shift log window, drawer.
void qc.invalidateQueries({ queryKey: qk.shift });
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
// Disabled when another operator holds the shift (can't open or close).
const label = blockedByOther
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
: isMine
? t("shift.headerClose")
: t("shift.headerOpen");
const tone = blockedByOther
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
: isMine
? "border-term-red text-term-red hover:bg-term-red/10"
: "border-term-green text-term-green hover:bg-term-green/10";
return (
<div className="flex items-center gap-1">
<button
type="button"
disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={() => act(isMine ? "close" : "open")}
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
>
{busy ? t("shift.opening") : label}
</button>
{!isOpen && (
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
)}
{err && <span className="text-[10px] text-term-red">{err}</span>}
</div>
);
}
function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation();
// One app-wide WebSocket for the live feed (booth + any live widget).
useLiveFeed();
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
// the permission its screen needs (the route guards enforce the same server-side).
const show = (perm: Permission) => can(user, perm);
return (
<div className="flex h-screen flex-col bg-term-bg text-term-text">
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
<nav className="flex items-center gap-1">
<NavLink to="/booth" label={t("nav.booth")} />
<NavLink to="/shift" label={t("nav.shift")} />
{/* One Setup entry — its tabs hold devices/tariff/subscriptions/site/users/
roles/shifts. 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("tariff:read") ||
show("subscription:read") ||
show("site:read") ||
show("user:read") ||
show("role:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
{user && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />}
<StatusDot />
<span className="text-[11px] text-term-muted">
{user?.username} · {user?.roleName}
</span>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={async () => {
await logout();
setUser(null);
}}
>
{t("common.logout")}
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet />
</main>
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
{user && <DeviceFooter />}
</div>
);
}
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => {
throw redirect({ to: "/booth" });
},
});
const boothRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/booth",
component: BoothScreen,
});
// Back-compat: the config screens used to be top-level routes. They now live under
// /setup as tabs — redirect the old paths so existing bookmarks/links don't 404.
const legacyRedirects = (
[
["/tariff", "/setup/tariff"],
["/subscriptions", "/setup/subscriptions"],
["/site", "/setup/site"],
["/users", "/setup/users"],
["/roles", "/setup/roles"],
] as const
).map(([from, to]) =>
createRoute({
getParentRoute: () => rootRoute,
path: from,
beforeLoad: () => {
throw redirect({ to });
},
}),
);
const shiftRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/shift",
component: function ShiftRoute() {
const { user } = rootRoute.useRouteContext();
// "Admin" actions on the shift screen (drawer cash) need shift:cash.
return <ShiftControl isAdmin={can(user, "shift:cash")} />;
},
});
/** Guard factory: a route requiring `perm` redirects a user who lacks it back to
* the booth. Same permission the server enforces — defence in depth, not the only
* gate. */
function requirePerm(perm: Permission) {
return (ctx: RouterContext) => {
if (!can(ctx.user, perm)) throw redirect({ to: "/booth" });
};
}
// 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
// `site:update` (e.g. an operator who only has shift:read → goes to /setup/shifts).
const SETUP_TABS: { to: string; perm: Permission }[] = [
{ to: "/setup", perm: "site:update" },
{ to: "/setup/tariff", perm: "tariff:read" },
{ to: "/setup/subscriptions", perm: "subscription:read" },
{ to: "/setup/site", perm: "site:read" },
{ to: "/setup/users", perm: "user:read" },
{ to: "/setup/roles", perm: "role:read" },
{ to: "/setup/shifts", perm: "shift:read" },
];
// /setup is a LAYOUT route (tab bar + <Outlet>); the config screens are its
// children. The layout itself has no permission gate — each child enforces its own
// (so a user who can reach ANY tab gets the hub, but only the tabs they're allowed).
const setupRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/setup",
component: SetupLayout,
});
// Index tab = Devices (the former SetupWizard). Lives at /setup exactly. A user who
// lacks site:update (e.g. an operator) is redirected to the FIRST tab they CAN see
// rather than bounced to the booth — so "Setup" always lands somewhere useful.
const setupDevicesRoute = createRoute({
getParentRoute: () => setupRoute,
path: "/",
beforeLoad: ({ context }) => {
if (can(context.user, "site:update")) return;
const firstOther = SETUP_TABS.find((tab) => tab.to !== "/setup" && can(context.user, tab.perm));
throw redirect({ to: firstOther?.to ?? "/booth" });
},
component: () => <SetupWizard />,
});
const tariffRoute = createRoute({
getParentRoute: () => setupRoute,
path: "tariff",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffComposer />,
});
const subscriptionsRoute = createRoute({
getParentRoute: () => setupRoute,
path: "subscriptions",
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
component: () => <SubscriptionManager />,
});
const siteRoute = createRoute({
getParentRoute: () => setupRoute,
path: "site",
beforeLoad: ({ context }) => requirePerm("site:read")(context),
component: function SiteRoute() {
const { user } = rootRoute.useRouteContext();
return <SiteSettings canEdit={can(user, "site:update")} />;
},
});
const usersRoute = createRoute({
getParentRoute: () => setupRoute,
path: "users",
beforeLoad: ({ context }) => requirePerm("user:read")(context),
component: function UsersRoute() {
const { user } = rootRoute.useRouteContext();
return <UsersManager user={user} />;
},
});
const rolesRoute = createRoute({
getParentRoute: () => setupRoute,
path: "roles",
beforeLoad: ({ context }) => requirePerm("role:read")(context),
component: function RolesRoute() {
const { user } = rootRoute.useRouteContext();
return <RolesManager user={user} />;
},
});
// Shift history. Gated by shift:read (operators have it) — the SERVER scopes the
// data: operators see only their own; shift:cash holders see all + can filter.
const shiftsHistoryRoute = createRoute({
getParentRoute: () => setupRoute,
path: "shifts",
beforeLoad: ({ context }) => requirePerm("shift:read")(context),
component: function ShiftsHistoryRoute() {
const { user } = rootRoute.useRouteContext();
return <ShiftsHistory user={user} />;
},
});
const routeTree = rootRoute.addChildren([
indexRoute,
boothRoute,
...legacyRedirects,
shiftRoute,
setupRoute.addChildren([
setupDevicesRoute,
tariffRoute,
subscriptionsRoute,
siteRoute,
usersRoute,
rolesRoute,
shiftsHistoryRoute,
]),
]);
export const router = createRouter({
routeTree,
context: { user: null, setUser: () => {} },
defaultPreload: "intent",
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
import { qk } from "../lib/query.js";
import { useLiveStore } from "../lib/live-store.js";
// Fixed device-status footer for the booth chrome. One compact chip per configured
// device — relays, readers, cameras, printers — labelled by ROLE, never vendor
// (e.g. "Lexuesi hyrje", "Printer kabina", "Kamera dalje"), with a traffic-light
// dot. Fault detail does NOT pollute the footer: clicking opens a small panel that
// lists the degraded/offline devices and their issues. Status is fed by the
// DeviceMonitor over the WS (snapshot on connect + per-device pushes, held in the
// live store); a REST snapshot seeds it / fills in if the WS is briefly down.
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
const DOT: Record<DeviceStatus["state"], string> = {
ready: "bg-term-green",
degraded: "bg-term-amber",
offline: "bg-term-red",
};
const TEXT: Record<DeviceStatus["state"], string> = {
ready: "text-term-text",
degraded: "text-term-amber",
offline: "text-term-red",
};
/** i18n key for a device category. */
const CATEGORY_KEY: Record<DeviceStatus["category"], string> = {
access: "devices.catAccess",
reader: "devices.catReader",
camera: "devices.catCamera",
printer: "devices.catPrinter",
};
/** i18n key for the role/direction token (null = no suffix). */
function roleKey(roleKind: DeviceStatus["roleKind"]): string | null {
return roleKind ? `devices.role.${roleKind}` : null;
}
/** Stable display order: access (barrier) first, then readers, cameras, printers. */
const ORDER: Record<DeviceStatus["category"], number> = {
access: 0,
reader: 1,
camera: 2,
printer: 3,
};
/** "Lexuesi hyrje" — category word + localised role/direction (when known). */
function useLabel() {
const { t } = useTranslation();
return (d: DeviceStatus) => {
const cat = t(CATEGORY_KEY[d.category]);
const rk = roleKey(d.roleKind);
return rk ? `${cat} ${t(rk)}` : cat;
};
}
function sortDevices(list: DeviceStatus[]): DeviceStatus[] {
return [...list].sort(
(a, b) => ORDER[a.category] - ORDER[b.category] || (a.roleKind ?? "").localeCompare(b.roleKind ?? ""),
);
}
export function DeviceFooter() {
const { t } = useTranslation();
const label = useLabel();
// Seed/fallback from REST; the WS keeps the live store authoritative thereafter.
const seed = useQuery({ queryKey: qk.deviceStatus, queryFn: fetchDeviceStatus });
const live = useLiveStore((s) => s.devices);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLElement>(null);
// Close the issues panel on an outside click or Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
// Prefer the live store (WS); fall back to the REST snapshot before the first push.
const fromLive = Object.values(live);
const devices = sortDevices(fromLive.length > 0 ? fromLive : seed.data?.devices ?? []);
const problems = devices.filter((d) => d.state !== "ready");
return (
<footer
ref={rootRef}
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[11px]"
>
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
{t("devices.footerTitle")}
</span>
<div className="flex items-center gap-1.5 overflow-x-auto">
{devices.length === 0 ? (
<span className="text-term-muted">{t("devices.none")}</span>
) : (
devices.map((d) => {
const isProblem = d.state !== "ready";
return (
<button
key={d.deviceId}
type="button"
// Only a problem chip is interactive (opens the issues panel).
onClick={isProblem ? () => setOpen((v) => !v) : undefined}
aria-disabled={!isProblem}
title={isProblem ? t("devices.clickForIssues") : undefined}
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-term border border-term-border bg-term-panel-2 px-2 py-0.5 ${
isProblem ? "cursor-pointer hover:border-term-amber" : "cursor-default"
}`}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]} ${
d.state === "offline" ? "animate-pulse" : ""
}`}
/>
<span className={TEXT[d.state]}>{label(d)}</span>
</button>
);
})
)}
</div>
{/* Right-aligned roll-up; clicking opens the issues panel when any exist. */}
<button
type="button"
disabled={problems.length === 0}
onClick={() => setOpen((v) => !v)}
className="ml-auto shrink-0 tabular-nums disabled:cursor-default"
>
{problems.length === 0 ? (
devices.length > 0 ? (
<span className="text-term-green">{t("devices.allOk")}</span>
) : null
) : (
<span className="text-term-amber hover:underline">
{t("devices.issuesCount", { count: problems.length })}
</span>
)}
</button>
{/* Issues panel — anchored above the footer, lists only problem devices. */}
{open && problems.length > 0 && (
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
{t("devices.issuesTitle")}
</span>
<button
type="button"
onClick={() => setOpen(false)}
className="text-term-muted hover:text-term-text"
aria-label={t("common.close")}
>
✕
</button>
</div>
<ul className="max-h-[40vh] overflow-y-auto p-1.5">
{problems.map((d) => (
<li
key={d.deviceId}
className="flex items-start gap-2 border-b border-term-border/40 px-1.5 py-1.5 last:border-b-0"
>
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
{t(`devices.state.${d.state}`)}
</span>
</div>
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
</div>
</div>
</li>
))}
</ul>
</div>
)}
</footer>
);
}
+45
View File
@@ -0,0 +1,45 @@
import * as Dialog from "@radix-ui/react-dialog";
import type { ReactNode } from "react";
// Reusable modal shell — a thin wrapper over Radix Dialog matching the terminal
// chrome (title bar + ✕, dark overlay, square panel). The same styling BoothPayModal
// uses inline, factored out so every popped-out form looks identical. Radix handles
// focus trap, Escape, and outside-click → onClose. `width` is a Tailwind max-width
// class (the panel is responsive: w-full up to that cap).
export function Modal({
open,
onClose,
title,
children,
width = "max-w-xl",
}: {
open: boolean;
onClose: () => void;
title: ReactNode;
children: ReactNode;
/** Tailwind max-width class for the panel (default max-w-xl). */
width?: string;
}) {
return (
<Dialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
<Dialog.Content
className={`fixed left-1/2 top-1/2 z-50 max-h-[90vh] w-[95vw] ${width} -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl`}
aria-describedby={undefined}
>
<div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{title}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
✕
</Dialog.Close>
</div>
<div className="p-4">{children}</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
// Terminal panel: a bordered, titled box — the basic building block of the dense
// booth layout. Title bar in amber, square corners, subtle layered surfaces.
export function Panel({
title,
right,
children,
className = "",
}: {
title?: string;
/** Optional right-aligned content in the title bar (e.g. a status dot). */
right?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section
className={`flex flex-col border border-term-border bg-term-panel rounded-term overflow-hidden ${className}`}
>
{title && (
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber">
{title}
</h2>
{right}
</header>
)}
<div className="flex-1 min-h-0 p-3">{children}</div>
</section>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
// Entry/exit evidence images for a session. Lets the operator verify the car at the
// booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed,
// served with a long immutable cache); clicking one enlarges it. Read-only.
export function SnapshotStrip({ identity }: { identity: string }) {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["snapshots", identity],
queryFn: () => fetchSnapshots(identity),
enabled: !!identity,
});
const [zoom, setZoom] = useState<string | null>(null);
const shots = data?.snapshots ?? [];
const failures = data?.failures ?? [];
/** Localized direction label for a snapshot/failure tile. */
const dirLabel = (dir: "entry" | "exit" | null): string =>
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
if (shots.length === 0 && failures.length === 0)
return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
return (
<>
<div className="flex flex-wrap gap-2">
{shots.map((s) => (
<button
key={s.id}
type="button"
onClick={() => setZoom(s.id)}
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
>
<img
src={snapshotImageUrl(s.id)}
alt={s.direction ?? "snapshot"}
className="h-20 w-28 object-cover"
loading="lazy"
/>
<span
className={`text-[9px] uppercase tracking-wider ${
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
}`}
>
{dirLabel(s.direction)}
</span>
</button>
))}
{/* Failed captures — a placeholder tile so an absent image is explained, not
silently missing. Shown only when no successful shot exists for the same
direction (the server already filters recovered captures out). */}
{failures.map((f, i) => (
<div
key={`fail-${f.direction ?? "both"}-${i}`}
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
>
<span className="text-lg leading-none text-term-amber">⚠</span>
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
<span className="px-1 text-[9px] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
</div>
))}
</div>
{zoom && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
onClick={() => setZoom(null)}
>
<img src={snapshotImageUrl(zoom)} alt="snapshot" className="max-h-full max-w-full object-contain" />
</div>
)}
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import { useLiveStore, type WsStatus } from "../lib/live-store.js";
// Small live-connection indicator for the booth chrome: a coloured dot + label
// reflecting the WebSocket status. Green = live, amber = connecting, red = down.
const COLOR: Record<WsStatus, string> = {
open: "bg-term-green",
connecting: "bg-term-amber",
closed: "bg-term-red",
};
const LABEL_KEY: Record<WsStatus, string> = {
open: "status.live",
connecting: "status.connecting",
closed: "status.offline",
};
export function StatusDot() {
const { t } = useTranslation();
const status = useLiveStore((s) => s.status);
return (
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
<span
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
/>
{t(LABEL_KEY[status])}
</span>
);
}
+9 -2
View File
@@ -1,18 +1,25 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
// Operator SPA. Built by Vite and served by Fastify in production // Operator SPA. Built by Vite and served by Fastify in production
// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the // (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the
// local Fastify server. // local Fastify server.
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
server: { server: {
port: 5173, port: 5173,
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,
// notably under WSL2 mirrored networking. // notably under WSL2 mirrored networking.
"/api": "http://127.0.0.1:3000", "/api": {
target: "http://127.0.0.1:3000",
// The live booth feed (/api/ws) is a WebSocket — without `ws: true` the
// proxy would not forward the upgrade. The backend's Origin allowlist must
// include the dev origin (http://localhost:5173) via WS_ALLOWED_ORIGINS.
ws: true,
},
"/health": "http://127.0.0.1:3000", "/health": "http://127.0.0.1:3000",
}, },
}, },
@@ -0,0 +1,6 @@
ALTER TABLE `site_config` ADD `park_name` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `operator_name` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `nius` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `address` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `phone` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `email` text;
@@ -0,0 +1 @@
ALTER TABLE `site_config` ADD `exit_voucher_default` integer DEFAULT false NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `users` ADD `language` text DEFAULT 'sq' NOT NULL;
@@ -0,0 +1,25 @@
-- Rename permit → subscription (master data only). The signed ledger keeps its
-- immutable `permitId` payload — NOT touched here. Data-preserving ALTER RENAMEs
-- (SQLite 3.25+) rather than drop/recreate, so existing subscriptions survive.
-- Adds per-subscription pricing (price_minor + period + currency) and a site-wide
-- default monthly price. See wiki/entities/subscription.md.
ALTER TABLE `permits` RENAME TO `subscriptions`;
--> statement-breakpoint
ALTER TABLE `permit_credentials` RENAME TO `subscription_credentials`;
--> statement-breakpoint
ALTER TABLE `subscription_credentials` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `permit_plates` RENAME TO `subscription_plates`;
--> statement-breakpoint
ALTER TABLE `subscription_plates` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `price_minor` integer;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `period` text DEFAULT 'monthly' NOT NULL;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `currency` text;
--> statement-breakpoint
ALTER TABLE `sessions` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `site_config` ADD `subscription_monthly_price_minor` integer;
@@ -0,0 +1 @@
ALTER TABLE `site_config` ADD `timezone` text;
@@ -0,0 +1 @@
ALTER TABLE `site_config` ADD `default_vehicle_category` text;
+103
View File
@@ -0,0 +1,103 @@
-- Dynamic RBAC: roles become DATA. Replaces the hardcoded users.role enum with a
-- role_id FK into a composable `roles` table + a `role_permissions` grid.
-- See wiki/entities/local-jwt-auth.md and @parking/shared PERMISSIONS.
-- 1. Roles + the role→permission grid.
CREATE TABLE `roles` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`builtin` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `roles_name_unique` ON `roles` (`name`);
--> statement-breakpoint
CREATE TABLE `role_permissions` (
`role_id` text NOT NULL,
`permission` text NOT NULL,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `role_permissions_role_id_permission_unique` ON `role_permissions` (`role_id`,`permission`);
--> statement-breakpoint
-- 2. Seed the protected built-in `admin` role. Its permission set is enforced in
-- code (always ALL), but we materialise the rows too so the grid is complete.
INSERT INTO `roles` (`id`, `name`, `builtin`) VALUES ('admin', 'Admin', 1);
--> statement-breakpoint
INSERT INTO `role_permissions` (`role_id`, `permission`) VALUES
('admin','user:create'),('admin','user:read'),('admin','user:update'),('admin','user:delete'),
('admin','role:create'),('admin','role:read'),('admin','role:update'),('admin','role:delete'),
('admin','tariff:read'),('admin','tariff:update'),
('admin','subscription:read'),('admin','subscription:create'),('admin','subscription:update'),('admin','subscription:delete'),
('admin','site:read'),('admin','site:update'),
('admin','device:read'),
('admin','shift:read'),('admin','shift:create'),('admin','shift:cash'),
('admin','payment:read'),('admin','payment:create'),
('admin','session:read'),
('admin','event:read'),('admin','event:void'),
('admin','report:read');
--> statement-breakpoint
-- 3. Seed composable roles matching the OLD enum's intended behaviour, so any
-- existing operator/cashier/readonly user keeps working. These are ordinary
-- (non-builtin) rows an admin may later edit or delete.
INSERT INTO `roles` (`id`, `name`, `builtin`) VALUES ('operator', 'Operator', 0);
--> statement-breakpoint
INSERT INTO `role_permissions` (`role_id`, `permission`) VALUES
('operator','payment:read'),('operator','payment:create'),
('operator','session:read'),
('operator','shift:read'),('operator','shift:create'),
('operator','subscription:read'),
('operator','tariff:read'),
('operator','site:read'),
('operator','device:read'),
('operator','event:read'),
('operator','report:read');
--> statement-breakpoint
INSERT INTO `roles` (`id`, `name`, `builtin`) VALUES ('cashier', 'Cashier', 0);
--> statement-breakpoint
INSERT INTO `role_permissions` (`role_id`, `permission`) VALUES
('cashier','payment:read'),('cashier','payment:create'),
('cashier','session:read'),
('cashier','shift:read'),('cashier','shift:create'),
('cashier','subscription:read'),
('cashier','tariff:read'),
('cashier','site:read'),
('cashier','device:read'),
('cashier','event:read'),
('cashier','report:read');
--> statement-breakpoint
INSERT INTO `roles` (`id`, `name`, `builtin`) VALUES ('readonly', 'Read-only', 0);
--> statement-breakpoint
INSERT INTO `role_permissions` (`role_id`, `permission`) VALUES
('readonly','session:read'),
('readonly','subscription:read'),
('readonly','tariff:read'),
('readonly','site:read'),
('readonly','device:read'),
('readonly','event:read'),
('readonly','report:read');
--> statement-breakpoint
-- 4. Rebuild `users` to swap the `role` enum column for a `role_id` FK. SQLite
-- can't DROP a column cleanly, so: create the new shape, copy rows mapping the
-- old role string -> role id (identical strings), drop, rename.
CREATE TABLE `users_new` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`role_id` text NOT NULL,
`language` text DEFAULT 'sq' NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `users_new` (`id`, `username`, `password_hash`, `role_id`, `language`, `created_at`)
SELECT `id`, `username`, `password_hash`, `role`, `language`, `created_at` FROM `users`;
--> statement-breakpoint
DROP TABLE `users`;
--> statement-breakpoint
ALTER TABLE `users_new` RENAME TO `users`;
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
@@ -0,0 +1,5 @@
ALTER TABLE `users` ADD `theme` text DEFAULT 'dark' NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `full_name` text;--> statement-breakpoint
ALTER TABLE `users` ADD `phone` text;--> statement-breakpoint
ALTER TABLE `users` ADD `email` text;--> statement-breakpoint
ALTER TABLE `users` ADD `address` text;
+797
View File
@@ -0,0 +1,797 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
"prevId": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
"tables": {
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"park_name": {
"name": "park_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"operator_name": {
"name": "operator_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nius": {
"name": "nius",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+805
View File
@@ -0,0 +1,805 @@
{
"version": "6",
"dialect": "sqlite",
"id": "dbee8e05-0b49-4af7-962c-9aab53b36eb7",
"prevId": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
"tables": {
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"park_name": {
"name": "park_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"operator_name": {
"name": "operator_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nius": {
"name": "nius",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exit_voucher_default": {
"name": "exit_voucher_default",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+813
View File
@@ -0,0 +1,813 @@
{
"version": "6",
"dialect": "sqlite",
"id": "620eba1b-2c7e-4bd4-8b3e-c779a69e87b9",
"prevId": "dbee8e05-0b49-4af7-962c-9aab53b36eb7",
"tables": {
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"park_name": {
"name": "park_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"operator_name": {
"name": "operator_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nius": {
"name": "nius",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exit_voucher_default": {
"name": "exit_voucher_default",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'sq'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+56
View File
@@ -8,6 +8,62 @@
"when": 1781632874398, "when": 1781632874398,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1781682176094,
"tag": "0001_neat_slipstream",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1781713560438,
"tag": "0002_panoramic_tiger_shark",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1781774228086,
"tag": "0003_early_hawkeye",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1781800000000,
"tag": "0004_subscriptions_rename",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1781884800000,
"tag": "0005_site_timezone",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1781884900000,
"tag": "0006_site_default_category",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1781885000000,
"tag": "0007_rbac",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1781885100000,
"tag": "0008_user_profile_theme",
"breakpoints": true
} }
] ]
} }
+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, sql } from "drizzle-orm"; export { eq, and, desc, gte, 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
+138 -27
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { blob, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
// Schema notes: // Schema notes:
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md): // - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
@@ -9,19 +9,72 @@ import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md. // tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input). // • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md. // High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a // - Business master data (tariffs/subscriptions/blocklist) IS mutable, but its USE is fixed in a
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned: // signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md. // editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first). // - Authorization is DYNAMIC RBAC, fully local (offline-first): `roles` are data
// See wiki/entities/local-jwt-auth.md. // (admin-composable), `role_permissions` is the role→permission grid, and each
// `users` row points at one role via `role_id`. Permissions are checked per
// route (see @parking/shared PERMISSIONS). A built-in, locked `admin` role
// (id='admin') always holds every permission. See wiki/entities/local-jwt-auth.md.
/** A composable role: a named bundle of permissions. `builtin` rows (the `admin`
* role) are protected — not editable or deletable, and always granted all
* permissions. Everything else is admin-composed at runtime. */
export const roles = sqliteTable("roles", {
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
// 1 = protected built-in (the `admin` role). 0 = admin-composed.
builtin: integer("builtin").notNull().default(0),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
/** The role→permission grid. One row per granted `resource:action` permission.
* The `admin` role is granted all permissions implicitly in code, so its rows
* here are belt-and-suspenders. See @parking/shared PERMISSIONS. */
export const rolePermissions = sqliteTable(
"role_permissions",
{
roleId: text("role_id")
.notNull()
.references(() => roles.id),
permission: text("permission").notNull(),
},
(t) => ({
// A permission is granted to a role at most once.
uniq: unique().on(t.roleId, t.permission),
}),
);
export const users = sqliteTable("users", { export const users = sqliteTable("users", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
username: text("username").notNull().unique(), username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
role: text("role", { // One role per user (RBAC). Resolves to a permission set at request time.
enum: ["admin", "operator", "cashier", "readonly"], roleId: text("role_id")
}).notNull(), .notNull()
.references(() => roles.id),
// Preferred UI language for this user (operator-facing). Loaded on login and
// restored from any booth. Albanian is the default. Printed tickets are NOT
// governed by this — they're always Albanian (customer-facing). See i18n.md.
language: text("language", { enum: ["sq", "en"] })
.notNull()
.default("sq"),
// Preferred UI theme for this user. Persisted like `language` (read on login,
// restored from any booth, changed without a token refresh). Dark is the default
// (the booth runs in a dark room). Printed tickets are unaffected. See i18n.md.
theme: text("theme", { enum: ["dark", "light"] })
.notNull()
.default("dark"),
// Optional operator profile metadata — display name + contact details. All
// nullable; only username/password/role are required to create a user. fullName
// (when set) is the human label for audit/Z-report display.
fullName: text("full_name"),
phone: text("phone"),
email: text("email"),
address: text("address"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@@ -87,7 +140,7 @@ export const snapshots = sqliteTable("snapshots", {
direction: text("direction", { enum: ["entry", "exit"] }).notNull(), direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
// The camera `devices` instance that captured it (raw provenance). // The camera `devices` instance that captured it (raw provenance).
deviceId: text("device_id"), deviceId: text("device_id"),
// The session/credential ref (ticket id, plate, permit) — links to the ledger event. // The session/credential ref (ticket id, plate, subscription) — links to the ledger event.
identity: text("identity"), identity: text("identity"),
contentType: text("content_type").notNull(), contentType: text("content_type").notNull(),
bytes: blob("bytes").notNull().$type<Buffer>(), bytes: blob("bytes").notNull().$type<Buffer>(),
@@ -131,9 +184,53 @@ export const setupState = sqliteTable("setup_state", {
// Single-row site settings (admin-configurable). The home for site-wide knobs; // Single-row site settings (admin-configurable). The home for site-wide knobs;
// `capacity` is the nominal space count the FULL gate refuses transient entry at // `capacity` is the nominal space count the FULL gate refuses transient entry at
// (null = no cap). See wiki/concepts/capacity-occupancy.md. // (null = no cap). See wiki/concepts/capacity-occupancy.md.
// Park identity/metadata (all optional) lives here too — display name, the legal
// operator, the NIUS tax id, address and contact. These feed the ticket/receipt
// header (park name + NIUS are commonly required on an Albanian parking receipt)
// and admin display. All nullable: the lot runs fine with none set.
// See wiki/concepts/site-metadata.md.
export const siteConfig = sqliteTable("site_config", { export const siteConfig = sqliteTable("site_config", {
id: integer("id").primaryKey(), // always 1 id: integer("id").primaryKey(), // always 1
capacity: integer("capacity"), // null = no capacity limit capacity: integer("capacity"), // null = no capacity limit
/** Park display name shown on the ticket header / UI (e.g. "Acme Parking"). */
parkName: text("park_name"),
/** Legal entity operating the lot, for receipts (may differ from parkName). */
operatorName: text("operator_name"),
/** NIUS — Albanian tax/identification number, printed on the receipt when set. */
nius: text("nius"),
/** Free-text postal address (multi-line allowed). */
address: text("address"),
/** Contact phone — also used for the ticket "lost ticket? call …" footer. */
phone: text("phone"),
/** Contact email. */
email: text("email"),
/** Default for the booth pay modal's "print exit ticket" checkbox. Site-wide
* because it's booth GEOGRAPHY: when the booth is far from the exit, the
* customer pays at the booth and self-exits later by scanning a printed exit
* voucher (= the ticket id reprinted, now paid). When near the exit, the booth
* opens the barrier directly. The operator may still override per transaction.
* Stored 0/1 (SQLite has no bool). See wiki/concepts/booth-exit-flow.md. */
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
.notNull()
.default(false),
/** Default monthly subscription price in minor units (e.g. 1000000 = 10,000.00).
* A starting value the subscription form pre-fills; each subscription stores its
* own price and may differ. null = no site default set. See
* wiki/entities/subscription.md. */
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
/** 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
* each published tariff version's structure.tz so the windows are frozen/immutable
* per version — historical sessions reprice deterministically regardless of any
* later config change. null/absent ⇒ default "Europe/Tirane" at publish time.
* See wiki/concepts/tariff-time-tiers.md. */
timezone: text("timezone"),
/** Default vehicle/customer category assigned to a transient entry when none is
* captured at the lane (every transient today). Operator policy — a plain car park
* leaves it "default"; a mixed lot might set "car". Frozen into each vehicle_entry
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
defaultVehicleCategory: text("default_vehicle_category"),
updatedAt: text("updated_at") updatedAt: text("updated_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@@ -171,16 +268,28 @@ export const tariffVersions = sqliteTable("tariff_versions", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// --- Permits (subscriptions) --------------------------------------------- // --- Subscriptions --------------------------------------------------------
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event. // A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null = // month) instead of paying per stay. Mutable master data; every USE still produces a
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a // signed vehicle_entry/exit ledger event. Two optional, independent bindings:
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md. // car-count (maxConcurrent, default 1, null = unbound) and plate (plates rows,
export const permits = sqliteTable("permits", { // default none = any car). Identity = card/QR OR a matching plate (LPR/ANPR future).
// Pricing: priceMinor + period + currency record the plan; collecting the fee into
// the ledger/shift is deferred. See wiki/entities/subscription.md.
// NB: signed ledger events still carry `permitId` in their payload — immutable
// history, intentionally NOT renamed. These tables are the mutable master data,
// renamed permit→subscription in migration 0004.
export const subscriptions = sqliteTable("subscriptions", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
holderName: text("holder_name"), holderName: text("holder_name"),
contact: text("contact"), contact: text("contact"),
// Car-count binding: how many of the permit's cars may be inside at once. // Recurring price for the plan, in minor units (e.g. 1000000 = 10,000.00 ALL).
// null = no price set (comp/legacy). The `period` says what it recurs over.
priceMinor: integer("price_minor"),
period: text("period", { enum: ["monthly"] }).notNull().default("monthly"),
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
currency: text("currency"),
// Car-count binding: how many of the subscription's cars may be inside at once.
// null = unbound. Default 1. // null = unbound. Default 1.
maxConcurrent: integer("max_concurrent").default(1), maxConcurrent: integer("max_concurrent").default(1),
validFrom: text("valid_from"), validFrom: text("valid_from"),
@@ -193,19 +302,19 @@ export const permits = sqliteTable("permits", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane. // A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
export const permitCredentials = sqliteTable("permit_credentials", { export const subscriptionCredentials = sqliteTable("subscription_credentials", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
permitId: text("permit_id").notNull(), subscriptionId: text("subscription_id").notNull(),
kind: text("kind", { enum: ["rf", "qr"] }).notNull(), kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
value: text("value").notNull(), value: text("value").notNull(),
}); });
// Plate binding (optional). When a permit has plate rows, a matching plate read is // Plate binding (optional). When a subscription has plate rows, a matching plate read
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car). // is itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
export const permitPlates = sqliteTable("permit_plates", { export const subscriptionPlates = sqliteTable("subscription_plates", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
permitId: text("permit_id").notNull(), subscriptionId: text("subscription_id").notNull(),
plate: text("plate").notNull(), plate: text("plate").notNull(),
}); });
@@ -234,8 +343,8 @@ export const sessions = sqliteTable("sessions", {
// Identity that opened the session, and how it was read. // Identity that opened the session, and how it was read.
identity: text("identity"), identity: text("identity"),
source: text("source"), source: text("source"),
// null while transient; set when matched to a permit. // null while transient; set when matched to a subscription.
permitId: text("permit_id"), subscriptionId: text("subscription_id"),
enteredAt: text("entered_at").notNull(), enteredAt: text("entered_at").notNull(),
// null until exit; presence = CLOSED. // null until exit; presence = CLOSED.
exitedAt: text("exited_at"), exitedAt: text("exited_at"),
@@ -248,6 +357,8 @@ export const sessions = sqliteTable("sessions", {
}); });
export type UserRow = typeof users.$inferSelect; export type UserRow = typeof users.$inferSelect;
export type RoleRow = typeof roles.$inferSelect;
export type RolePermissionRow = typeof rolePermissions.$inferSelect;
export type LedgerEventRow = typeof ledgerEvents.$inferSelect; export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
export type DeviceEventRow = typeof deviceEvents.$inferSelect; export type DeviceEventRow = typeof deviceEvents.$inferSelect;
export type SnapshotRow = typeof snapshots.$inferSelect; export type SnapshotRow = typeof snapshots.$inferSelect;
@@ -256,8 +367,8 @@ export type SetupStateRow = typeof setupState.$inferSelect;
export type SiteConfigRow = typeof siteConfig.$inferSelect; export type SiteConfigRow = typeof siteConfig.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect; export type TariffVersionRow = typeof tariffVersions.$inferSelect;
export type PermitRow = typeof permits.$inferSelect; export type SubscriptionRow = typeof subscriptions.$inferSelect;
export type PermitCredentialRow = typeof permitCredentials.$inferSelect; export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
export type PermitPlateRow = typeof permitPlates.$inferSelect; export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
export type BlocklistRow = typeof blocklist.$inferSelect; export type BlocklistRow = typeof blocklist.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect; export type SessionRow = typeof sessions.$inferSelect;
+3
View File
@@ -5,6 +5,7 @@ import { registry } from "../registry.js";
import { dingtianDriver } from "./access-dingtian.js"; import { dingtianDriver } from "./access-dingtian.js";
import { stubAccessDriver } from "./access-stub.js"; import { stubAccessDriver } from "./access-stub.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js"; import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { cashinoDriver } from "./printer-cashino.js";
import { rongtaDriver } from "./printer-rongta.js"; import { rongtaDriver } from "./printer-rongta.js";
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
@@ -22,6 +23,7 @@ export function registerBuiltinDrivers(): void {
registry.register(hikvisionDriver); registry.register(hikvisionDriver);
registry.register(dahuaDriver); registry.register(dahuaDriver);
registry.register(rongtaDriver); registry.register(rongtaDriver);
registry.register(cashinoDriver);
} }
export { export {
@@ -33,4 +35,5 @@ export {
hikvisionDriver, hikvisionDriver,
dahuaDriver, dahuaDriver,
rongtaDriver, rongtaDriver,
cashinoDriver,
}; };
@@ -0,0 +1,157 @@
import type {
DeviceHealth,
PrinterDevice,
PrintReport,
ReceiptData,
SubscriptionCardData,
TicketData,
} from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
import {
probe,
renderReceipt,
renderReport,
renderSubscriptionCard,
renderTicket,
sendRaw,
} from "./printer-escpos.js";
// Cashino 80mm network thermal printer driver. The Cashino is an ESC/POS clone:
// it PRINTS identically to the Rongta (same byte stream — see ./printer-escpos.ts),
// so tickets, reports and subscription cards render the same. What it does NOT
// have is the Rongta board's decoded status web page (/prn_stat.htm). It cannot
// report paper-out / cover-open / cutter faults in a form we trust.
//
// Therefore this driver deliberately does NOT implement MonitorableDevice
// (no readStatus). The device monitor then falls back to the generic
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the
// booth footer shows this printer as "ready" when it's reachable and "offline"
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
// (Reusing the Rongta driver made it scrape a status page the Cashino doesn't
// serve, producing the bogus "degraded" feedback this driver fixes.)
//
// No auth on the print socket — like the other field devices it lives on the
// isolated device VLAN. Roles + failover work exactly as for the Rongta
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
class CashinoPrinter implements PrinterDevice {
readonly driverId = "cashino";
readonly #host: string;
readonly #port: number;
readonly #timeout: number;
constructor(config: DeviceConfig) {
this.#host = String(config.host);
this.#port = config.port ? Number(config.port) : 9100;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
}
async connect(): Promise<void> {
await this.healthCheck();
}
async disconnect(): Promise<void> {
stubLog(this.driverId, "disconnect");
}
/**
* Reachability only — a TCP connect probe of the raw print socket. The Cashino
* has no trustworthy status protocol, so this is the floor and the ceiling of
* what we report: reachable → ready, unreachable → offline. Deliberately NO
* readStatus(): the monitor uses this for the traffic-light, never a guessed
* paper/cover state.
*/
async healthCheck(): Promise<DeviceHealth> {
try {
await probe(this.#host, this.#port, this.#timeout);
return { status: "ready" };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
}
}
async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
}
async printReport(report: PrintReport): Promise<void> {
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
stubLog(
this.driverId,
`printed report "${report.title}" (${report.lines.length} lines)`,
);
}
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw(
this.#host,
this.#port,
renderSubscriptionCard(data),
this.#timeout,
);
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
async printReceipt(data: ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
stubLog(
this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
);
}
}
const roleField: ConfigField = {
key: "role",
label: "Role",
type: "select",
required: true,
default: "entry-dispenser",
options: [
{
value: "entry-dispenser",
label: "Entry dispenser (outside / at the lane)",
},
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
],
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
};
const rankField: ConfigField = {
key: "failoverRank",
label: "Failover rank",
type: "number",
required: false,
default: 0,
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
};
export const cashinoDriver: PrinterDriver = {
id: "cashino",
category: "printer",
label: "Cashino 80mm thermal printer",
description:
"Cashino 80mm thermal printer (ESC/POS over raw TCP, port 9100). Prints like the Rongta but has no status page — monitored by reachability ping only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip"],
configFields: [
hostField,
{
...portField(9100),
required: false,
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
},
roleField,
rankField,
{
key: "timeoutMs",
label: "Timeout (ms)",
type: "number",
required: false,
default: 3000,
},
],
create: (c) => new CashinoPrinter(c),
};
@@ -0,0 +1,432 @@
import { Socket } from "node:net";
import type {
PrintReport,
ReceiptData,
SubscriptionCardData,
TicketData,
} from "../interfaces.js";
// Shared ESC/POS rendering + raw-TCP transport for 80mm thermal printers.
// Rongta RP-series, Cashino, and the many OEM clones all speak ESC/POS over a
// raw TCP socket on port 9100 (the JetDirect/RAW convention) with no auth on the
// print socket — they live on the isolated device VLAN. The BYTE STREAM is
// identical across these clones; what differs is live status reporting (the
// Rongta board serves a decoded status page; the Cashino does not), so status
// stays in each driver while the rendering/transport live here.
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
// --- ESC/POS command bytes ----------------------------------------------------
const ESC = 0x1b;
const GS = 0x1d;
const LF = 0x0a;
const INIT = Buffer.from([ESC, 0x40]); // ESC @ — reset to power-on defaults
const ALIGN_CENTER = Buffer.from([ESC, 0x61, 0x01]); // ESC a 1
const ALIGN_LEFT = Buffer.from([ESC, 0x61, 0x00]); // ESC a 0
const BOLD_ON = Buffer.from([ESC, 0x45, 0x01]); // ESC E 1
const BOLD_OFF = Buffer.from([ESC, 0x45, 0x00]); // ESC E 0
const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
// Select code page 852 (Latin-2) for the character set: ESC t n, n=18 (0x12).
// CP852 carries the Albanian letters we print (ë, ç, …); without it the printer
// would interpret our high bytes as CP437 glyphs. Sent in every print's INIT
// preamble. See wiki/concepts/site-metadata.md (i18n / codepage).
const SELECT_CP852 = Buffer.from([ESC, 0x74, 0x12]);
// Minimal Unicode → CP852 byte map for the characters Albanian text actually uses
// beyond ASCII. Anything not listed is transliterated to an ASCII fallback (below)
// so we never emit a byte that renders as the wrong glyph. Extend as needed.
const CP852: Record<string, number> = {
ë: 0x89,
Ë: 0xd3, // CP852 0xD3 = U+00CB Ë (0xEB is ű — wrong; fixed after a misprint)
ç: 0x87,
Ç: 0x80,
// common Latin-2 extras that may appear in a park name/address:
ä: 0x84,
ö: 0x94,
ü: 0x81,
é: 0x82,
á: 0xa0,
í: 0xa1,
ó: 0xa2,
ú: 0xa3,
};
// ASCII transliteration for any char with no CP852 mapping (last-resort, so an
// odd glyph degrades to a readable letter rather than garbage).
const ASCII_FALLBACK: Record<string, string> = {
ë: "e",
Ë: "E",
ç: "c",
Ç: "C",
ä: "a",
ö: "o",
ü: "u",
é: "e",
á: "a",
í: "i",
ó: "o",
ú: "u",
};
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
* through; mapped chars use their CP852 byte; unmapped non-ASCII falls back to an
* ASCII letter. Pair with SELECT_CP852 in the print preamble. */
function line(text = ""): Buffer {
const out: number[] = [];
// Intl.NumberFormat separates the amount from the currency with a NO-BREAK
// SPACE (U+00A0) or NARROW NO-BREAK SPACE (U+202F); neither is in CP852, so
// they'd print as "?". Normalise to a plain space (e.g. "1000 Lekë").
const normalised = text.replace(/[  ]/g, " ");
for (const ch of normalised) {
const code = ch.codePointAt(0) ?? 0;
const mapped = CP852[ch];
const fallback = ASCII_FALLBACK[ch];
if (code < 0x80) {
out.push(code);
} else if (mapped !== undefined) {
out.push(mapped);
} else if (fallback !== undefined) {
out.push(...Buffer.from(fallback, "ascii"));
} else {
out.push(0x3f); // "?" — unknown char, never a wrong glyph
}
}
out.push(LF);
return Buffer.from(out);
}
// --- Scannable symbol (printer-generated, no image rendering) -----------------
// The ticket id is the session key (wiki/concepts/ticket-encoding.md). We print it
// as a 1D Code128 barcode so ANY legacy laser barcode scanner the booth might have
// can read it. The barcode is rendered by the printer board from these ESC/POS
// commands — we send the data, the firmware draws the bars (no bitmap, no
// dependency). The same code is printed as large human-readable digits below, so
// the operator can hand-key it if every reader fails.
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data. */
function code128(data: string): Buffer {
// Code128 code set B (printable ASCII) — prefix the data with the {B selector.
const payload = Buffer.from(`{B${data}`, "ascii");
return Buffer.concat([
Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle)
Buffer.from([GS, 0x77, 0x03]), // GS w 3 — module width = 3 (wider bars for the short-range "Simple" QR/barcode engine; 13-digit Code128 ≈ 495/576 dots, fits 80mm with quiet zones)
Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves)
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
Buffer.from([GS, 0x6b, 0x49, payload.length]),
payload,
]);
}
// --- 2D QR symbol (printer-generated via ESC/POS GS ( k) -----------------------
// A true QR for the SUBSCRIPTION card — the subscriber scans it at the reader (which
// reads QR + 1D barcode) every entry/exit for the coverage period. The board renders
// the QR from these GS ( k commands (no bitmap, no dependency), same approach as
// code128. We also print the code as text below as the hand-key fallback.
/** A QR code via ESC/POS `GS ( k`. `size` = module dot size (1–16; 6 ≈ readable on
* 80mm at short range). Error-correction level M (15%) — robust to a smudged print. */
function qrCode(data: string, size = 6): Buffer {
const bytes = Buffer.from(data, "ascii");
// pL/pH encode the data length + 3 (the cn,fn,m header bytes) for function 180.
const store = bytes.length + 3;
const pL = store & 0xff;
const pH = (store >> 8) & 0xff;
return Buffer.concat([
// fn 165: select QR model — 1d 28 6b 04 00 31 41 <model=50(2)> 00
Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]),
// fn 167: module size — 1d 28 6b 03 00 31 43 <size>
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, size]),
// fn 169: error correction level — 1d 28 6b 03 00 31 45 <49=M>
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31]),
// fn 180: store the symbol data — 1d 28 6b pL pH 31 50 30 <data>
Buffer.from([GS, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30]),
bytes,
// fn 181: print the stored symbol — 1d 28 6b 03 00 31 51 30
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]),
]);
}
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this
// later without touching the render functions. See wiki/concepts/site-metadata.md.
const STR = {
/** NIUS label prefix; printed only when the park has a NIUS. */
nius: (v: string) => `NIUS: ${v}`,
/** "Printed at:" — precedes the issue timestamp. */
issuedAt: (v: string) => `Printuar më: ${v}`,
/** Subscription-card title. */
subscription: "ABONIM",
/** "Holder: <name>" line on the card. */
holder: (name: string) => `Mbajtësi: ${name}`,
/** "Valid: <from> – <to>" line on the card. */
validity: (from: string, to: string) => `Vlen: ${from} – ${to}`,
phone: (v: string) => `TEL: ${v}`,
// --- payment receipt ---
/** Receipt title. */
receipt: "FATURË PAGESE",
/** Voucher-mode title (the same slip self-exits). */
voucherTitle: "BILETË DALJE",
/** "Entry:" — entry time row. */
entry: (v: string) => `Hyrja: ${v}`,
/** "Paid:" — payment time row. */
paid: (v: string) => `Pagesa: ${v}`,
/** "Duration:" — time parked. */
duration: (v: string) => `Kohëzgjatja: ${v}`,
/** "Tender:" — cash/card. */
tender: (v: string) => `Mënyra: ${v}`,
tenderCash: "Para në dorë",
tenderCard: "Kartë",
/** "Paid:" amount label (precedes the large total). */
amountLabel: "PAGUAR",
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
* 80mm width, so neither wraps mid-word. */
graceLines: (min: number): readonly string[] => [
`Dilni brenda ${min} min.`,
"Skanoni këtë biletë në dalje.",
],
/** Thank-you footer. */
thanks: "Faleminderit!",
} as const;
/** Format integer minor units + ISO-4217 currency as a major-unit string for the
* printed receipt. Mirrors the booth UI's formatMoney (no float money model). */
function money(amountMinor: number, currency: string): string {
const major = amountMinor / 100;
try {
return new Intl.NumberFormat("sq-AL", {
style: "currency",
currency,
}).format(major);
} catch {
return `${major.toFixed(2)} ${currency}`;
}
}
/** Human duration between two ISO times, e.g. "2h 14m" / "47m". Whole minutes,
* mirroring the booth UI's formatDuration. */
function duration(fromIso: string, toIso: string): string {
const ms = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(ms) || ms < 0) return "—";
const mins = Math.floor(ms / 60_000);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
/** Local date+time "YYYY-MM-DD HH:MM" for a receipt row. The host clock is the
* site's local time (the appliance runs in the site's zone). */
function stamp(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
export function renderReport(report: PrintReport): Buffer {
return Buffer.concat([
INIT,
SELECT_CP852,
ALIGN_CENTER,
BOLD_ON,
line(report.title),
BOLD_OFF,
ALIGN_LEFT,
line(),
...report.lines.map((l) => line(l)),
FEED_AND_CUT,
]);
}
/** Render the park-identity header from site metadata. Prints the park name large
* (or "PARKING" if unset), then operator / NIUS / address lines that are present.
* NIUS and the rest only print when set. Non-ASCII renders via CP852 (see line()). */
function renderHeader(h: TicketData["header"]): Buffer {
const parts: Buffer[] = [
ALIGN_CENTER,
BOLD_ON,
DOUBLE_ON,
line(h?.parkName || "PARKING"),
DOUBLE_OFF,
BOLD_OFF,
];
if (h?.operatorName) parts.push(line(h.operatorName));
if (h?.nius) parts.push(line(STR.nius(h.nius)));
if (h?.address) {
// Address may be multi-line; print each line centered.
for (const ln of h.address.split(/\r?\n/))
if (ln.trim()) parts.push(line(ln.trim()));
}
if (h?.phone) parts.push(line(STR.phone(h.phone)));
return Buffer.concat(parts);
}
/** Build the full ESC/POS byte stream for an entry ticket.
* Header (park identity) → 1D Code128 barcode of the ticket id → the id in large
* digits → issue time. Code128 is read by ANY legacy 1D barcode scanner the booth
* might have; the printed digits are the fallback if every reader fails (operator
* hand-keys the all-numeric code). Text is Albanian.
* See wiki/concepts/ticket-encoding.md and site-metadata.md. */
export function renderTicket(data: TicketData): Buffer {
return Buffer.concat([
INIT,
SELECT_CP852,
renderHeader(data.header),
line(),
// The scannable barcode + the same code in large human-readable digits.
code128(data.ticketId),
line(),
BOLD_ON,
DOUBLE_ON,
line(data.ticketId),
DOUBLE_OFF,
BOLD_OFF,
line(),
line(STR.issuedAt(data.issuedAt)),
FEED_AND_CUT,
]);
}
/** Build the ESC/POS byte stream for a SUBSCRIPTION CARD: park header → a scannable
* QR of the code → the code in text (hand-key fallback) → holder + validity window.
* The subscriber keeps this and scans the QR at the reader every entry/exit. */
export function renderSubscriptionCard(data: SubscriptionCardData): Buffer {
const parts: Buffer[] = [
INIT,
SELECT_CP852,
renderHeader(data.header),
line(),
BOLD_ON,
line(STR.subscription),
BOLD_OFF,
line(),
ALIGN_CENTER,
qrCode(data.code),
line(),
// The code in text, as the fallback if the QR won't scan.
line(data.code),
ALIGN_LEFT,
line(),
];
if (data.holderName) parts.push(line(STR.holder(data.holderName)));
if (data.validFrom || data.validTo) {
parts.push(line(STR.validity(data.validFrom ?? "—", data.validTo ?? "—")));
}
parts.push(FEED_AND_CUT);
return Buffer.concat(parts);
}
/** Build the ESC/POS byte stream for a PAYMENT RECEIPT. Header → title → the
* transparency figures (entry / paid / duration / amount / tender). In voucher
* mode it ALSO prints the scannable ticket-id barcode and emphasises the
* walk-back grace, so the one slip both proves payment and self-exits at a
* distant exit reader. Standalone (voucher=false) is detail-only. Albanian. */
export function renderReceipt(data: ReceiptData): Buffer {
const parts: Buffer[] = [
INIT,
SELECT_CP852,
renderHeader(data.header),
line(),
ALIGN_CENTER,
BOLD_ON,
DOUBLE_ON,
line(data.voucher ? STR.voucherTitle : STR.receipt),
DOUBLE_OFF,
BOLD_OFF,
line(),
ALIGN_LEFT,
// The transparency figures.
line(STR.entry(stamp(data.enteredAt))),
line(STR.paid(stamp(data.paidAt))),
line(STR.duration(duration(data.enteredAt, data.paidAt))),
line(
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
),
line(),
// The amount, large and centred.
ALIGN_CENTER,
line(STR.amountLabel),
BOLD_ON,
DOUBLE_ON,
line(money(data.amountMinor, data.currency)),
DOUBLE_OFF,
BOLD_OFF,
line(),
];
if (data.voucher) {
// The same ticket id, scannable at the exit reader, + the grace emphasis.
parts.push(
code128(data.ticketId),
line(),
line(data.ticketId),
line(),
);
if (data.graceExitMin != null && data.graceExitMin > 0) {
parts.push(
BOLD_ON,
...STR.graceLines(data.graceExitMin).map((l) => line(l)),
BOLD_OFF,
);
}
}
parts.push(line(), line(STR.thanks), FEED_AND_CUT);
return Buffer.concat(parts);
}
/** Open a TCP socket, write the bytes, wait for flush, then close. */
export function sendRaw(
host: string,
port: number,
payload: Buffer,
timeoutMs: number,
): Promise<void> {
return new Promise((resolve, reject) => {
const sock = new Socket();
let settled = false;
const done = (err?: Error) => {
if (settled) return;
settled = true;
sock.destroy();
err ? reject(err) : resolve();
};
sock.setTimeout(timeoutMs);
sock.on("timeout", () => done(new Error("timeout")));
sock.on("error", done);
sock.connect(port, host, () => {
sock.write(payload, (err) => (err ? done(err) : done()));
});
});
}
/** TCP connect probe — reachability of the raw print socket. The print socket has
* no status protocol we rely on, so this is the floor for any ESC/POS printer:
* it answers "is the printer reachable", not "is it out of paper". */
export function probe(
host: string,
port: number,
timeoutMs: number,
): Promise<void> {
return new Promise((resolve, reject) => {
const sock = new Socket();
let settled = false;
const done = (err?: Error) => {
if (settled) return;
settled = true;
sock.destroy();
err ? reject(err) : resolve();
};
sock.setTimeout(timeoutMs);
sock.on("timeout", () => done(new Error("timeout")));
sock.on("error", done);
sock.connect(port, host, () => done());
});
}
// --- shared driver config fields ----------------------------------------------
// Role + failover are identical across ESC/POS printers; defined here so each
// driver shares them. See wiki/concepts/printer-roles-failover.md.
export type PrinterRole = "entry-dispenser" | "booth-receipt";
+99 -109
View File
@@ -1,4 +1,3 @@
import { Socket } from "node:net";
import { request as httpRequest } from "node:http"; import { request as httpRequest } from "node:http";
import type { import type {
Device, Device,
@@ -7,15 +6,27 @@ import type {
PrinterDevice, PrinterDevice,
PrinterStatus, PrinterStatus,
PrintReport, PrintReport,
SubscriptionCardData,
TicketData, TicketData,
} from "../interfaces.js"; } from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
import {
probe,
renderReceipt,
renderReport,
renderSubscriptionCard,
renderTicket,
sendRaw,
} from "./printer-escpos.js";
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the // Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket // many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
// on port 9100 — the JetDirect/RAW convention. There is no auth on the print // on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport
// socket; like the other field devices it lives on the isolated device VLAN. // are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique
// to Rongta — and lives here — is LIVE STATUS via the board's own status web page.
// There is no auth on the print socket; like the other field devices it lives on
// the isolated device VLAN.
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md. // See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
// //
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a // ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
@@ -26,90 +37,22 @@ import { hostField, portField, stubLog } from "./common.js";
// itself is role-agnostic; the role/rank live in config and the caller (server) // itself is role-agnostic; the role/rank live in config and the caller (server)
// owns the failover selection. See wiki/concepts/printer-roles-failover.md. // owns the failover selection. See wiki/concepts/printer-roles-failover.md.
// --- ESC/POS command bytes ----------------------------------------------------
const ESC = 0x1b;
const GS = 0x1d;
const LF = 0x0a;
const INIT = Buffer.from([ESC, 0x40]); // ESC @ — reset to power-on defaults
const ALIGN_CENTER = Buffer.from([ESC, 0x61, 0x01]); // ESC a 1
const ALIGN_LEFT = Buffer.from([ESC, 0x61, 0x00]); // ESC a 0
const BOLD_ON = Buffer.from([ESC, 0x45, 0x01]); // ESC E 1
const BOLD_OFF = Buffer.from([ESC, 0x45, 0x00]); // ESC E 0
const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
/** Encode a printable line as bytes (CP437/ASCII subset) + a line feed. */
function line(text = ""): Buffer {
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
}
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
function renderReport(report: PrintReport): Buffer {
return Buffer.concat([
INIT,
ALIGN_CENTER,
BOLD_ON,
line(report.title),
BOLD_OFF,
ALIGN_LEFT,
line(),
...report.lines.map((l) => line(l)),
FEED_AND_CUT,
]);
}
/** Build the full ESC/POS byte stream for an entry ticket. */
function renderTicket(data: TicketData): Buffer {
return Buffer.concat([
INIT,
ALIGN_CENTER,
BOLD_ON,
DOUBLE_ON,
line("PARKING"),
DOUBLE_OFF,
BOLD_OFF,
line(),
BOLD_ON,
line(data.ticketId),
BOLD_OFF,
ALIGN_LEFT,
line(),
line(`Issued: ${data.issuedAt}`),
FEED_AND_CUT,
]);
}
/** Open a TCP socket, write the bytes, wait for flush, then close. */
function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const sock = new Socket();
let settled = false;
const done = (err?: Error) => {
if (settled) return;
settled = true;
sock.destroy();
err ? reject(err) : resolve();
};
sock.setTimeout(timeoutMs);
sock.on("timeout", () => done(new Error("timeout")));
sock.on("error", done);
sock.connect(port, host, () => {
sock.write(payload, (err) => (err ? done(err) : done()));
});
});
}
// --- live status via the device's own status web page ------------------------- // --- live status via the device's own status web page -------------------------
// The Rongta board serves /prn_stat.htm, a small HTML table where the DEVICE has // The Rongta board serves /prn_stat.htm, a small HTML table where the DEVICE has
// already decoded the ESC/POS status bits into labelled Yes/No rows. We scrape // already decoded the ESC/POS status bits into labelled Yes/No rows. We scrape
// that rather than send raw `DLE EOT` ourselves: on this clone the DLE EOT reply // that rather than send raw `DLE EOT` ourselves: on this clone the DLE EOT reply
// bytes don't follow the canonical bit layout (verified on hardware), so trusting // bytes don't follow the canonical bit layout (verified on hardware), so trusting
// the device's own decode is the safe choice. See printer-status-monitoring.md. // the device's own decode is the safe choice. See printer-status-monitoring.md.
// A clone that does NOT serve this page (e.g. the Cashino) uses its own driver
// with a plain reachability probe — it must not pretend to report paper/cover.
/** The fault flags the status page reports (a subset of PrinterStatus). */ /** The fault flags the status page reports (a subset of PrinterStatus). */
type StatusFlag = "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline"; type StatusFlag =
| "coverOpen"
| "cutterError"
| "paperEnd"
| "paperNearEnd"
| "offline";
type StatusFlags = Partial<Record<StatusFlag, boolean>>; type StatusFlags = Partial<Record<StatusFlag, boolean>>;
/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */ /** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */
@@ -122,10 +65,20 @@ const STATUS_FIELDS: Record<string, StatusFlag> = {
}; };
/** GET the status page over HTTP and return the raw HTML. */ /** GET the status page over HTTP and return the raw HTML. */
function fetchStatusPage(host: string, httpPort: number, timeoutMs: number): Promise<string> { function fetchStatusPage(
host: string,
httpPort: number,
timeoutMs: number,
): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = httpRequest( const req = httpRequest(
{ host, port: httpPort, path: "/prn_stat.htm", method: "GET", timeout: timeoutMs }, {
host,
port: httpPort,
path: "/prn_stat.htm",
method: "GET",
timeout: timeoutMs,
},
(res) => { (res) => {
let data = ""; let data = "";
res.on("data", (c) => (data += c)); res.on("data", (c) => (data += c));
@@ -154,8 +107,15 @@ function parseStatusPage(html: string): StatusFlags {
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = rowRe.exec(html))) { while ((m = rowRe.exec(html))) {
if (m[1] === undefined || m[2] === undefined) continue; if (m[1] === undefined || m[2] === undefined) continue;
const label = m[1].replace(/&nbsp;/gi, " ").replace(/\s+/g, " ").trim().toLowerCase(); const label = m[1]
const value = m[2].replace(/&nbsp;/gi, " ").trim().toLowerCase(); .replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
const value = m[2]
.replace(/&nbsp;/gi, " ")
.trim()
.toLowerCase();
const key = STATUS_FIELDS[label]; const key = STATUS_FIELDS[label];
if (key && (value === "yes" || value === "no")) { if (key && (value === "yes" || value === "no")) {
out[key] = value === "yes"; out[key] = value === "yes";
@@ -164,24 +124,6 @@ function parseStatusPage(html: string): StatusFlags {
return out; return out;
} }
/** TCP connect probe — the print socket has no status protocol we rely on. */
function probe(host: string, port: number, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const sock = new Socket();
let settled = false;
const done = (err?: Error) => {
if (settled) return;
settled = true;
sock.destroy();
err ? reject(err) : resolve();
};
sock.setTimeout(timeoutMs);
sock.on("timeout", () => done(new Error("timeout")));
sock.on("error", done);
sock.connect(port, host, () => done());
});
}
class RongtaPrinter implements PrinterDevice, MonitorableDevice { class RongtaPrinter implements PrinterDevice, MonitorableDevice {
readonly driverId = "rongta"; readonly driverId = "rongta";
readonly #host: string; readonly #host: string;
@@ -220,7 +162,28 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
async printReport(report: PrintReport): Promise<void> { async printReport(report: PrintReport): Promise<void> {
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout); await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`); stubLog(
this.driverId,
`printed report "${report.title}" (${report.lines.length} lines)`,
);
}
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw(
this.#host,
this.#port,
renderSubscriptionCard(data),
this.#timeout,
);
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
stubLog(
this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
);
} }
/** /**
@@ -244,7 +207,13 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
const flags = parseStatusPage(html); const flags = parseStatusPage(html);
const expected: StatusFlag[] = ["coverOpen", "cutterError", "paperEnd", "paperNearEnd", "offline"]; const expected: StatusFlag[] = [
"coverOpen",
"cutterError",
"paperEnd",
"paperNearEnd",
"offline",
];
const missing = expected.filter((k) => flags[k] === undefined); const missing = expected.filter((k) => flags[k] === undefined);
if (missing.length > 0) { if (missing.length > 0) {
return { return {
@@ -265,7 +234,8 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
return { return {
status: faults.length > 0 ? "degraded" : "ready", status: faults.length > 0 ? "degraded" : "ready",
...flags, ...flags,
detail: faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined, detail:
faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
checkedAt, checkedAt,
}; };
} }
@@ -281,7 +251,10 @@ const roleField: ConfigField = {
required: true, required: true,
default: "entry-dispenser", default: "entry-dispenser",
options: [ options: [
{ value: "entry-dispenser", label: "Entry dispenser (outside / at the lane)" }, {
value: "entry-dispenser",
label: "Entry dispenser (outside / at the lane)",
},
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" }, { value: "booth-receipt", label: "Booth printer (receipts + backup)" },
], ],
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.", help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
@@ -301,15 +274,32 @@ export const rongtaDriver: PrinterDriver = {
category: "printer", category: "printer",
label: "Rongta 80mm thermal printer", label: "Rongta 80mm thermal printer",
description: description:
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.", "Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip"], transports: ["tcp-ip"],
configFields: [ configFields: [
hostField, hostField,
{ ...portField(9100), required: false, help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100)." }, {
{ key: "httpPort", label: "Status web port", type: "port", required: false, default: 80, help: "Device status page (/prn_stat.htm) port for live monitoring (default 80)." }, ...portField(9100),
required: false,
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
},
{
key: "httpPort",
label: "Status web port",
type: "port",
required: false,
default: 80,
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).",
},
roleField, roleField,
rankField, rankField,
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 3000 }, {
key: "timeoutMs",
label: "Timeout (ms)",
type: "number",
required: false,
default: 3000,
},
], ],
create: (c) => new RongtaPrinter(c), create: (c) => new RongtaPrinter(c),
}; };
+1
View File
@@ -18,6 +18,7 @@ export {
hikvisionDriver, hikvisionDriver,
dahuaDriver, dahuaDriver,
rongtaDriver, rongtaDriver,
cashinoDriver,
} from "./drivers/index.js"; } from "./drivers/index.js";
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js"; export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
export { export {
+58
View File
@@ -190,9 +190,61 @@ export interface Snapshot {
} }
// --- Printers (ticket dispenser / booth printer) ------------------------- // --- Printers (ticket dispenser / booth printer) -------------------------
/** Optional park identity printed at the top of a ticket/receipt. All fields
* optional — the driver prints only what's set. Sourced from site_config; an
* Albanian parking receipt commonly must show the park name + NIUS. */
export interface TicketHeader {
readonly parkName?: string | null;
readonly operatorName?: string | null;
/** NIUS — Albanian tax/identification number. */
readonly nius?: string | null;
readonly address?: string | null;
readonly phone?: string | null;
}
export interface TicketData { export interface TicketData {
readonly ticketId: string; readonly ticketId: string;
readonly issuedAt: string; // ISO-8601 readonly issuedAt: string; // ISO-8601
/** Park identity for the header. Absent → driver prints the generic "PARKING". */
readonly header?: TicketHeader;
}
/** A PAYMENT RECEIPT handed to the customer after a completed payment — the
* transparency record: when they entered, when they paid, how long they stayed,
* and how much they paid. Printed in two modes (see `voucher`):
* - voucher mode: ALSO carries the scannable ticket-id barcode + the walk-back
* grace window, so the same slip both proves payment AND self-exits at a
* distant exit reader (replaces the old barcode-only voucher);
* - standalone mode: detail-only (no barcode), printed at payment when the booth
* is at the exit and no voucher is issued.
* Money is integer MINOR units + an ISO-4217 currency (never a float) — the
* driver formats it. See wiki/concepts/booth-exit-flow.md, tariff.md. */
export interface ReceiptData {
readonly ticketId: string;
readonly enteredAt: string; // ISO-8601
readonly paidAt: string; // ISO-8601
readonly amountMinor: number;
readonly currency: string; // ISO-4217 (e.g. "ALL")
readonly tender: "cash" | "card";
/** Voucher mode: print the scannable barcode + emphasise the walk-back grace. */
readonly voucher: boolean;
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
readonly graceExitMin?: number | null;
readonly header?: TicketHeader;
}
/** A subscription card: the customer's keepsake, printed at the booth on creation
* (and re-printable). The driver renders the `code` as a SCANNABLE QR (the
* subscriber scans it every entry/exit) plus the code as text + the holder/validity.
* See wiki/entities/subscription.md. */
export interface SubscriptionCardData {
/** The credential value to encode in the QR (e.g. "SUB-…"). */
readonly code: string;
readonly holderName?: string | null;
/** Coverage window, for the printed card (human-readable already, or ISO). */
readonly validFrom?: string | null;
readonly validTo?: string | null;
readonly header?: TicketHeader;
} }
export interface PrinterDevice extends Device { export interface PrinterDevice extends Device {
@@ -201,6 +253,12 @@ export interface PrinterDevice extends Device {
* printed as-is; the driver adds a header/cut. Kept generic so the business * printed as-is; the driver adds a header/cut. Kept generic so the business
* layer composes the content. See wiki/concepts/shift.md. */ * layer composes the content. See wiki/concepts/shift.md. */
printReport(report: PrintReport): Promise<void>; printReport(report: PrintReport): Promise<void>;
/** Print a subscription card: a scannable QR of the code + holder/validity. */
printSubscriptionCard(data: SubscriptionCardData): Promise<void>;
/** Print a payment receipt (transparency: entry/paid/duration/amount). In
* voucher mode it also carries the ticket-id barcode + grace window so it
* doubles as the self-exit voucher. See ReceiptData. */
printReceipt(data: ReceiptData): Promise<void>;
} }
export interface PrintReport { export interface PrintReport {
+4 -2
View File
@@ -15,9 +15,11 @@
"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"
}, },
"devDependencies": { "devDependencies": {
"typescript": "6.0.3" "typescript": "6.0.3",
"vitest": "^4.1.9"
} }
} }
+617 -35
View File
@@ -5,7 +5,61 @@
// and exit events are never edited or deleted — a "void" is itself an appended // and exit events are never edited or deleted — a "void" is itself an appended
// event. See wiki/concepts/append-only-event-chain.md. // event. See wiki/concepts/append-only-event-chain.md.
export type Role = "admin" | "operator" | "cashier" | "readonly"; // --- Authorization: dynamic RBAC (resource × CRUD permissions) ---------------
// Roles are DATA (admin-composable rows in the DB), not a hardcoded enum. A role
// is a named bundle of PERMISSIONS; a permission is a `resource:action` pair drawn
// from the code-defined grid below. Route guards check a permission, never a role
// name. A built-in, locked `admin` role (id ADMIN_ROLE_ID) always holds every
// permission, so administration can never be locked out. See
// wiki/entities/local-jwt-auth.md and the RBAC plan.
/** The resources permissions are scoped to (code-defined; roles/assignments are data). */
export const RESOURCES = [
"user", // manage operators/cashiers + reset password
"role", // compose roles + assign permissions
"tariff", // read / publish a new version
"subscription", // the subscription registry
"site", // site_config + device setup/assign
"device", // device status / printers / snapshots / catalog
"shift", // open/close own shift; move the drawer float
"payment", // take payment, quote, voucher/receipt, exit, reopen
"session", // active sessions, lookup
"event", // the signed ledger feed + void
"report", // events feed, occupancy, future reports
] as const;
export type Resource = (typeof RESOURCES)[number];
/** CRUD plus two domain verbs where CRUD doesn't fit: `void` (append a void event,
* NOT a delete) and `cash` (move the drawer float — an admin-grade shift action). */
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash";
/** A single permission, e.g. "tariff:update". The route guard checks one of these. */
export type Permission = `${Resource}:${Action}`;
/** The complete, code-defined permission grid. Only these strings are checkable by
* a guard — an admin composes roles by selecting from this set. Preserves today's
* exact authz semantics (e.g. void split from read; shift cash split from open). */
export const PERMISSIONS: readonly Permission[] = [
"user:create", "user:read", "user:update", "user:delete",
"role:create", "role:read", "role:update", "role:delete",
"tariff:read", "tariff:update",
"subscription:read", "subscription:create", "subscription:update", "subscription:delete",
"site:read", "site:update",
"device:read",
"shift:read", "shift:create", "shift:cash",
"payment:read", "payment:create",
"session:read",
"event:read", "event:void",
"report:read",
] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL
* permissions. At least one user must always hold it (no-lockout invariant). */
export const ADMIN_ROLE_ID = "admin";
/** Transitional alias. Roles are now DB rows keyed by a string id; `Role` is kept
* as `string` so any not-yet-migrated reference still compiles. */
export type Role = string;
export type Direction = "entry" | "exit"; export type Direction = "entry" | "exit";
@@ -36,6 +90,11 @@ export interface LedgerEvent {
readonly signature: string; readonly signature: string;
/** Which signer/key produced `signature` (verifiable across a signer swap). */ /** Which signer/key produced `signature` (verifiable across a signer swap). */
readonly keyId: string; readonly keyId: string;
/** READ-TIME ENRICHMENT — not signed, not stored. When the event belongs to a
* subscription occurrence (payload.permitId), the server resolves the holder's
* name here so the UI shows "Aqif Kopertoni" instead of "SUBSESS-08cd1c52…".
* Absent on non-subscription events and on legacy serializers. */
readonly subscriberLabel?: string | null;
} }
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */ /** Business/accountability events that live in the SIGNED, hash-chained ledger. */
@@ -52,6 +111,10 @@ export type LedgerEventType =
// with a takings summary (shift_z_report). See wiki/concepts/shift.md. // with a takings summary (shift_z_report). See wiki/concepts/shift.md.
| "shift_open" | "shift_open"
| "shift_z_report" | "shift_z_report"
// Admin loads/removes physical drawer cash (the float). Signed payload:
// { amountMinor (signed: + load, − removal), reason, currency, operator }.
// Folds into the drawer balance carried across shifts. See wiki/concepts/shift.md.
| "cash_movement"
| "anomaly"; | "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */ /** How money was tendered (for payment events + the shift Z-report). */
@@ -76,24 +139,144 @@ export interface LedgerPayload {
readonly discountMinor?: number; readonly discountMinor?: number;
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */ /** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
readonly fxRate?: number | null; readonly fxRate?: number | null;
/** void / anomaly / override: a human/machine reason code. */ /** void / anomaly / override: a human-readable English sentence, signed as the
* immutable fallback. Prefer `reasonCode` for display (it localizes); `reason` is
* what's shown for legacy events with no code, and what an English log records. */
readonly reason?: string; readonly reason?: string;
/** Stable, language-neutral classification of WHY this event happened (e.g.
* "exit.refused.unpaid"). The presentation layer localizes it via REASONS; the
* signed bytes never change, so a language added later applies retroactively. */
readonly reasonCode?: ReasonCode;
/** Interpolation values for `reasonCode`'s message template (counts, ids, ratios).
* Signed alongside the code so the rendered sentence is reproducible. */
readonly reasonParams?: Record<string, string | number>;
/** plate/vehicle from the vision service (advisory). */ /** plate/vehicle from the vision service (advisory). */
readonly plate?: string; readonly plate?: string;
readonly plateConfidence?: number; readonly plateConfidence?: number;
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
* pricing reprices identically at exit. Absent on legacy entries (= default). */
readonly category?: string;
/** Free-form for forward-compat without a schema change. */ /** Free-form for forward-compat without a schema change. */
readonly [k: string]: unknown; readonly [k: string]: unknown;
} }
/**
* The closed set of reasons an anomaly/payment/override can carry. These are STABLE
* language-neutral keys — the anti-fraud ledger signs the code (+ params), and the
* presentation layer translates it. Adding a language = adding catalog entries, with
* NO re-signing of past events. Codes are grouped by flow: entry.* / exit.* / sub.*.
*
* When you add a new reason at an append site, add its code here AND a message in
* BOTH web catalogs (`reason.<code>` in sq.ts + en.ts) — the type makes a missing
* code a compile error at the call site, and Catalog parity makes a missing
* translation a build error.
*/
export const REASON_CODES = [
// entry
"entry.refused.full",
"entry.held.noTicket",
// exit refusals
"exit.refused.closed",
"exit.refused.noSession",
"exit.refused.unpaid",
"exit.refused.graceExpired",
// exit recorded but the barrier could not be driven (operator must open by hand)
"exit.open.noBarrier",
"exit.open.unavailable",
"exit.open.failed",
// free $0 grace exit
"exit.freeGrace",
// manual / human-intervention barrier open
"exit.manualOpen",
// subscriptions
"sub.refused.notFound",
"sub.refused.outOfWindow",
"sub.refused.noSession",
"sub.refused.atCapacity",
] as const;
export type ReasonCode = (typeof REASON_CODES)[number];
/**
* English message templates for each reason code — the SINGLE source for the signed
* `reason` fallback string (server renders this) AND the en.ts catalog. `{name}`
* placeholders are filled from `reasonParams`. Other languages live in the web
* catalogs keyed `reason.<code>`; this English copy stays here so the server can sign
* a human fallback without importing a UI catalog.
*/
export const REASON_EN: Record<ReasonCode, string> = {
"entry.refused.full": "entry refused — lot full ({count}/{capacity})",
"entry.held.noTicket": "entry held — ticket not printed: {detail}",
"exit.refused.closed": "exit refused — session already closed",
"exit.refused.noSession": "exit refused — no open session for ticket",
"exit.refused.unpaid": "exit refused — not paid (take payment first)",
"exit.refused.graceExpired": "exit refused — walk-back grace expired (top-up required)",
"exit.open.noBarrier": "exit recorded, but no exit barrier is configured — open manually",
"exit.open.unavailable": "exit recorded, but the barrier is unavailable — open manually",
"exit.open.failed": "exit recorded, but the barrier did not open — open manually",
"exit.freeGrace": "free entry-grace (no charge)",
"exit.manualOpen": "manual barrier open (human intervention)",
"sub.refused.notFound": "subscription refused — not found",
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
};
/**
* Fill a `{name}` template from params. Missing params are left as the literal token
* (defensive — a malformed event still renders something). Shared by the server (to
* sign the English fallback) and any caller that has a template string + params.
*/
export function fillTemplate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (whole, key: string) =>
key in params ? String(params[key]) : whole,
);
}
/** Render a reason code to its English sentence (the signed fallback). */
export function renderReasonEn(code: ReasonCode, params?: Record<string, string | number>): string {
return fillTemplate(REASON_EN[code], params);
}
/**
* Build the trio of reason fields to merge into a signed payload: the stable code,
* its params, and the rendered English `reason` (the immutable, localization-free
* fallback). Use at every anomaly/payment/override append site so the ledger is
* self-describing and the UI can localize without parsing free text. Spread it:
* payload: { ...reasonPayload("exit.refused.unpaid"), exitRefused: true }
*/
export function reasonPayload(
code: ReasonCode,
params?: Record<string, string | number>,
): { reasonCode: ReasonCode; reasonParams?: Record<string, string | number>; reason: string } {
return {
reasonCode: code,
...(params ? { reasonParams: params } : {}),
reason: renderReasonEn(code, params),
};
}
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */ /** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot"; export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
/** /**
* The composable rate card stored in a tariff_version.structure. Pure data the * The composable rate card stored in a tariff_version.structure.
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace; *
* a flat rate is just one block. See wiki/concepts/tariff.md. * Two shapes, a discriminated union (see TariffStructure):
* - V1 (TariffStructureV1): a single block ladder + cap/grace at the top level —
* the original shape. Bare structures with no `defaultCard` are V1 and price
* via the verbatim V1 algorithm, UNCHANGED. The one live production version is
* V1 and must keep pricing identically.
* - V2 (TariffStructureV2): a default card + optional WINDOWED cards selected by
* wall-clock time-of-day / day-of-week / date and/or vehicle category, each card
* a flat rate OR a block ladder. Adds the legacy ParkSQL2017 pricing breadth on
* top of integer-minor-unit money + immutable versions. See wiki/concepts/tariff.md
* and wiki/concepts/tariff-time-tiers.md.
*
* Pure data the fee function interprets — no rates in code, integer minor units.
*/ */
export interface TariffStructure { export interface TariffStructureV1 {
/** Free if exited within this (drop-off/turnaround). */ /** Free if exited within this (drop-off/turnaround). */
readonly gracePeriodEntryMin: number; readonly gracePeriodEntryMin: number;
/** Billing granularity; partial increments round UP. */ /** Billing granularity; partial increments round UP. */
@@ -116,6 +299,74 @@ export interface TariffBlock {
readonly priceMinorPerIncrement: number; readonly priceMinorPerIncrement: number;
} }
/** A wall-clock activation window for a V2 card. All parts are AND-ed; an absent
* part is unconstrained. Evaluated in the version's frozen tz. */
export interface TariffWindow {
/** Days-of-week this card is active (0=Sun..6=Sat), local to tz. Absent/empty = every day. */
readonly dow?: readonly number[];
/** Inclusive local date window "YYYY-MM-DD" (seasonal/holiday). Absent = unbounded that side. */
readonly dateFrom?: string;
readonly dateTo?: string;
/** Local hour-of-day window "HH:MM". `toHour <= fromHour` means it WRAPS past
* midnight (e.g. 22:00→06:00 night rate). Absent pair = all day. */
readonly fromHour?: string;
readonly toHour?: string;
}
/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap).
* `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */
export interface TariffCard {
/** Human label (also the final, deterministic precedence tiebreak). */
readonly name: string;
/** Integer precedence tiebreak among equally-specific cards; higher wins. */
readonly priority: number;
/** Vehicle/customer category this card prices. Absent = applies to all categories. */
readonly category?: string;
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
readonly window?: TariffWindow;
/** Flat price per billing increment (mutually exclusive with `blocks`). */
readonly flatMinor?: number;
/** Stepped ladder (mutually exclusive with `flatMinor`); last block open-ended. */
readonly blocks?: readonly TariffBlock[];
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
* a mixed day (see computeFeeV2). null = no cap. */
readonly dailyCapMinor?: number | null;
}
export interface TariffStructureV2 {
/** Schema marker; presence of `defaultCard` is the real discriminant. */
readonly version: 2;
/** IANA zone the wall-clock windows are evaluated in, FROZEN in the version for
* reproducibility — never read from the host clock. Copied from site config on
* publish (default "Europe/Tirane"). */
readonly tz: string;
// --- shared billing knobs (same meaning as V1) ---
readonly gracePeriodEntryMin: number;
readonly incrementMin: number;
readonly lostTicketMinor: number;
readonly gracePeriodExitMin: number;
readonly overstay: "reprice";
/** The always-applicable fallback (no window). Its dailyCapMinor governs the day. */
readonly defaultCard: TariffCard;
/** Ordered, optional windowed/category cards. Absent/empty ⇒ behaves like V1. */
readonly windowedCards?: readonly TariffCard[];
}
/** The stored/wire type: legacy-bare V1 or windowed V2. computeFee + validate accept
* both; the discriminant is the presence of `defaultCard`. */
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
/** True when a structure is the windowed V2 shape (has a defaultCard). */
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
return (t as TariffStructureV2).defaultCard != null;
}
/** The vehicle/customer category assigned to a transient entry when none is captured
* (every transient today). A V2 card with no `category` applies to all; a card WITH a
* category only applies to a matching session — so the default routes to the
* category-agnostic + default cards. See wiki/concepts/tariff-time-tiers.md. */
export const DEFAULT_VEHICLE_CATEGORY = "default";
/** /**
* Compute the parking fee (integer minor units) for a stay, from a TariffStructure. * Compute the parking fee (integer minor units) for a stay, from a TariffStructure.
* PURE + deterministic + offline — the pay station calls it with asOf = now; the * PURE + deterministic + offline — the pay station calls it with asOf = now; the
@@ -131,7 +382,18 @@ export function computeFee(
enteredAt: string, enteredAt: string,
asOf: string, asOf: string,
tariff: TariffStructure, tariff: TariffStructure,
category?: string,
): number { ): number {
return isTariffV2(tariff)
? computeFeeV2(enteredAt, asOf, tariff, category)
: computeFeeV1(enteredAt, asOf, tariff);
}
/** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept
* VERBATIM so bare/legacy structures (incl. the live production version) price
* identically. Do not "unify" this into the V2 path: a rounding divergence would
* corrupt repricing of already-signed sessions. */
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
const ms = Date.parse(asOf) - Date.parse(enteredAt); const ms = Date.parse(asOf) - Date.parse(enteredAt);
if (!Number.isFinite(ms) || ms <= 0) return 0; if (!Number.isFinite(ms) || ms <= 0) return 0;
const rawMinutes = ms / 60_000; const rawMinutes = ms / 60_000;
@@ -157,49 +419,251 @@ export function computeFee(
return total; return total;
} }
/**
* The V2 fee algorithm — adds wall-clock time-of-day / day-of-week / date windows
* and vehicle-category cards on top of the V1 ladder. PURE + integer + deterministic
* (the signed ledger reprices against this; reproducibility is mandatory).
*
* Two decoupled clocks: ELAPSED minutes advance the block-ladder position (continuous
* across card switches — a happy-hour boundary mid-stay does NOT reset the ladder);
* WALL-CLOCK time (in the version's frozen tz) selects which card's rate applies to
* each increment. Stepping one increment at a time and re-selecting the card makes the
* boundary slicing implicit. The DEFAULT card's dailyCap governs each rolling-24h day
* (a windowed card lowers the rate but never the day ceiling). See tariff-time-tiers.md.
*/
function computeFeeV2(
enteredAt: string,
asOf: string,
tariff: TariffStructureV2,
category?: string,
): number {
const enteredMs = Date.parse(enteredAt);
const ms = Date.parse(asOf) - enteredMs;
if (!Number.isFinite(ms) || ms <= 0) return 0;
const rawMinutes = ms / 60_000;
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
const inc = Math.max(1, tariff.incrementMin);
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
// Cards in contention: the default plus any windowed card matching the category.
// (A card with no `category` applies to all; one with a category applies only to
// a matching session.) The defaultCard always matches and is the fallback.
const cards = [
tariff.defaultCard,
...(tariff.windowedCards ?? []).filter((c) => c.category == null || c.category === category),
];
const dayCap = tariff.defaultCard.dailyCapMinor ?? null;
const DAY = 24 * 60;
let total = 0;
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0;
for (let within = segStart; within < segEnd; within += inc) {
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
const card = selectCard(cards, wall);
if (card.flatMinor != null) {
segFee += card.flatMinor;
} else {
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
segFee += rateAt(card.blocks ?? [], within - segStart);
}
}
if (dayCap != null) segFee = Math.min(segFee, dayCap);
total += segFee;
}
return total;
}
/** /**
* Validate an admin-authored tariff structure. Returns [] if valid, else a list * Validate an admin-authored tariff structure. Returns [] if valid, else a list
* of human-readable problems. Pure — used by the composer route (and any caller) * of human-readable problems. Pure — used by the composer route (and any caller)
* so a malformed rate card can never be published. See wiki/concepts/tariff.md. * so a malformed rate card can never be published. See wiki/concepts/tariff.md.
*/ */
export function validateTariffStructure(s: unknown): string[] { export function validateTariffStructure(s: unknown): string[] {
const errs: string[] = [];
if (!s || typeof s !== "object") return ["structure must be an object"]; if (!s || typeof s !== "object") return ["structure must be an object"];
const t = s as Partial<TariffStructure>; // Discriminate: a `defaultCard` ⇒ the windowed V2 shape; otherwise legacy bare V1.
// The V1 branch is kept byte-identical (same messages) so the live version still
// validates the same on any future republish.
return (s as Partial<TariffStructureV2>).defaultCard != null
? validateTariffV2(s as Partial<TariffStructureV2>)
: validateTariffV1(s as Partial<TariffStructureV1>);
}
const nonNegInt = (v: unknown, label: string) => { function nonNegInt(v: unknown, label: string, errs: string[]): void {
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`); if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
}; }
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin");
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin");
nonNegInt(t.lostTicketMinor, "lostTicketMinor");
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
errs.push("incrementMin must be a positive integer");
}
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor");
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
if (!Array.isArray(t.blocks) || t.blocks.length === 0) { /** Validate the block ladder (ascending bounds, open-ended last). `prefix` labels
errs.push("blocks must be a non-empty array"); * errors (e.g. "blocks" or "defaultCard.blocks"). Shared by V1 + V2. */
} else { function validateBlocks(blocks: unknown, prefix: string, errs: string[]): void {
if (!Array.isArray(blocks) || blocks.length === 0) {
errs.push(`${prefix} must be a non-empty array`);
return;
}
let prevBound = 0; let prevBound = 0;
t.blocks.forEach((b, i) => { blocks.forEach((b: Partial<TariffBlock>, i: number) => {
const last = i === t.blocks!.length - 1; const last = i === blocks.length - 1;
nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`); nonNegInt(b?.priceMinorPerIncrement, `${prefix}[${i}].priceMinorPerIncrement`, errs);
if (b?.uptoMin == null) { if (b?.uptoMin == null) {
if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`); if (!last) errs.push(`${prefix}[${i}] is open-ended (uptoMin null) but not last`);
} else { } else if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) { errs.push(`${prefix}[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
} else { } else {
prevBound = b.uptoMin; prevBound = b.uptoMin;
} }
}
}); });
// The LAST block must be open-ended (uptoMin null) so the "thereafter" rate is
// always explicit — a bounded final block silently inherits its own rate past its
// bound (a hidden, never-stated price). See wiki/concepts/tariff.md.
const lastBlock = (blocks as Partial<TariffBlock>[])[blocks.length - 1];
if (lastBlock && lastBlock.uptoMin != null) {
errs.push(
prefix === "blocks"
? "the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly"
: `${prefix}: the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly`,
);
} }
}
function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
const errs: string[] = [];
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin", errs);
nonNegInt(t.lostTicketMinor, "lostTicketMinor", errs);
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
errs.push("incrementMin must be a positive integer");
}
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs);
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
validateBlocks(t.blocks, "blocks", errs);
return errs; return errs;
} }
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
const YMD = /^\d{4}-\d{2}-\d{2}$/;
/** Validate one V2 card's pricing body (flat XOR ladder) + window. */
function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefault: boolean, errs: string[]): void {
if (!c || typeof c !== "object") {
errs.push(`${label} must be an object`);
return;
}
if (typeof c.name !== "string" || c.name.length === 0) errs.push(`${label}.name is required`);
if (typeof c.priority !== "number" || !Number.isInteger(c.priority)) errs.push(`${label}.priority must be an integer`);
const hasFlat = c.flatMinor != null;
const hasBlocks = c.blocks != null;
if (hasFlat === hasBlocks) {
errs.push(`${label} must set exactly one of flatMinor or blocks`);
} else if (hasFlat) {
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
} else {
validateBlocks(c.blocks, `${label}.blocks`, errs);
if (c.dailyCapMinor != null) nonNegInt(c.dailyCapMinor, `${label}.dailyCapMinor`, errs);
}
if (isDefault) {
if (c.window != null) errs.push("defaultCard must not have a window (it is the always-active fallback)");
if (c.category != null) errs.push("defaultCard must not have a category (it is the catch-all)");
} else {
validateWindow(c.window, `${label}.window`, errs);
if (c.category != null && (typeof c.category !== "string" || c.category.length === 0)) {
errs.push(`${label}.category must be a non-empty string when present`);
}
}
}
function validateWindow(w: Partial<TariffWindow> | undefined, label: string, errs: string[]): void {
if (w == null) return; // a windowed card with no window = always-on tier (allowed)
if (w.dow != null) {
if (!Array.isArray(w.dow) || w.dow.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) {
errs.push(`${label}.dow must be integers 0-6 (0=Sun)`);
}
}
const hasFrom = w.fromHour != null;
const hasTo = w.toHour != null;
if (hasFrom !== hasTo) errs.push(`${label}: fromHour and toHour must be set together`);
if (hasFrom && hasTo) {
if (!HHMM.test(w.fromHour!)) errs.push(`${label}.fromHour must be "HH:MM"`);
if (!HHMM.test(w.toHour!)) errs.push(`${label}.toHour must be "HH:MM"`);
// toHour <= fromHour is allowed (overnight wrap) — not an error.
}
if (w.dateFrom != null && !YMD.test(w.dateFrom)) errs.push(`${label}.dateFrom must be "YYYY-MM-DD"`);
if (w.dateTo != null && !YMD.test(w.dateTo)) errs.push(`${label}.dateTo must be "YYYY-MM-DD"`);
if (w.dateFrom != null && w.dateTo != null && YMD.test(w.dateFrom) && YMD.test(w.dateTo) && w.dateFrom > w.dateTo) {
errs.push(`${label}.dateFrom must be ≤ dateTo`);
}
}
function validateTariffV2(t: Partial<TariffStructureV2>): string[] {
const errs: string[] = [];
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin", errs);
nonNegInt(t.lostTicketMinor, "lostTicketMinor", errs);
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
errs.push("incrementMin must be a positive integer");
}
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
const cards = t.windowedCards ?? [];
// tz is required once there are windowed cards (wall-clock is meaningless without it).
if (cards.length > 0 && (typeof t.tz !== "string" || t.tz.length === 0)) {
errs.push("tz (IANA timezone) is required when windowedCards are present");
}
validateCard(t.defaultCard, "defaultCard", true, errs);
if (!Array.isArray(t.windowedCards) && t.windowedCards != null) {
errs.push("windowedCards must be an array");
} else {
cards.forEach((c, i) => validateCard(c, `windowedCards[${i}]`, false, errs));
}
// Precedence determinism: reject two cards (same category bucket) that tie on
// (specificity, priority) with overlapping windows — the operator must break the
// tie with priority rather than relying silently on the name tiebreak.
detectAmbiguousPrecedence(cards, errs);
return errs;
}
/** Flag pairs of windowed cards that could BOTH be the precedence winner for some
* instant (same category bucket, equal specificity + priority, overlapping windows).
* Conservative overlap test; false positives are safer than a silent tie. */
function detectAmbiguousPrecedence(cards: readonly Partial<TariffCard>[], errs: string[]): void {
for (let i = 0; i < cards.length; i++) {
for (let j = i + 1; j < cards.length; j++) {
const a = cards[i]!;
const b = cards[j]!;
if ((a.category ?? null) !== (b.category ?? null)) continue;
if (a.priority !== b.priority) continue;
const sa = specificity(a as TariffCard);
const sb = specificity(b as TariffCard);
if (sa[0] !== sb[0] || sa[1] !== sb[1] || sa[2] !== sb[2]) continue;
if (windowsOverlap(a.window, b.window)) {
errs.push(
`windowedCards "${a.name ?? i}" and "${b.name ?? j}" are equally specific with the same priority and overlapping windows — give one a higher priority to break the tie`,
);
}
}
}
}
/** Conservative window-overlap: true unless a dimension provably disjoints them. */
function windowsOverlap(a: TariffWindow | undefined, b: TariffWindow | undefined): boolean {
if (!a || !b) return true; // an unconstrained window overlaps anything
// dow: disjoint only if both constrain dow and share no day.
if (a.dow && a.dow.length && b.dow && b.dow.length && !a.dow.some((d) => b.dow!.includes(d))) return false;
// date: disjoint only if both fully bounded and ranges don't intersect.
if (a.dateFrom && a.dateTo && b.dateFrom && b.dateTo && (a.dateTo < b.dateFrom || b.dateTo < a.dateFrom)) return false;
// hour: disjoint only if both have non-wrapping ranges that don't intersect.
if (a.fromHour && a.toHour && b.fromHour && b.toHour) {
const af = hourToMin(a.fromHour), at = hourToMin(a.toHour), bf = hourToMin(b.fromHour), bt = hourToMin(b.toHour);
if (at > af && bt > bf && (at <= bf || bt <= af)) return false; // both non-wrapping & disjoint
}
return true;
}
/** Price of the increment that starts at `cumulativeMin` — the block whose range /** Price of the increment that starts at `cumulativeMin` — the block whose range
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */ * [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number { function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
@@ -213,12 +677,130 @@ function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0; return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0;
} }
export const ROLES: readonly Role[] = [ // --- V2 wall-clock helpers (pure, deterministic given the frozen tz) ----------
"admin",
"operator", /** Wall-clock breakdown of an instant in a fixed IANA tz. Pure: the same (instant,
"cashier", * tz) always yields the same result (tz is frozen in the tariff version, never the
"readonly", * host). Uses Intl.DateTimeFormat — handles DST for the named zone. */
] as const; export interface WallClock {
readonly y: number;
readonly mo: number; // 1-12
readonly d: number; // 1-31
readonly hour: number; // 0-23
readonly minute: number; // 0-59
readonly dow: number; // 0=Sun..6=Sat
}
const DOW_INDEX: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
export function localBreakdown(instantMs: number, tz: string): WallClock {
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
weekday: "short",
});
const parts = fmt.formatToParts(new Date(instantMs));
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
return {
y: Number(get("year")),
mo: Number(get("month")),
d: Number(get("day")),
hour: Number(get("hour")),
minute: Number(get("minute")),
dow: DOW_INDEX[get("weekday")] ?? 0,
};
}
/** "HH:MM" → minutes-of-day (0-1439). Invalid → NaN (validation rejects those). */
function hourToMin(hhmm: string): number {
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!m) return NaN;
return Number(m[1]) * 60 + Number(m[2]);
}
/** "YYYY-MM-DD" → comparable integer YYYYMMDD. */
function dateKey(w: WallClock): number {
return w.y * 10000 + w.mo * 100 + w.d;
}
function isoDateKey(iso: string): number {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
return m ? Number(m[1]) * 10000 + Number(m[2]) * 100 + Number(m[3]) : NaN;
}
/** Does a card's window cover this wall-clock instant? Absent parts are unconstrained;
* an absent window (defaultCard) always matches. An hour range with `toHour <= fromHour`
* is an overnight wrap (active when hour ≥ fromHour OR hour < toHour). */
function matchesWindow(w: TariffWindow | undefined, wall: WallClock): boolean {
if (!w) return true;
if (w.dow && w.dow.length > 0 && !w.dow.includes(wall.dow)) return false;
if (w.dateFrom != null && dateKey(wall) < isoDateKey(w.dateFrom)) return false;
if (w.dateTo != null && dateKey(wall) > isoDateKey(w.dateTo)) return false;
if (w.fromHour != null && w.toHour != null) {
const from = hourToMin(w.fromHour);
const to = hourToMin(w.toHour);
const now = wall.hour * 60 + wall.minute;
if (to <= from) {
// overnight wrap, e.g. 22:00→06:00
if (!(now >= from || now < to)) return false;
} else {
if (!(now >= from && now < to)) return false;
}
}
return true;
}
/** Specificity tuple (date, dow, hour) — more constrained windows win. Higher is
* more specific; compared lexicographically. */
function specificity(c: TariffCard): [number, number, number] {
const w = c.window;
const hasDate = w != null && (w.dateFrom != null || w.dateTo != null) ? 1 : 0;
const hasDow = w != null && w.dow != null && w.dow.length > 0 ? 1 : 0;
const hasHour = w != null && w.fromHour != null && w.toHour != null ? 1 : 0;
return [hasDate, hasDow, hasHour];
}
/** Pick the single active card for a wall-clock instant from the candidate cards
* (default + category-matched). TOTAL + order-independent: most-specific wins, then
* higher `priority`, then `name` lexicographically as the final deterministic tiebreak
* (never array index). The defaultCard has specificity (0,0,0) so it only wins when
* nothing more specific matches. */
function selectCard(cards: readonly TariffCard[], wall: WallClock): TariffCard {
let best: TariffCard | undefined;
let bestSpec: [number, number, number] = [-1, -1, -1];
for (const c of cards) {
if (!matchesWindow(c.window, wall)) continue;
const spec = specificity(c);
if (best === undefined || compareCard(spec, c, bestSpec, best) > 0) {
best = c;
bestSpec = spec;
}
}
// The defaultCard always matches, so `best` is never undefined in practice; the
// fallback keeps the function total even for a pathological empty card list.
return best ?? cards[0]!;
}
/** Order: specificity desc, then priority desc, then name asc. Returns >0 if (specA,a)
* should beat (specB,b). */
function compareCard(
specA: [number, number, number],
a: TariffCard,
specB: [number, number, number],
b: TariffCard,
): number {
for (let i = 0; i < 3; i++) {
if (specA[i]! !== specB[i]!) return specA[i]! - specB[i]!;
}
if (a.priority !== b.priority) return a.priority - b.priority;
// Name as the final, total tiebreak. Lower name wins → invert so >0 means a beats b.
if (a.name !== b.name) return a.name < b.name ? 1 : -1;
return 0;
}
/** /**
* Signs the canonical bytes of an event for the append-only chain. This is the * Signs the canonical bytes of an event for the append-only chain. This is the
+257
View File
@@ -0,0 +1,257 @@
import { describe, it, expect } from "vitest";
import {
computeFee,
validateTariffStructure,
type TariffStructureV1,
type TariffStructureV2,
type TariffCard,
} from "./index.js";
const entered = "2026-06-18T00:00:00.000Z";
const at = (min: number) => new Date(Date.parse(entered) + min * 60_000).toISOString();
// ---------------------------------------------------------------------------
// (a) GOLDEN V1 regression — the live production structure must reprice to these
// exact integers. Captured from the pre-V2 engine. This is the most important
// test: it proves a signed historical session reprices identically.
// ---------------------------------------------------------------------------
const liveV1: TariffStructureV1 = {
gracePeriodEntryMin: 5,
incrementMin: 60,
blocks: [
{ uptoMin: 60, priceMinorPerIncrement: 20000 },
{ uptoMin: 180, priceMinorPerIncrement: 10000 },
],
dailyCapMinor: 100000,
lostTicketMinor: 100000,
gracePeriodExitMin: 5,
overstay: "reprice",
};
describe("V1 golden regression", () => {
const golden: Record<number, number> = {
3: 0, 30: 20000, 60: 20000, 61: 30000, 120: 30000, 180: 40000,
181: 50000, 240: 50000, 1440: 100000, 1500: 120000, 2880: 200000,
};
for (const [min, want] of Object.entries(golden)) {
it(`${min} min → ${want}`, () => {
expect(computeFee(entered, at(Number(min)), liveV1)).toBe(want);
});
}
it("a V1 structure ignores the category argument", () => {
expect(computeFee(entered, at(120), liveV1, "bus")).toBe(30000);
});
});
// ---------------------------------------------------------------------------
// V2 building blocks
// ---------------------------------------------------------------------------
const ladder = (open: number, first?: { uptoMin: number; rate: number }) =>
first
? [{ uptoMin: first.uptoMin, priceMinorPerIncrement: first.rate }, { uptoMin: null, priceMinorPerIncrement: open }]
: [{ uptoMin: null, priceMinorPerIncrement: open }];
const defaultCard: TariffCard = {
name: "default",
priority: 0,
blocks: ladder(20000), // flat 200/h ladder (open-ended)
dailyCapMinor: null,
};
function v2(windowedCards: TariffCard[], tz = "Europe/Tirane", over: Partial<TariffStructureV2> = {}): TariffStructureV2 {
return {
version: 2,
tz,
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 100000,
gracePeriodExitMin: 5,
overstay: "reprice",
defaultCard,
windowedCards,
...over,
};
}
describe("V2 back-compat: a V2 with no windowed cards prices like its default ladder", () => {
it("default-only V2 == equivalent V1", () => {
const s = v2([]);
// 200/h flat ladder, 3h
expect(computeFee(entered, at(180), s)).toBe(60000);
});
});
describe("V2 time-of-day window (happy hour)", () => {
// Tirane is UTC+2 in June (DST). entered 00:00Z = 02:00 local.
// Happy hour 04:00–06:00 local = 02:00–04:00Z. Default 200/h, happy 50/h.
const happy: TariffCard = {
name: "happy",
priority: 10,
window: { fromHour: "04:00", toHour: "06:00" },
blocks: ladder(5000),
};
const s = v2([happy]);
it("a stay crossing into happy hour bills each increment by its wall-clock card", () => {
// 0-120min elapsed = local 02:00-04:00 (default 200/h ×2 = 400),
// 120-240min = local 04:00-06:00 (happy 50/h ×2 = 100). Total 500 = 50000.
expect(computeFee(entered, at(240), s)).toBe(50000);
});
it("a stay entirely before happy hour is all default", () => {
expect(computeFee(entered, at(120), s)).toBe(40000); // 2h × 200
});
});
describe("V2 overnight wrap window", () => {
// night 22:00→06:00 local (wraps midnight), cheap 50/h.
const night: TariffCard = {
name: "night",
priority: 10,
window: { fromHour: "22:00", toHour: "06:00" },
blocks: ladder(5000),
};
const s = v2([night]);
it("an early-morning stay (local 02:00-04:00) is inside the wrap → night rate", () => {
expect(computeFee(entered, at(120), s)).toBe(10000); // 2h × 50
});
});
describe("V2 day-of-week tested at the increment's wall-clock day", () => {
// 2026-06-18 is a Thursday (dow 4). A Friday-only card must NOT apply.
const friOnly: TariffCard = { name: "fri", priority: 10, window: { dow: [5] }, blocks: ladder(5000) };
it("Thursday stay does not get the Friday card", () => {
expect(computeFee(entered, at(120), v2([friOnly]))).toBe(40000); // default 200×2
});
const thuOnly: TariffCard = { name: "thu", priority: 10, window: { dow: [4] }, blocks: ladder(5000) };
it("Thursday stay gets the Thursday card", () => {
expect(computeFee(entered, at(120), v2([thuOnly]))).toBe(10000); // 50×2
});
});
describe("V2 flat card", () => {
const flatNight: TariffCard = {
name: "flat",
priority: 10,
window: { fromHour: "00:00", toHour: "23:59" }, // effectively all day here
flatMinor: 3000,
};
it("flat card charges flatMinor per increment", () => {
expect(computeFee(entered, at(180), v2([flatNight]))).toBe(9000); // 3h × 30
});
});
describe("V2 category filter", () => {
const busCard: TariffCard = { name: "bus", priority: 10, category: "bus", blocks: ladder(40000) };
const s = v2([busCard]);
it("a bus session uses the bus card (400/h)", () => {
expect(computeFee(entered, at(120), s, "bus")).toBe(80000);
});
it("a car session ignores the bus card → default (200/h)", () => {
expect(computeFee(entered, at(120), s, "car")).toBe(40000);
});
it("no category given ignores the bus card → default", () => {
expect(computeFee(entered, at(120), s)).toBe(40000);
});
});
describe("V2 daily cap uses the DEFAULT card's cap on a mixed day", () => {
// default cap 1000/day; a cheap night card present. 24h elapsed.
const night: TariffCard = { name: "night", priority: 10, window: { fromHour: "22:00", toHour: "06:00" }, blocks: ladder(5000) };
const s = v2([night], "Europe/Tirane", { defaultCard: { ...defaultCard, dailyCapMinor: 100000 } });
it("a 24h stay is capped at the default card's 1000/day", () => {
expect(computeFee(entered, at(1440), s)).toBe(100000);
});
});
describe("V2 precedence is total + order-independent", () => {
// Specificity order is date > dow > hour-only (see plan / tariff-time-tiers.md).
// So a dow-constrained card beats an hour-only card at an overlapping instant.
const dowCard: TariffCard = { name: "a-dow", priority: 5, window: { dow: [4] }, blocks: ladder(10000) }; // Thu, 100/h
const hourCard: TariffCard = { name: "b-hour", priority: 5, window: { fromHour: "02:00", toHour: "04:00" }, blocks: ladder(5000) }; // local 02-04, 50/h
it("dow (more specific than hour-only) wins at an overlapping instant", () => {
// local 02:00-04:00 = elapsed 0-120; both match, dow ranks above hour → 100/h
expect(computeFee(entered, at(120), v2([dowCard, hourCard]))).toBe(20000);
});
it("a date window beats a dow window (date is most specific)", () => {
const dateCard: TariffCard = { name: "c-date", priority: 1, window: { dateFrom: "2026-06-18", dateTo: "2026-06-18" }, blocks: ladder(5000) }; // 50/h
// date beats dow even with LOWER priority (specificity dominates priority)
expect(computeFee(entered, at(120), v2([dowCard, dateCard]))).toBe(10000);
});
it("fee is identical when windowedCards order is shuffled", () => {
const a = computeFee(entered, at(120), v2([dowCard, hourCard]));
const b = computeFee(entered, at(120), v2([hourCard, dowCard]));
expect(a).toBe(b);
});
});
describe("V2 DST determinism (Europe/Tirane)", () => {
// Spring forward 2026-03-29 03:00 local (clocks 02:00→03:00). Fall back 2026-10-25.
const cheap: TariffCard = { name: "c", priority: 10, window: { fromHour: "00:00", toHour: "23:59" }, flatMinor: 1000 };
it("a stay across the spring-forward boundary prices deterministically", () => {
const e = "2026-03-29T00:00:00.000Z"; // 01:00 local pre-jump
const a1 = computeFee(e, new Date(Date.parse(e) + 240 * 60_000).toISOString(), v2([cheap]));
const a2 = computeFee(e, new Date(Date.parse(e) + 240 * 60_000).toISOString(), v2([cheap]));
expect(a1).toBe(a2); // determinism
expect(a1).toBe(4000); // 4h × flat 10
});
});
// ---------------------------------------------------------------------------
// (e) validation accept/reject matrix
// ---------------------------------------------------------------------------
describe("validate V1 (unchanged messages)", () => {
it("accepts the live structure", () => {
expect(validateTariffStructure({ ...liveV1, blocks: [...liveV1.blocks, { uptoMin: null, priceMinorPerIncrement: 5000 }] })).toEqual([]);
});
it("rejects a bounded last block", () => {
expect(validateTariffStructure(liveV1)).toContain(
"the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly",
);
});
});
describe("validate V2", () => {
const okDefault: TariffCard = { name: "d", priority: 0, blocks: ladder(20000) };
const base = { version: 2 as const, tz: "Europe/Tirane", gracePeriodEntryMin: 5, incrementMin: 60, lostTicketMinor: 0, gracePeriodExitMin: 5, overstay: "reprice" as const };
it("accepts a minimal default-only V2", () => {
expect(validateTariffStructure({ ...base, defaultCard: okDefault })).toEqual([]);
});
it("requires tz when windowedCards present", () => {
const errs = validateTariffStructure({ ...base, tz: "", defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { dow: [1] }, blocks: ladder(5000) }] });
expect(errs).toContain("tz (IANA timezone) is required when windowedCards are present");
});
it("rejects a card with both flat and blocks", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
expect(errs).toContain("defaultCard must set exactly one of flatMinor or blocks");
});
it("rejects defaultCard with a window", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
expect(errs).toContain("defaultCard must not have a window (it is the always-active fallback)");
});
it("rejects a bad hour format", () => {
const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { fromHour: "25:00", toHour: "26:00" }, blocks: ladder(5000) }] });
expect(errs.some((e) => e.includes("fromHour"))).toBe(true);
});
it("rejects ambiguous precedence (equal specificity+priority, overlapping)", () => {
const errs = validateTariffStructure({
...base,
defaultCard: okDefault,
windowedCards: [
{ name: "x", priority: 5, window: { dow: [1, 2] }, blocks: ladder(5000) },
{ name: "y", priority: 5, window: { dow: [2, 3] }, blocks: ladder(6000) },
],
});
expect(errs.some((e) => e.includes("higher priority to break the tie"))).toBe(true);
});
it("allows the tie to be broken by priority", () => {
const errs = validateTariffStructure({
...base,
defaultCard: okDefault,
windowedCards: [
{ name: "x", priority: 5, window: { dow: [1, 2] }, blocks: ladder(5000) },
{ name: "y", priority: 6, window: { dow: [2, 3] }, blocks: ladder(6000) },
],
});
expect(errs).toEqual([]);
});
});
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
// Only run tests from src (TypeScript source). Without this, the compiled copies
// in dist/ get picked up as duplicate (stale) test files.
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});
+1519 -7
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -14,7 +14,7 @@ session projection.
## The rule ## The rule
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an An identity (ticket id, [[subscription]] credential, or plate) **must not enter while it already has an
OPEN [[parking-session|session]].** At entry: OPEN [[parking-session|session]].** At entry:
``` ```
@@ -25,13 +25,13 @@ identify vehicle → is there already an OPEN session for this id?
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`. powers [[capacity-occupancy]] and [[subscription]] `maxConcurrent`.
## Interaction with the limits already designed ## Interaction with the limits already designed
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same - **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
id is always a violation (or a re-print/duplication attempt). id is always a violation (or a re-print/duplication attempt).
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a - **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[subscription]]): a
multi-car permit legitimately has several open sessions, but **the same car/credential** entering multi-car permit legitimately has several open sessions, but **the same car/credential** entering
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance. twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.

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