Files
parking_solution/wiki/concepts/booth-console.md
T
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

5.7 KiB

type, tags, sources, updated, status
type tags sources updated status
concept
parking
frontend
booth
realtime
ui
2026-06-18 open

Booth Console (operator UI architecture)

The operator console — the real-time UI an attendant runs at a manned booth. Built 2026-06-17/18 on top of the react-vite-spa. This page covers the architecture (stack, live feed, layout); the booth's business flows live in booth-exit-flow, shift, parking-session.

Stack (added 2026-06-17, beyond plain React)

The operator UI outgrew "plain React + useState" once it needed live updates and a real layout:

  • TanStack Query owns SERVER state (fetch/cache/refetch/loading-error), wrapping the existing thin apiFetch client. Server data is never duplicated into client state.
  • TanStack Router — real routes (/booth, /shift, /setup, /tariff, /permits, /site), role-guarded (admin-only routes redirect non-admins to /booth). Code-based route tree.
  • Zustand — small CLIENT state only: the live WebSocket status + a rolling in-memory event feed + the latest pushed occupancy. Anything durable is re-fetched via Query.
  • Tailwind v4 with a "Bloomberg-terminal" theme (apps/web/src/index.css, @theme): near-black surfaces, amber/green/red/cyan status accents, monospace, dense/keyboard-first. Radix primitives (Dialog, etc.) for accessible unstyled components.
  • react-i18next for i18n (Albanian default).

This SUPERSEDES the original "plain React, no framework" note on react-vite-spa — that held while the UI was a few admin forms; the live booth console justified the additions.

Live feed — one WebSocket (/api/ws)

The booth must reflect entries/exits/payments the instant they happen, so the console opens one authenticated WebSocket app-wide instead of polling. See the WS tap in append-only-event-chain (EventLog.append fires a read-side onAppended callback → the device bus emitLedger → the WS route fans it out):

  • On each signed ledger append (entry/exit/payment/void/anomaly/cash_movement/shift_*) the server pushes the event plus the recomputed capacity-occupancy (a fold over the same ledger, always authoritative). Printer-status changes (printer-status-monitoring) ride the same socket.
  • The client appends to the Zustand feed for the live ticker AND invalidates the matching Query caches (events, occupancy, active-sessions) — so Query stays the source of truth; the WS is the freshness trigger. Auto-reconnect with capped backoff survives a server restart.

Auth — anti-CSWSH

The handshake is a normal GET through Fastify, so the HttpOnly JWT cookie that guards the REST API guards the WS too. But a browser WebSocket can't send the CSRF double-submit header, which would leave the socket open to Cross-Site WebSocket Hijacking (a malicious page opens ws://<booth>/api/ws, the browser auto-attaches the cookie, the attacker reads the live feed). So the WS route replaces CSRF with an Origin allowlist (same-origin always; extra origins via WS_ALLOWED_ORIGINS for the dev SPA): a missing/cross origin is rejected before auth. The stream is read-only — it can never mutate state. (Found + fixed by automated security review, 2026-06-17.)

The booth screen (/booth)

Dense terminal layout: a ticket input (HID-scanner-friendly — types the id + Enter) spanning the top; a left column with the occupancy gauge above the booth-exit-flow list; a right column with the live event ticker. Submitting/clicking a ticket opens the pay/exit modal (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via the WS.

The shift control (header) + the booth gate

The header carries a single shift button that expresses the [[shift|site-wide single-open shift]] (added 2026-06-18):

  • No shift open → "Open shift" (green, enabled).
  • My shift open → "Close shift" (red, enabled — signs + prints the Z-report).
  • Another operator's shift open → disabled, titled with who holds it. You can neither open yours nor close theirs until they hand over.

State comes from one shared Query (useShift() → GET /api/shift/current, returning { open: {startedAt, operator} | null, isMine }); the WS invalidates it on shift_open / shift_z_report / cash_movement, so the button (and the per-shift log scope) update live without polling.

The booth screen gates on this: the pay/exit modal shows an "open a shift" banner (with a one-click Open shift now) and disables pay/exit/voucher until this operator's shift is open; the Active-Sessions "Open barrier" is disabled the same way. The server enforces it regardless (requireShift 409 no_shift) — the UI just front-runs the rejection. The live feed is scoped to the open shift's window (empty when no shift is open). See shift for the rule and the routes.

Dev notes

  • Vite proxies /api/ws (ws: true) to the backend; the backend's Origin allowlist must include the dev SPA origin (WS_ALLOWED_ORIGINS=http://localhost:5173). In production Fastify serves the SPA same-origin, so the allowlist isn't needed.
  • Start the dev SPA via pnpm dev from apps/web (not npx vite --host …, which has mangled args and served 404s in this environment).

Open

  • No automated frontend tests — the booth/live-feed/modal logic is verified manually (Playwright + curl + DB inspection), not by a suite. The standing test-harness gap (see reconciliation-adjacent notes) now spans front and back.
  • The pre-existing admin screens (Setup/Tariff/Permits/Site/Shift) still carry their old inline styles — reachable and functional, not yet on the terminal component system.