Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8ddda86e7 | |||
| bba988c4e8 | |||
| 5697137c52 | |||
| ca8c7f2fa2 | |||
| f87e4c0d6b | |||
| 4e2e4feedb | |||
| 48660d3ec8 | |||
| 14c83e182a | |||
| 445bca0bf6 | |||
| 062feeae2f | |||
| 50a3095ef3 | |||
| eb3dc18e67 | |||
| 06dab1e790 | |||
| 9956488fd5 | |||
| 49df2015c8 | |||
| c2f06a5d2a | |||
| 58d8f06ba0 | |||
| 71aaad03b9 | |||
| 727c62da90 |
+1
-20
@@ -1,24 +1,5 @@
|
||||
{
|
||||
"hooks": {
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"PreToolUse": []
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
- 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).
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
# Generate one with: openssl rand -hex 32
|
||||
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 ----------------------------------------------------------------
|
||||
# PORT=3000
|
||||
# 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
|
||||
# ADMIN_USER=admin
|
||||
# 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
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/jwt": "10.1.0",
|
||||
"@fastify/static": "9.1.3",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@parking/db": "workspace:*",
|
||||
"@parking/devices": "workspace:*",
|
||||
"@parking/shared": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import {
|
||||
printWithFailover,
|
||||
registry,
|
||||
type PrinterDevice,
|
||||
type PrinterInstance,
|
||||
type TicketData,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print an exit voucher for a paid session: the same ticket id reprinted as a
|
||||
* barcode, on the booth printer (failing over to the entry dispenser). Returns the
|
||||
* id of the printer that printed it. Throws NoPrinterAvailableError if none can.
|
||||
*/
|
||||
export async function printExitVoucher(
|
||||
db: Db,
|
||||
ticketId: string,
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<string> {
|
||||
const printers = loadPrinters(db);
|
||||
const ticket: TicketData = {
|
||||
ticketId,
|
||||
issuedAt: new Date().toISOString(),
|
||||
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.printTicket(ticket),
|
||||
);
|
||||
logger.info(`exit voucher 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { PrinterStatus } from "@parking/devices";
|
||||
import type { LedgerEventRow } from "@parking/db";
|
||||
|
||||
// Internal event bus for device-originated events (button presses, etc.).
|
||||
// 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.
|
||||
// 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.
|
||||
export interface DeviceReadEvent {
|
||||
readonly driverId: string;
|
||||
@@ -34,7 +35,7 @@ export interface DeviceReadEvent {
|
||||
export interface ReadOutcome {
|
||||
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||
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";
|
||||
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
||||
readonly reason?: string;
|
||||
@@ -48,6 +49,33 @@ export interface PrinterStatusEvent {
|
||||
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 {
|
||||
emitInput(event: DeviceInputEvent): void {
|
||||
this.emit("input", event);
|
||||
@@ -74,6 +102,32 @@ class DeviceEventBus extends EventEmitter {
|
||||
this.on("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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sessions, type Db, type DeviceRow } from "@parking/db";
|
||||
import { randomInt } from "node:crypto";
|
||||
import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||
import {
|
||||
NoPrinterAvailableError,
|
||||
printWithFailover,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PrinterDevice,
|
||||
type PrinterInstance,
|
||||
type TicketData,
|
||||
type TicketHeader,
|
||||
} from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
@@ -72,9 +73,9 @@ export class EntryFlow {
|
||||
|
||||
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
||||
// 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
|
||||
// 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.
|
||||
const occ = getOccupancy(this.#db);
|
||||
if (occ.full) {
|
||||
@@ -91,7 +92,7 @@ export class EntryFlow {
|
||||
const printers = this.#loadPrinters();
|
||||
|
||||
// 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 {
|
||||
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||
d.printTicket(ticket),
|
||||
@@ -182,9 +183,67 @@ export class EntryFlow {
|
||||
}
|
||||
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 {
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -81,15 +81,34 @@ export function hashEvent(canonical: string): string {
|
||||
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 {
|
||||
readonly #db: Db;
|
||||
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. */
|
||||
#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.#signer = signer;
|
||||
this.#resolveVerifier = resolveVerifier ?? (() => signer);
|
||||
this.#onAppended = onAppended;
|
||||
}
|
||||
|
||||
/** 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));
|
||||
// Keep the chain going even if one append rejects (don't wedge the lock).
|
||||
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 {
|
||||
@@ -146,7 +174,13 @@ export class EventLog {
|
||||
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
|
||||
* first detected break, or { ok: true }. This is what reconciliation and an
|
||||
* 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 } {
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
@@ -159,8 +193,16 @@ export class EventLog {
|
||||
if ((row.prevHash ?? null) !== prevHash) {
|
||||
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);
|
||||
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)" };
|
||||
}
|
||||
prevHash = hashEvent(canonical);
|
||||
|
||||
+318
-17
@@ -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 type { ResolvedRelay } from "./device-resolve.js";
|
||||
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { LedgerPayload } from "@parking/shared";
|
||||
import { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
@@ -30,9 +30,32 @@ interface SessionView {
|
||||
readonly enteredAt: string;
|
||||
readonly open: boolean; // no vehicle_exit yet
|
||||
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
|
||||
// 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 {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
@@ -45,8 +68,189 @@ export class ExitFlow {
|
||||
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 reason = view ? "exit refused — session already closed" : "exit refused — no open session for ticket";
|
||||
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
|
||||
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
|
||||
return { ok: false, status: view ? "closed" : "no_session", 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 reason = !paid
|
||||
? "exit refused — not paid (take payment first)"
|
||||
: "exit refused — walk-back grace expired (top-up required)";
|
||||
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
|
||||
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
|
||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", 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,
|
||||
reason: "free entry-grace (no charge)",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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: "exit recorded, but no exit barrier is configured — open manually" };
|
||||
}
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (!access) {
|
||||
await this.#openFailedAnomaly(id, "exit controller would not build");
|
||||
return { ok: true, opened: false, reason: "exit recorded, but the barrier is unavailable — open manually" };
|
||||
}
|
||||
try {
|
||||
await access.pulseOpen(resolved.relay);
|
||||
} catch (err) {
|
||||
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
|
||||
return { ok: true, opened: false, reason: "exit recorded, but the barrier did not open — open manually" };
|
||||
}
|
||||
|
||||
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: {
|
||||
reason: "manual barrier open (human intervention)",
|
||||
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: "no exit barrier configured — open manually" };
|
||||
}
|
||||
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: "barrier unavailable — open manually" };
|
||||
}
|
||||
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: "barrier did not open — open manually" };
|
||||
}
|
||||
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
|
||||
* 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> {
|
||||
const key = `${e.deviceId}:${e.value}`;
|
||||
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||
@@ -76,6 +280,28 @@ export class ExitFlow {
|
||||
return { accepted: false, direction: "exit", 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,
|
||||
reason: "free entry-grace (no charge)",
|
||||
},
|
||||
});
|
||||
this.#logger.info(`exit free within entry-grace (${e.value})`);
|
||||
return this.#signExitAndOpen(resolved, e);
|
||||
}
|
||||
|
||||
// PAID + within walk-back grace?
|
||||
const paid = view.paidAt != null;
|
||||
const withinGrace =
|
||||
@@ -96,37 +322,73 @@ export class ExitFlow {
|
||||
return { accepted: false, direction: "exit", reason };
|
||||
}
|
||||
|
||||
// Valid: sign the exit BEFORE opening, then open, then update the cache.
|
||||
await this.#log.append({
|
||||
type: "vehicle_exit",
|
||||
direction: "exit",
|
||||
source: e.kind === "plate" ? "lpr" : "ticket",
|
||||
identity: e.value,
|
||||
payload: { sessionRef: e.value },
|
||||
});
|
||||
// Valid (a real payment within walk-back grace): sign + open.
|
||||
return this.#signExitAndOpen(resolved, e);
|
||||
}
|
||||
|
||||
/** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
|
||||
* Shared by the paid-exit and free-entry-grace paths. The caller has already
|
||||
* 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);
|
||||
if (access) await access.pulseOpen(resolved.relay);
|
||||
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" ? { reason: "human-intervention exit (manual barrier open)" } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
|
||||
#fireExitSnapshot(identity: string): void {
|
||||
void snapshotAsync({
|
||||
db: this.#db,
|
||||
direction: "exit",
|
||||
identity: e.value,
|
||||
identity,
|
||||
logger: this.#logger,
|
||||
}).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 {
|
||||
this.#db
|
||||
.update(sessions)
|
||||
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||
.where(eq(sessions.id, e.value))
|
||||
.where(eq(sessions.id, identity))
|
||||
.run();
|
||||
} 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: { reason: "exit signed but barrier 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). */
|
||||
@@ -153,15 +415,54 @@ 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;
|
||||
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure);
|
||||
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 {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
open: !exited,
|
||||
paidAt,
|
||||
subscription,
|
||||
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. */
|
||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
|
||||
@@ -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 type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
@@ -35,6 +35,57 @@ export interface Quote {
|
||||
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 {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
@@ -109,6 +160,192 @@ export class PayStation {
|
||||
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. */
|
||||
#openEntry(identity: string) {
|
||||
const rows = this.#db
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,32 +2,32 @@ import { devices, eq, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.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";
|
||||
|
||||
// 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):
|
||||
// - 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).
|
||||
//
|
||||
// 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
|
||||
// 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"
|
||||
// reader the exit side; "both" defers to the flow's own inference (permit: session
|
||||
// state; transient: exit).
|
||||
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
||||
// session state; transient: exit).
|
||||
|
||||
export class ReadDispatcher {
|
||||
readonly #db: Db;
|
||||
readonly #exit: ExitFlow;
|
||||
readonly #permit: PermitFlow;
|
||||
readonly #subscription: SubscriptionFlow;
|
||||
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.#exit = exit;
|
||||
this.#permit = permit;
|
||||
this.#subscription = subscription;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
@@ -41,13 +41,13 @@ export class ReadDispatcher {
|
||||
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
|
||||
}
|
||||
|
||||
const permit = this.#permit.match(e);
|
||||
if (permit) {
|
||||
return this.#permit.run(resolved, e, permit);
|
||||
const sub = this.#subscription.match(e);
|
||||
if (sub) {
|
||||
return this.#subscription.run(resolved, e, sub);
|
||||
}
|
||||
// Not a permit → transient ticket exit. An ENTRY reader can't produce a transient
|
||||
// exit (transient entry is the button flow, not a reader), so reject+log rather
|
||||
// than treat an entry scan as an exit.
|
||||
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
||||
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
||||
// rather than treat an entry scan as an exit.
|
||||
if (resolved.direction === "entry") {
|
||||
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ interface LoginBody {
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LANGS = ["sq", "en"] as const;
|
||||
type Lang = (typeof LANGS)[number];
|
||||
interface LanguageBody {
|
||||
language: Lang;
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
@@ -41,7 +47,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
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 { id: user.id, username: user.username, role: user.role, language: user.language };
|
||||
});
|
||||
|
||||
app.post("/api/auth/logout", async (_req, reply) => {
|
||||
@@ -49,13 +57,30 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
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(
|
||||
"/api/auth/me",
|
||||
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
|
||||
async (req) => {
|
||||
const { sub, username, role } = req.user;
|
||||
return { id: sub, username, role };
|
||||
const row = await db.select().from(users).where(eq(users.id, sub)).get();
|
||||
return { id: sub, username, role, language: row?.language ?? "sq" };
|
||||
},
|
||||
);
|
||||
|
||||
// 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: requireRole("admin", "operator", "cashier", "readonly") },
|
||||
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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireRole } 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 = requireRole("admin", "operator", "cashier", "readonly");
|
||||
|
||||
app.get("/api/devices/status", { preHandler: guard }, async () => ({
|
||||
devices: monitor.snapshot(),
|
||||
}));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { EventLog } from "../event-log.js";
|
||||
|
||||
@@ -17,12 +17,22 @@ export async function eventRoutes(
|
||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
|
||||
// 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",
|
||||
{ preHandler: guard },
|
||||
async (req) => {
|
||||
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();
|
||||
const rows = db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(since ? gte(ledgerEvents.occurredAt, since) : undefined)
|
||||
.orderBy(desc(ledgerEvents.index))
|
||||
.limit(limit)
|
||||
.all();
|
||||
return { events: rows };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import { NoPrinterAvailableError } from "@parking/devices";
|
||||
import { requireRole } from "../auth.js";
|
||||
import {
|
||||
NoOpenSessionError,
|
||||
NoTariffError,
|
||||
type PayStation,
|
||||
} from "../pay-station.js";
|
||||
import type { ExitFlow } from "../exit-flow.js";
|
||||
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
||||
import { printExitVoucher } from "../booth-print.js";
|
||||
|
||||
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session
|
||||
// then takes payment; the payment becomes a signed ledger event. PCI scope stays
|
||||
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender`
|
||||
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md.
|
||||
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
|
||||
// when the booth is at/near the exit — open the barrier. The payment becomes a
|
||||
// signed ledger event; PCI scope stays OUT of the app (card capture is a standalone
|
||||
// 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 {
|
||||
identity: string;
|
||||
@@ -20,11 +27,97 @@ interface PayBody {
|
||||
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
|
||||
overrideMinor?: number;
|
||||
}
|
||||
interface ExitBody {
|
||||
identity: string;
|
||||
}
|
||||
interface VoucherBody {
|
||||
identity: string;
|
||||
}
|
||||
|
||||
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> {
|
||||
// Cashier/operator/admin operate the pay station; readonly may not.
|
||||
export async function payRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
payStation: PayStation,
|
||||
exitFlow: ExitFlow,
|
||||
shift: ShiftService,
|
||||
): Promise<void> {
|
||||
// Cashier/operator/admin operate the booth; readonly may not.
|
||||
const guard = requireRole("admin", "operator", "cashier");
|
||||
|
||||
// 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: guard }, 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: guard },
|
||||
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.)
|
||||
app.get<{ Querystring: QuoteQuery }>(
|
||||
"/api/pay/quote",
|
||||
@@ -43,7 +136,7 @@ export async function payRoutes(app: FastifyInstance, payStation: PayStation): P
|
||||
// Pay: take payment and append the signed `payment` event.
|
||||
app.post<{ Body: PayBody }>(
|
||||
"/api/pay",
|
||||
{ preHandler: guard },
|
||||
{ preHandler: [guard, requireShift] },
|
||||
async (req, reply) => {
|
||||
const { identity, tender, overrideMinor } = req.body ?? {};
|
||||
if (!identity || (tender !== "cash" && tender !== "card")) {
|
||||
@@ -60,6 +153,35 @@ 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 printExitVoucher(db, identity, 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) {
|
||||
|
||||
@@ -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,6 +2,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import type { DeviceReadEvent } from "../device-events.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
|
||||
// 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,
|
||||
db: Db,
|
||||
dispatcher: ReadDispatcher,
|
||||
capture: CredentialCapture,
|
||||
): Promise<void> {
|
||||
// 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
|
||||
@@ -59,10 +61,19 @@ export async function qrReaderRoutes(
|
||||
// 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
|
||||
// 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;
|
||||
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 = {
|
||||
driverId: "gee-qr-reader",
|
||||
deviceId,
|
||||
@@ -73,10 +84,18 @@ export async function qrReaderRoutes(
|
||||
try {
|
||||
const outcome = await dispatcher.dispatch(read);
|
||||
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) {
|
||||
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).
|
||||
|
||||
+172
-82
@@ -51,6 +51,127 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
||||
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> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
@@ -166,100 +287,69 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const fullConfig: Record<string, unknown> = { ...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 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 outcome = await configureDevice(app, { id, driverId, config, backendIp });
|
||||
if ("error" in outcome) {
|
||||
return reply.code(outcome.error.code).send({ error: outcome.error.message });
|
||||
}
|
||||
|
||||
const row = {
|
||||
id,
|
||||
category,
|
||||
driverId,
|
||||
config: fullConfig,
|
||||
config: outcome.config,
|
||||
enabled: true,
|
||||
};
|
||||
await db.insert(devices).values(row);
|
||||
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
||||
return reply.code(201).send({
|
||||
...row,
|
||||
config: redactSecrets(fullConfig),
|
||||
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
|
||||
config: redactSecrets(outcome.config),
|
||||
...(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 } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireRole } from "../auth.js";
|
||||
import {
|
||||
InvalidCashMovementError,
|
||||
NoOpenShiftError,
|
||||
ShiftAlreadyOpenError,
|
||||
type ShiftService,
|
||||
} from "../shift-service.js";
|
||||
|
||||
interface CashMovementBody {
|
||||
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
|
||||
amountMinor: number;
|
||||
reason?: string;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
// 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
|
||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||
@@ -14,13 +22,43 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
||||
// Cashier/operator/admin run shifts; readonly can't.
|
||||
const guard = requireRole("admin", "operator", "cashier");
|
||||
|
||||
// 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
|
||||
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
|
||||
// someone else's shift → disabled. Also returns the live drawer balance.
|
||||
// - open: the open shift { startedAt, operator } or null (site-wide)
|
||||
// - isMine: true iff the open shift belongs to the requesting operator
|
||||
// - operator: the requesting user (for the UI's own identity)
|
||||
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
|
||||
const operator = req.user.username;
|
||||
const open = shift.openShiftFor(operator);
|
||||
return { operator, open: open ? { startedAt: open.occurredAt } : null };
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
// 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: requireRole("admin") },
|
||||
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) => {
|
||||
try {
|
||||
return await shift.open(req.user.username);
|
||||
|
||||
@@ -7,9 +7,49 @@ import { getOccupancy } from "../occupancy.js";
|
||||
// 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.
|
||||
|
||||
interface SiteConfigBody {
|
||||
// Optional park-metadata text fields (all nullable). Trimmed; "" → null.
|
||||
const TEXT_FIELDS = [
|
||||
"parkName",
|
||||
"operatorName",
|
||||
"nius",
|
||||
"address",
|
||||
"phone",
|
||||
"email",
|
||||
] as const;
|
||||
type TextField = (typeof TEXT_FIELDS)[number];
|
||||
|
||||
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||
/** 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;
|
||||
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> {
|
||||
@@ -19,25 +59,50 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||
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 () => {
|
||||
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) => {
|
||||
const { capacity } = req.body ?? ({} as SiteConfigBody);
|
||||
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
|
||||
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
|
||||
const body = req.body ?? ({} as SiteConfigBody);
|
||||
|
||||
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" });
|
||||
}
|
||||
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 updatedAt = new Date().toISOString();
|
||||
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 {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
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 { requireRole } from "../auth.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> {
|
||||
// Admin manages subscriptions; 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: 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: writeGuard }, 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: writeGuard },
|
||||
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);
|
||||
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: writeGuard },
|
||||
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: writeGuard },
|
||||
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();
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import type { Role } from "@parking/shared";
|
||||
import { deviceEvents } from "../device-events.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.
|
||||
|
||||
/** Roles allowed to watch the live feed (everyone signed in; readonly included —
|
||||
* it's a read-only stream). */
|
||||
const WATCH_ROLES: Role[] = ["admin", "operator", "cashier", "readonly"];
|
||||
|
||||
/**
|
||||
* 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 || !WATCH_ROLES.includes(req.user.role)) {
|
||||
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) => {
|
||||
send({ kind: "ledger", event, 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();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
+56
-18
@@ -1,5 +1,6 @@
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import websocket from "@fastify/websocket";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
@@ -9,16 +10,18 @@ import { EntryFlow } from "./entry-flow.js";
|
||||
import { EventLog } from "./event-log.js";
|
||||
import { ExitFlow } from "./exit-flow.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 { ReadDispatcher } from "./read-dispatch.js";
|
||||
import { CredentialCapture } from "./credential-capture.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 { deviceRoutes } from "./routes/devices.js";
|
||||
import { eventRoutes } from "./routes/events.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 { shiftRoutes } from "./routes/shift.js";
|
||||
import { siteRoutes } from "./routes/site.js";
|
||||
@@ -26,6 +29,8 @@ import { snapshotRoutes } from "./routes/snapshots.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
import { printerRoutes } from "./routes/printers.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
|
||||
// plugins emitting onto a shared internal event bus; auth is fully local
|
||||
@@ -44,6 +49,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
|
||||
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.
|
||||
// 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
|
||||
@@ -80,15 +89,32 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
app.addHook("onReady", async () => printerMonitor.start());
|
||||
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
|
||||
// (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
|
||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||
// 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);
|
||||
|
||||
// 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.
|
||||
await snapshotRoutes(app, db);
|
||||
|
||||
@@ -103,36 +129,48 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
app.addHook("onClose", async () => unsubscribeEntry());
|
||||
|
||||
// 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
|
||||
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md.
|
||||
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||
// 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 permitFlow = new PermitFlow(db, eventLog, app.log);
|
||||
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log);
|
||||
const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log);
|
||||
const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
|
||||
const unsubscribeRead = deviceEvents.onRead((e) => {
|
||||
void readDispatcher.dispatch(e);
|
||||
});
|
||||
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
|
||||
// verdict (host-in-the-loop, synchronous). Routes the read through the dispatcher
|
||||
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||
await qrReaderRoutes(app, db, readDispatcher);
|
||||
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
||||
// on an armed reader for enrollment; otherwise the read routes through the
|
||||
// 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 +
|
||||
// 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);
|
||||
await payRoutes(app, payStation);
|
||||
await payRoutes(app, db, payStation, exitFlow, shiftService);
|
||||
|
||||
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||
await tariffRoutes(app, db);
|
||||
|
||||
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||
await permitRoutes(app, db);
|
||||
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
||||
// wiki/entities/subscription.md.
|
||||
await subscriptionRoutes(app, db, credentialCapture);
|
||||
|
||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
|
||||
const shiftService = new ShiftService(db, eventLog, app.log);
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
|
||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||
|
||||
@@ -11,9 +11,16 @@ import type { EventLog } from "./event-log.js";
|
||||
// See wiki/concepts/shift.md.
|
||||
|
||||
export class ShiftAlreadyOpenError extends Error {
|
||||
constructor(operator: string) {
|
||||
super(`operator ${operator} already has an open shift`);
|
||||
/** The operator who currently holds the open shift (may be someone else). */
|
||||
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.heldBy = heldBy;
|
||||
}
|
||||
}
|
||||
export class NoOpenShiftError extends Error {
|
||||
@@ -22,6 +29,14 @@ export class NoOpenShiftError extends Error {
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShiftReport {
|
||||
readonly operator: string;
|
||||
@@ -31,9 +46,25 @@ export interface ShiftReport {
|
||||
readonly cardTotalMinor: number;
|
||||
readonly currency: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
export class InvalidCashMovementError extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
this.name = "InvalidCashMovementError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ShiftService {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
@@ -45,6 +76,12 @@ export class ShiftService {
|
||||
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. */
|
||||
openShiftFor(operator: string) {
|
||||
// Scan shift events for this operator; the shift is open if the most recent
|
||||
@@ -60,19 +97,116 @@ export class ShiftService {
|
||||
return last && last.type === "shift_open" ? last : null;
|
||||
}
|
||||
|
||||
/** Open a shift for the operator (explicit start). */
|
||||
async open(operator: string): Promise<{ startedAt: string }> {
|
||||
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator);
|
||||
/**
|
||||
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** 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 { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||||
await this.#log.append({
|
||||
type: "shift_open",
|
||||
source: "manual",
|
||||
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,
|
||||
});
|
||||
this.#logger.info(`shift opened for ${operator}`);
|
||||
return { startedAt };
|
||||
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||
return { startedAt, openingFloatMinor };
|
||||
}
|
||||
|
||||
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
||||
@@ -102,6 +236,50 @@ export class ShiftService {
|
||||
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({
|
||||
type: "shift_z_report",
|
||||
source: "manual",
|
||||
@@ -114,23 +292,20 @@ export class ShiftService {
|
||||
cardTotalMinor,
|
||||
currency: currency ?? undefined,
|
||||
paymentCount: payments.length,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
expectedDrawerMinor,
|
||||
},
|
||||
});
|
||||
|
||||
const printed = await this.#printZReport({
|
||||
operator,
|
||||
startedAt,
|
||||
endedAt,
|
||||
cashTotalMinor,
|
||||
cardTotalMinor,
|
||||
currency,
|
||||
paymentCount: payments.length,
|
||||
});
|
||||
const printed = await this.#printZReport(report);
|
||||
|
||||
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
|
||||
@@ -151,6 +326,13 @@ export class ShiftService {
|
||||
`Payments: ${r.paymentCount}`,
|
||||
`Cash: ${money(r.cashTotalMinor)} ${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 {
|
||||
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||
interface SnapshotJob {
|
||||
readonly db: Db;
|
||||
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 logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
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 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: "subscription not found" };
|
||||
|
||||
// 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 = `subscription ${sub.status}/out-of-window`;
|
||||
await this.#reject(m, reason);
|
||||
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 = "subscription exit with no open session (already out / never entered)";
|
||||
await this.#reject(m, reason);
|
||||
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 = `subscription at capacity (${open.length}/${sub.maxConcurrent} cars in)`;
|
||||
await this.#reject(m, reason);
|
||||
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;
|
||||
}
|
||||
|
||||
async #reject(m: SubscriptionMatch, reason: string): Promise<void> {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: m.carKey,
|
||||
// `permitId`/`permitRefused` are the on-chain field names (immutable).
|
||||
payload: { reason: `subscription refused — ${reason}`, permitId: m.subscriptionId, permitRefused: true },
|
||||
});
|
||||
this.#logger.warn(`subscription 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(`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
@@ -12,13 +12,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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-dom": "19.2.7"
|
||||
"react-dom": "19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.0.16"
|
||||
}
|
||||
|
||||
@@ -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="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
|
||||
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>
|
||||
);
|
||||
}
|
||||
+29
-39
@@ -1,15 +1,16 @@
|
||||
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 { PermitManager } from "./PermitManager.js";
|
||||
import { SetupWizard } from "./SetupWizard.js";
|
||||
import { ShiftControl } from "./ShiftControl.js";
|
||||
import { SiteSettings } from "./SiteSettings.js";
|
||||
import { TariffComposer } from "./TariffComposer.js";
|
||||
import { queryClient } from "./lib/query.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { router } from "./router.js";
|
||||
|
||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
||||
// simple enough that a framework's abstractions cost more than they save.
|
||||
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me.
|
||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||
// off to TanStack Router inside the QueryClient provider. The router renders the
|
||||
// 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.
|
||||
|
||||
export function App() {
|
||||
@@ -22,37 +23,26 @@ export function App() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>;
|
||||
if (!user) return <Login onLoggedIn={setUser} />;
|
||||
// Apply the signed-in user's preferred language whenever it resolves/changes
|
||||
// (login, bootstrap, or a toggle). Albanian is the default before auth resolves.
|
||||
useEffect(() => {
|
||||
if (user) setLanguage(user.language);
|
||||
}, [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 (
|
||||
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
|
||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<h1 style={{ margin: 0 }}>Parking System</h1>
|
||||
<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>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} context={{ user, setUser }} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
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,
|
||||
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 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 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) {
|
||||
const r = await printVoucher(identity);
|
||||
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
||||
} else {
|
||||
const r = await boothExit(identity);
|
||||
setResult(
|
||||
r.opened
|
||||
? t("pay.paidBarrierOpened")
|
||||
: t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }),
|
||||
);
|
||||
}
|
||||
// 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="mt-2 rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{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={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
|
||||
tender === tn
|
||||
? "border-term-amber text-term-amber"
|
||||
: "border-term-border text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
{t(`pay.${tn}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voucher checkbox (default from site config) */}
|
||||
<label className="flex items-center gap-2 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
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" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
|
||||
>
|
||||
{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="rounded-term border border-term-cyan bg-term-cyan/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-cyan disabled:opacity-50"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.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";
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ e }: { e: LedgerEvent }) {
|
||||
const { t } = useTranslation();
|
||||
const style = EVENT_STYLE[e.type];
|
||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-term-border/50 py-1 text-[12px] tabular-nums">
|
||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`w-20 shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||
<span className="truncate text-term-text">{e.identity ?? "—"}</span>
|
||||
<span className="ml-auto text-term-muted">#{e.index}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"
|
||||
>
|
||||
{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);
|
||||
|
||||
// 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} />)
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { login, type SessionUser } from "./api.js";
|
||||
|
||||
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -22,11 +24,11 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
|
||||
<h1>Parking System</h1>
|
||||
<h1>{t("auth.title")}</h1>
|
||||
<form onSubmit={submit}>
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<label>
|
||||
Username
|
||||
{t("auth.username")}
|
||||
<br />
|
||||
<input
|
||||
value={username}
|
||||
@@ -39,7 +41,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
||||
</div>
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<label>
|
||||
Password
|
||||
{t("auth.password")}
|
||||
<br />
|
||||
<input
|
||||
type="password"
|
||||
@@ -52,7 +54,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
||||
</div>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<button type="submit" disabled={busy || !username || !password}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
assignDevice,
|
||||
editDevice,
|
||||
discoverDevices,
|
||||
fetchBackendIps,
|
||||
fetchCatalog,
|
||||
@@ -127,8 +128,12 @@ function CategorySection({
|
||||
onChanged: () => Promise<void> | void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const showForm = adding || assignments.length === 0;
|
||||
const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined;
|
||||
// Show the add form for an empty category or an explicit "+ Add", but not while
|
||||
// editing an existing row (that row renders its own inline form).
|
||||
const showForm = !editing && (adding || assignments.length === 0);
|
||||
|
||||
// Binding categories need a controller to point at first.
|
||||
const isBound = category !== "access";
|
||||
@@ -162,15 +167,43 @@ function CategorySection({
|
||||
|
||||
{assignments.length > 0 && (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||
{assignments.map((a) => (
|
||||
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
|
||||
))}
|
||||
{assignments.map((a) =>
|
||||
editingId === a.id ? (
|
||||
<li key={a.id} style={{ listStyle: "none", padding: 0 }}>
|
||||
<DeviceForm
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
editing={a}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</li>
|
||||
) : (
|
||||
<AssignmentRow
|
||||
key={a.id}
|
||||
assignment={a}
|
||||
controllers={controllers}
|
||||
onChanged={onChanged}
|
||||
onEdit={() => {
|
||||
setAdding(false);
|
||||
setEditingId(a.id);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{blockedNoController ? (
|
||||
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
||||
) : showForm ? (
|
||||
) : editing ? null : showForm ? (
|
||||
<DeviceForm
|
||||
category={category}
|
||||
entries={entries}
|
||||
@@ -197,10 +230,12 @@ function AssignmentRow({
|
||||
assignment,
|
||||
controllers,
|
||||
onChanged,
|
||||
onEdit,
|
||||
}: {
|
||||
assignment: Assignment;
|
||||
controllers: Assignment[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -237,6 +272,9 @@ function AssignmentRow({
|
||||
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
||||
<button type="button" onClick={onEdit} disabled={removing}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={remove} disabled={removing}>
|
||||
{removing ? "Removing…" : "Remove"}
|
||||
</button>
|
||||
@@ -280,6 +318,7 @@ function DeviceForm({
|
||||
discoverableIds,
|
||||
pushCapableIds,
|
||||
controllers,
|
||||
editing,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
@@ -288,21 +327,42 @@ function DeviceForm({
|
||||
discoverableIds: string[];
|
||||
pushCapableIds: string[];
|
||||
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;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
// 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 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 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).
|
||||
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.
|
||||
const [controllerId, setControllerId] = useState<string>("");
|
||||
const [boundRelay, setBoundRelay] = useState<number | "">("");
|
||||
const [controllerId, setControllerId] = useState<string>(
|
||||
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 [testing, setTesting] = useState(false);
|
||||
@@ -420,12 +480,17 @@ function DeviceForm({
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await assignDevice({
|
||||
category,
|
||||
driverId: selected.id,
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
});
|
||||
const result = editing
|
||||
? await editDevice(editing.id, {
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
})
|
||||
: await assignDevice({
|
||||
category,
|
||||
driverId: selected.id,
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
});
|
||||
await onSaved(result.warnings ?? []);
|
||||
} catch (e) {
|
||||
setSaveError((e as Error).message);
|
||||
@@ -439,7 +504,9 @@ function DeviceForm({
|
||||
{entries.length === 0 ? (
|
||||
<em>No drivers registered.</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 value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
|
||||
<option value="" disabled>
|
||||
Choose a device…
|
||||
</option>
|
||||
@@ -538,7 +605,7 @@ function DeviceForm({
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save & configure"}
|
||||
{saving ? "Saving…" : editing ? "Save changes" : "Save & configure"}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel} disabled={saving}>
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
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
|
||||
// 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();
|
||||
|
||||
export function ShiftControl() {
|
||||
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
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 [report, setReport] = useState<ShiftReport | 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()
|
||||
.then((s) => setStartedAt(s.open?.startedAt ?? null))
|
||||
.then((s) => {
|
||||
setStartedAt(s.open?.startedAt ?? null);
|
||||
setDrawerMinor(s.drawerMinor);
|
||||
setCurrency(s.currency);
|
||||
})
|
||||
.catch(() => {
|
||||
/* readonly / not permitted — hide control */
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
useEffect(refresh, []);
|
||||
|
||||
async function start() {
|
||||
setBusy(true);
|
||||
@@ -28,6 +44,7 @@ export function ShiftControl() {
|
||||
try {
|
||||
const { startedAt } = await openShift();
|
||||
setStartedAt(startedAt);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
@@ -41,6 +58,7 @@ export function ShiftControl() {
|
||||
const z = await closeShift();
|
||||
setReport(z);
|
||||
setStartedAt(null);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
@@ -48,33 +66,96 @@ 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 (
|
||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||
<strong>Shift:</strong>{" "}
|
||||
<strong>{t("shift.label")}</strong>{" "}
|
||||
{startedAt ? (
|
||||
<>
|
||||
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "}
|
||||
<span style={{ color: "#16a34a" }}>{t("shift.open")}</span> {t("shift.since")}{" "}
|
||||
{new Date(startedAt).toLocaleString()}{" "}
|
||||
<button type="button" onClick={end} disabled={busy}>
|
||||
{busy ? "Ending…" : "End shift"}
|
||||
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: "#777" }}>not started</span>{" "}
|
||||
<span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
|
||||
<button type="button" onClick={start} disabled={busy}>
|
||||
{busy ? "Starting…" : "Start shift"}
|
||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* Live drawer balance (what's in the till right now / inherited). */}
|
||||
{drawerMinor != null && (
|
||||
<div style={{ marginTop: "0.5rem", color: "#555" }}>
|
||||
{t("shift.drawer")} <strong>{money(drawerMinor, currency)}</strong>
|
||||
{startedAt && <span style={{ color: "#888" }}> {t("shift.openingFloatInherited")}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
|
||||
|
||||
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
|
||||
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
|
||||
{t("shift.drawerCashAdmin")}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input
|
||||
value={moveAmount}
|
||||
onChange={(e) => setMoveAmount(e.target.value)}
|
||||
placeholder={t("shift.amount")}
|
||||
inputMode="decimal"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<input
|
||||
value={moveReason}
|
||||
onChange={(e) => setMoveReason(e.target.value)}
|
||||
placeholder={t("shift.reasonPlaceholder")}
|
||||
style={{ flex: 1, minWidth: 140 }}
|
||||
/>
|
||||
<button type="button" onClick={() => move(1)}>{t("shift.load")}</button>
|
||||
<button type="button" onClick={() => move(-1)}>{t("shift.remove")}</button>
|
||||
</div>
|
||||
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
|
||||
<div>Payments: {report.paymentCount}</div>
|
||||
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
|
||||
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}>
|
||||
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
|
||||
<div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
|
||||
<div>{t("shift.payments")} {report.paymentCount}</div>
|
||||
<div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
|
||||
<div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
|
||||
<div>{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
|
||||
<div>{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
|
||||
<div>{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
|
||||
</div>
|
||||
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
|
||||
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
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
|
||||
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse
|
||||
// transient entry at capacity) is enforced server-side in the entry flow.
|
||||
// See wiki/concepts/capacity-occupancy.md.
|
||||
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
|
||||
// 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 }) {
|
||||
const { t } = useTranslation();
|
||||
const [occ, setOcc] = useState<Occupancy | null>(null);
|
||||
const [capInput, setCapInput] = useState("");
|
||||
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
function reload() {
|
||||
@@ -17,18 +33,29 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
useEffect(() => {
|
||||
reload();
|
||||
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(() => {});
|
||||
}, []);
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
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 {
|
||||
await setCapacity(capacity);
|
||||
await saveSiteConfig(patch);
|
||||
reload();
|
||||
setMsg("Capacity saved.");
|
||||
setMsg(t("site.saved"));
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
@@ -36,28 +63,61 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||
<strong>Occupancy:</strong>{" "}
|
||||
<strong>{t("site.occupancy")}</strong>{" "}
|
||||
{occ == null ? (
|
||||
"…"
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600 }}>{occ.count}</span>
|
||||
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"}
|
||||
{occ.capacity != null ? ` / ${occ.capacity}` : ` ${t("site.noCapacitySet")}`}
|
||||
{occ.capacity != null && (
|
||||
<span style={{ color: "#666" }}> · {occ.free} free</span>
|
||||
<span style={{ color: "#666" }}> · {occ.free} {t("site.free")}</span>
|
||||
)}
|
||||
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "}
|
||||
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>{t("site.full")}</span>}{" "}
|
||||
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div style={{ marginTop: "0.6rem" }}>
|
||||
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
|
||||
<label>
|
||||
Capacity (blank = no limit):{" "}
|
||||
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
|
||||
</label>{" "}
|
||||
<button type="button" onClick={save}>Save</button>
|
||||
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
||||
{t("site.capacityLabel")}{" "}
|
||||
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exitVoucherDefault}
|
||||
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
||||
/>
|
||||
{t("site.printExitDefault")}
|
||||
<span style={{ color: "#888", fontSize: "0.8rem" }}>{t("site.printExitHint")}</span>
|
||||
</label>
|
||||
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
|
||||
{t("site.parkDetails")}
|
||||
</div>
|
||||
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
|
||||
{t(labelKey)}
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={meta[key] ?? ""}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder={phKey ? t(phKey) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={meta[key] ?? ""}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||
placeholder={phKey ? t(phKey) : undefined}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<div>
|
||||
<button type="button" onClick={save}>{t("site.save")}</button>
|
||||
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
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";
|
||||
|
||||
// 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 style={{ marginTop: "2rem" }}>
|
||||
<h2>{t("subs.title")}</h2>
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{subs.map((s) => (
|
||||
<li key={s.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
|
||||
<strong>{s.holderName ?? t("subs.unnamed")}</strong>
|
||||
<span style={{ color: s.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[s.status])}</span>
|
||||
<span style={{ color: "#0a7", fontVariantNumeric: "tabular-nums" }}>{priceLabel(s, t)}</span>
|
||||
<span style={{ color: "#666" }}>
|
||||
{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 style={{ flex: 1 }} />
|
||||
{/* Print code — only when the subscription has a QR credential to encode. */}
|
||||
{s.credentials.some((c) => c.kind === "qr") && (
|
||||
<button type="button" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
|
||||
)}
|
||||
<button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
|
||||
{s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
|
||||
<button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
|
||||
</li>
|
||||
))}
|
||||
{subs.length === 0 && <li style={{ color: "#777" }}>{t("subs.noneYet")}</li>}
|
||||
</ul>
|
||||
|
||||
{editing == null ? (
|
||||
<button type="button" onClick={startNew}>{t("subs.add")}</button>
|
||||
) : (
|
||||
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
||||
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("subs.new") : t("subs.editTitle")}</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
||||
<label>{t("subs.holderName")}</label>
|
||||
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||
<label>{t("subs.contact")}</label>
|
||||
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||
<label>{t("subs.monthlyPrice")}</label>
|
||||
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
<input
|
||||
value={form.priceMajor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
|
||||
inputMode="decimal"
|
||||
placeholder={t("subs.pricePlaceholder")}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
<input value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} />
|
||||
<span style={{ color: "#888" }}>/ {t("subs.perMonth")}</span>
|
||||
</span>
|
||||
<label>{t("subs.carLimit")}</label>
|
||||
<span>
|
||||
<label style={{ marginRight: "0.5rem" }}>
|
||||
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
||||
</label>
|
||||
{form.carBound && (
|
||||
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
||||
)}
|
||||
</span>
|
||||
<label>{t("subs.validFrom")}</label>
|
||||
<input type="date" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
|
||||
<label>{t("subs.months")}</label>
|
||||
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input
|
||||
value={form.months}
|
||||
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
placeholder="1"
|
||||
style={{ width: 50 }}
|
||||
/>
|
||||
<span style={{ color: "#888" }}>{t("subs.monthsHint")}</span>
|
||||
{/* Live preview of the coverage end + the N×price total. */}
|
||||
{coverageHint && <span style={{ color: "#0a7" }}>{coverageHint}</span>}
|
||||
</span>
|
||||
<label>{t("subs.validToOverride")}</label>
|
||||
<input type="date" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
|
||||
<label>{t("subs.boundPlates")}</label>
|
||||
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||
</div>
|
||||
|
||||
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||
(read off a card via "Read card"). */}
|
||||
<select 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 value={c.value} readOnly style={{ flex: 1, fontFamily: "ui-monospace, monospace", background: "#f6f6f6" }} />
|
||||
) : (
|
||||
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{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 value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} />
|
||||
)}
|
||||
{c.kind === "rf" && (
|
||||
<button type="button" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
|
||||
)}
|
||||
<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: "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 style={{ marginTop: "0.5rem", padding: "0.6rem 0.75rem", border: "1px solid #0a7", borderRadius: 6, background: "#f0fbf6" }}>
|
||||
{capture.phase === "pick" ? (
|
||||
<>
|
||||
<div style={{ marginBottom: "0.35rem" }}>{t("subs.captureChooseReader")}</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||||
{readers.length === 0 && <span style={{ color: "#a00" }}>{t("subs.captureNoReaders")}</span>}
|
||||
{readers.map((r) => (
|
||||
<button key={r.id} type="button" onClick={() => pickReader(r.id)}>
|
||||
{t(`devices.role.${r.direction}`)} ({r.driverId})
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<span>{capture.status ?? t("subs.captureWaiting")}</span>
|
||||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||
{t("subs.needCredentialOrPlate")}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||
<button type="button" onClick={save}>{t("subs.save")}</button>
|
||||
<button type="button" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
fetchTariff,
|
||||
@@ -78,6 +79,7 @@ function toStructure(f: FormState): TariffStructure {
|
||||
}
|
||||
|
||||
export function TariffComposer() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<TariffState | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -112,7 +114,7 @@ export function TariffComposer() {
|
||||
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
||||
const fresh = await fetchTariff();
|
||||
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) {
|
||||
const text =
|
||||
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
||||
@@ -126,44 +128,40 @@ export function TariffComposer() {
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Tariff</h2>
|
||||
<h2>{t("tariff.title")}</h2>
|
||||
{!state?.active ? (
|
||||
<p style={{ color: "#b45309" }}>
|
||||
No rate card published yet — the pay station can't charge until you publish one.
|
||||
</p>
|
||||
<p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
|
||||
) : (
|
||||
<p style={{ color: "#555" }}>
|
||||
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "}
|
||||
{state.versions.length} version(s) in history. Publishing creates a new version; past
|
||||
sessions keep their original pricing.
|
||||
{t("tariff.activeSince", {
|
||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||
count: state.versions.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
|
||||
<label>Currency</label>
|
||||
<label>{t("tariff.currency")}</label>
|
||||
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
|
||||
<label>Free entry grace (min)</label>
|
||||
<label>{t("tariff.freeEntryGrace")}</label>
|
||||
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label>Billing increment (min)</label>
|
||||
<label>{t("tariff.billingIncrement")}</label>
|
||||
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label>Daily cap (blank = none)</label>
|
||||
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
|
||||
<label>Lost-ticket fee</label>
|
||||
<label>{t("tariff.dailyCap")}</label>
|
||||
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||
<label>{t("tariff.lostTicketFee")}</label>
|
||||
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label>Exit walk-back grace (min)</label>
|
||||
<label>{t("tariff.exitGrace")}</label>
|
||||
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
|
||||
Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last
|
||||
block's bound blank for "thereafter". Price is per billing increment.
|
||||
</p>
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.upToMin")}</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -174,7 +172,7 @@ export function TariffComposer() {
|
||||
<input
|
||||
value={b.uptoMin}
|
||||
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
|
||||
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"}
|
||||
placeholder={i === form.blocks.length - 1 ? t("tariff.thereafter") : t("tariff.egExample")}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
</td>
|
||||
@@ -183,7 +181,7 @@ export function TariffComposer() {
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
|
||||
Remove
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -191,12 +189,12 @@ export function TariffComposer() {
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||
+ Add block
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<button type="button" onClick={publish} disabled={saving}>
|
||||
{saving ? "Publishing…" : "Publish new version"}
|
||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||
</button>
|
||||
</div>
|
||||
{msg && (
|
||||
|
||||
+285
-21
@@ -45,10 +45,13 @@ export class ApiError extends Error {
|
||||
// --- Auth -----------------------------------------------------------------
|
||||
|
||||
export type Role = "admin" | "operator" | "cashier" | "readonly";
|
||||
export type Lang = "sq" | "en";
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
/** Preferred UI language (loaded from the server on login). */
|
||||
language: Lang;
|
||||
}
|
||||
|
||||
export function login(username: string, password: string): Promise<SessionUser> {
|
||||
@@ -62,6 +65,11 @@ export function logout(): Promise<{ ok: boolean }> {
|
||||
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 }) });
|
||||
}
|
||||
|
||||
/** Returns the current user, or null if not authenticated. */
|
||||
export async function fetchMe(): Promise<SessionUser | null> {
|
||||
try {
|
||||
@@ -186,6 +194,15 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||||
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). */
|
||||
export interface Assignment {
|
||||
id: string;
|
||||
@@ -260,48 +277,114 @@ export function publishTariffVersion(body: {
|
||||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
// --- Permits --------------------------------------------------------------
|
||||
// --- Subscriptions --------------------------------------------------------
|
||||
|
||||
export interface PermitCredential {
|
||||
export interface SubscriptionCredential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
export interface Permit {
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
holderName: 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;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
status: "active" | "suspended" | "revoked";
|
||||
credentials: PermitCredential[];
|
||||
credentials: SubscriptionCredential[];
|
||||
plates: string[];
|
||||
}
|
||||
export type PermitInput = Omit<Permit, "id" | "status"> & {
|
||||
status?: Permit["status"];
|
||||
/** A credential as SENT to the server: a QR value may be omitted/blank → the server
|
||||
* 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[] }> {
|
||||
return apiFetch("/api/permits");
|
||||
/** The create response = the saved subscription + the auto-print outcome. */
|
||||
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> {
|
||||
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
|
||||
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
|
||||
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
|
||||
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
/** Re-print a subscription's QR card (failed auto-print / lost card). */
|
||||
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> {
|
||||
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
||||
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 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 ---------------------------------------------------------------
|
||||
|
||||
export interface ShiftStatus {
|
||||
/** The requesting (logged-in) operator. */
|
||||
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 {
|
||||
operator: string;
|
||||
@@ -311,19 +394,35 @@ export interface ShiftReport {
|
||||
cardTotalMinor: number;
|
||||
currency: string | null;
|
||||
paymentCount: number;
|
||||
// Drawer (carries across shifts).
|
||||
openingFloatMinor: number;
|
||||
cashAddedMinor: number;
|
||||
cashRemovedMinor: number;
|
||||
expectedDrawerMinor: number;
|
||||
printed: boolean;
|
||||
}
|
||||
|
||||
export function fetchShift(): Promise<ShiftStatus> {
|
||||
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" });
|
||||
}
|
||||
export function closeShift(): Promise<ShiftReport> {
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Site config / occupancy ----------------------------------------------
|
||||
|
||||
export interface Occupancy {
|
||||
@@ -333,12 +432,177 @@ export interface Occupancy {
|
||||
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;
|
||||
}
|
||||
|
||||
export function fetchOccupancy(): Promise<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) 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 }) });
|
||||
}
|
||||
|
||||
// --- Snapshots (entry/exit evidence images) -------------------------------
|
||||
|
||||
export interface SnapshotMeta {
|
||||
id: string;
|
||||
direction: "entry" | "exit" | null;
|
||||
deviceId: string;
|
||||
identity: string;
|
||||
contentType: string;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
/** Snapshot metadata for a session identity (newest first). Image bytes are at
|
||||
* `/api/snapshots/:id` — use that URL directly as an <img src>. */
|
||||
export function fetchSnapshots(identity: string): Promise<{ snapshots: SnapshotMeta[] }> {
|
||||
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");
|
||||
}
|
||||
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> {
|
||||
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) });
|
||||
/** PUT a partial config — only the fields supplied are changed. */
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
@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. */
|
||||
@theme {
|
||||
/* Surfaces — near-black, layered greys for panels/borders. */
|
||||
--color-term-bg: #0a0e12;
|
||||
--color-term-panel: #11161c;
|
||||
--color-term-panel-2: #161d25;
|
||||
--color-term-border: #232c37;
|
||||
--color-term-muted: #6b7785;
|
||||
--color-term-text: #c9d3de;
|
||||
|
||||
/* Status accents — the terminal's signal colours. */
|
||||
--color-term-amber: #f5a623; /* primary accent / headings / focus */
|
||||
--color-term-green: #2ecc71; /* entry / ok / free */
|
||||
--color-term-red: #ff4d4f; /* exit / fault / full */
|
||||
--color-term-cyan: #38bdf8; /* payment / info */
|
||||
|
||||
/* Monospace stack — IBM Plex Mono / JetBrains first, system mono fallback. */
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
|
||||
"Menlo", "Consolas", monospace;
|
||||
|
||||
/* Tight radius — terminals are square. */
|
||||
--radius-term: 2px;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// 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: "—",
|
||||
},
|
||||
auth: {
|
||||
title: "Parking System",
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
signIn: "Sign in",
|
||||
signingIn: "Signing in…",
|
||||
},
|
||||
nav: {
|
||||
booth: "Booth",
|
||||
shift: "Shift",
|
||||
setup: "Setup",
|
||||
tariff: "Tariff",
|
||||
subscriptions: "Subscriptions",
|
||||
site: "Site",
|
||||
},
|
||||
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: "mixed",
|
||||
lane: "lane",
|
||||
booth: "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",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tariff",
|
||||
noRateCard: "No rate card 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: "Consumed in order as time accrues. \"Up to (min)\" is the block's upper bound; leave the last block's bound blank for \"thereafter\". Price is per billing increment.",
|
||||
upToMin: "Up to (min)",
|
||||
pricePerIncrement: "Price / increment",
|
||||
thereafter: "thereafter",
|
||||
egExample: "e.g. 60",
|
||||
remove: "Remove",
|
||||
addBlock: "+ Add block",
|
||||
publishNewVersion: "Publish new version",
|
||||
publishing: "Publishing…",
|
||||
publishedOk: "New tariff version published — it's now the active rate card.",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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…",
|
||||
},
|
||||
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.",
|
||||
noSnapshots: "no snapshots",
|
||||
loadingSnapshots: "loading snapshots…",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,297 @@
|
||||
// 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: "—",
|
||||
},
|
||||
auth: {
|
||||
title: "Sistemi i Parkimit",
|
||||
username: "Përdoruesi",
|
||||
password: "Fjalëkalimi",
|
||||
signIn: "Hyr",
|
||||
signingIn: "Duke hyrë…",
|
||||
},
|
||||
nav: {
|
||||
booth: "Kabina",
|
||||
shift: "Turni",
|
||||
setup: "Konfigurimi",
|
||||
tariff: "Tarifa",
|
||||
subscriptions: "Abonimet",
|
||||
site: "Vendi",
|
||||
},
|
||||
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: "i përzier",
|
||||
lane: "korsia",
|
||||
booth: "kabina",
|
||||
},
|
||||
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: "ngjarje",
|
||||
noEventsYet: "Asnjë ngjarje 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",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tarifa",
|
||||
noRateCard: "Asnjë kartë tarifore 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: "Konsumohen me radhë me kalimin e kohës. \"Deri në (min)\" është kufiri i sipërm i bllokut; lëre bosh kufirin e bllokut të fundit për \"më pas\". Çmimi është për interval faturimi.",
|
||||
upToMin: "Deri në (min)",
|
||||
pricePerIncrement: "Çmimi / interval",
|
||||
thereafter: "më pas",
|
||||
egExample: "p.sh. 60",
|
||||
remove: "Hiq",
|
||||
addBlock: "+ Shto bllok",
|
||||
publishNewVersion: "Publiko version të ri",
|
||||
publishing: "Duke publikuar…",
|
||||
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
|
||||
},
|
||||
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ë/etiketë RF",
|
||||
rfCardTagSoon: "Kartë/etiketë 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}}? (Ngjarjet 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",
|
||||
},
|
||||
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…",
|
||||
},
|
||||
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.",
|
||||
// snapshots
|
||||
noSnapshots: "asnjë foto",
|
||||
loadingSnapshots: "duke ngarkuar fotot…",
|
||||
},
|
||||
};
|
||||
|
||||
// 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>;
|
||||
@@ -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: {} }),
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}, []);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { StrictMode } from "react";
|
||||
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";
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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, SessionUser } from "./api.js";
|
||||
import { closeShift, logout, openShift, setLanguagePref } from "./api.js";
|
||||
import { qk, queryClient } from "./lib/query.js";
|
||||
import { setLanguage } from "./lib/i18n/index.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";
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
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")} />
|
||||
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
|
||||
{isAdmin && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
|
||||
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
<span className="text-[11px] text-term-muted">
|
||||
{user?.username} · {user?.role}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-term border border-term-border px-2 py-0.5 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text"
|
||||
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,
|
||||
});
|
||||
|
||||
const shiftRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/shift",
|
||||
component: function ShiftRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <ShiftControl isAdmin={user?.role === "admin"} />;
|
||||
},
|
||||
});
|
||||
|
||||
/** Guard: admin-only routes redirect non-admins back to the booth. */
|
||||
function adminOnly(ctx: RouterContext) {
|
||||
if (ctx.user?.role !== "admin") throw redirect({ to: "/booth" });
|
||||
}
|
||||
|
||||
const setupRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/setup",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
component: () => <SetupWizard />,
|
||||
});
|
||||
const tariffRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/tariff",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
component: () => <TariffComposer />,
|
||||
});
|
||||
const subscriptionsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/subscriptions",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
component: () => <SubscriptionManager />,
|
||||
});
|
||||
const siteRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/site",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
component: () => <SiteSettings canEdit={true} />,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
boothRoute,
|
||||
shiftRoute,
|
||||
setupRoute,
|
||||
tariffRoute,
|
||||
subscriptionsRoute,
|
||||
siteRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
context: { user: null, setUser: () => {} },
|
||||
defaultPreload: "intent",
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 ?? [];
|
||||
|
||||
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
||||
if (shots.length === 0) return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex 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={`${s.direction ?? "snapshot"} · ${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"
|
||||
}`}
|
||||
>
|
||||
{s.direction ?? "—"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// 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
|
||||
// local Fastify server.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 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,
|
||||
// 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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,34 @@
|
||||
"when": 1781632874398,
|
||||
"tag": "0000_baseline",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
||||
export * from "./schema.js";
|
||||
// Re-export the query helpers consumers need, so they don't depend on
|
||||
// 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
|
||||
|
||||
+70
-21
@@ -9,7 +9,7 @@ import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
|
||||
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
|
||||
// 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:
|
||||
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
|
||||
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
|
||||
@@ -22,6 +22,12 @@ export const users = sqliteTable("users", {
|
||||
role: text("role", {
|
||||
enum: ["admin", "operator", "cashier", "readonly"],
|
||||
}).notNull(),
|
||||
// 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"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
@@ -87,7 +93,7 @@ export const snapshots = sqliteTable("snapshots", {
|
||||
direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
|
||||
// The camera `devices` instance that captured it (raw provenance).
|
||||
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"),
|
||||
contentType: text("content_type").notNull(),
|
||||
bytes: blob("bytes").notNull().$type<Buffer>(),
|
||||
@@ -131,9 +137,40 @@ export const setupState = sqliteTable("setup_state", {
|
||||
// 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
|
||||
// (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", {
|
||||
id: integer("id").primaryKey(), // always 1
|
||||
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"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
@@ -171,16 +208,28 @@ export const tariffVersions = sqliteTable("tariff_versions", {
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// --- Permits (subscriptions) ---------------------------------------------
|
||||
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event.
|
||||
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null =
|
||||
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a
|
||||
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md.
|
||||
export const permits = sqliteTable("permits", {
|
||||
// --- Subscriptions --------------------------------------------------------
|
||||
// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
|
||||
// month) instead of paying per stay. Mutable master data; every USE still produces a
|
||||
// signed vehicle_entry/exit ledger event. Two optional, independent bindings:
|
||||
// car-count (maxConcurrent, default 1, null = unbound) and plate (plates rows,
|
||||
// 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(),
|
||||
holderName: text("holder_name"),
|
||||
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.
|
||||
maxConcurrent: integer("max_concurrent").default(1),
|
||||
validFrom: text("valid_from"),
|
||||
@@ -193,19 +242,19 @@ export const permits = sqliteTable("permits", {
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane.
|
||||
export const permitCredentials = sqliteTable("permit_credentials", {
|
||||
// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
|
||||
export const subscriptionCredentials = sqliteTable("subscription_credentials", {
|
||||
id: text("id").primaryKey(),
|
||||
permitId: text("permit_id").notNull(),
|
||||
subscriptionId: text("subscription_id").notNull(),
|
||||
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
// Plate binding (optional). When a permit has plate rows, a matching plate read is
|
||||
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
||||
export const permitPlates = sqliteTable("permit_plates", {
|
||||
// Plate binding (optional). When a subscription has plate rows, a matching plate read
|
||||
// is itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
||||
export const subscriptionPlates = sqliteTable("subscription_plates", {
|
||||
id: text("id").primaryKey(),
|
||||
permitId: text("permit_id").notNull(),
|
||||
subscriptionId: text("subscription_id").notNull(),
|
||||
plate: text("plate").notNull(),
|
||||
});
|
||||
|
||||
@@ -234,8 +283,8 @@ export const sessions = sqliteTable("sessions", {
|
||||
// Identity that opened the session, and how it was read.
|
||||
identity: text("identity"),
|
||||
source: text("source"),
|
||||
// null while transient; set when matched to a permit.
|
||||
permitId: text("permit_id"),
|
||||
// null while transient; set when matched to a subscription.
|
||||
subscriptionId: text("subscription_id"),
|
||||
enteredAt: text("entered_at").notNull(),
|
||||
// null until exit; presence = CLOSED.
|
||||
exitedAt: text("exited_at"),
|
||||
@@ -256,8 +305,8 @@ export type SetupStateRow = typeof setupState.$inferSelect;
|
||||
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
||||
export type TariffRow = typeof tariffs.$inferSelect;
|
||||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||
export type PermitRow = typeof permits.$inferSelect;
|
||||
export type PermitCredentialRow = typeof permitCredentials.$inferSelect;
|
||||
export type PermitPlateRow = typeof permitPlates.$inferSelect;
|
||||
export type SubscriptionRow = typeof subscriptions.$inferSelect;
|
||||
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||||
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PrinterDevice,
|
||||
PrinterStatus,
|
||||
PrintReport,
|
||||
SubscriptionCardData,
|
||||
TicketData,
|
||||
} from "../interfaces.js";
|
||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||
@@ -40,15 +41,128 @@ 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. */
|
||||
// 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, Ë: 0xeb,
|
||||
ç: 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 {
|
||||
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
|
||||
const out: number[] = [];
|
||||
for (const ch of text) {
|
||||
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 Rongta 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. (A QR for phone scanning may
|
||||
// be added later behind an admin toggle.)
|
||||
|
||||
/** 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. The QR
|
||||
// "model 2" sequence: set model → set module size → set error-correction → store the
|
||||
// data in symbol storage → print it. See ESC/POS GS ( k (function 165/167/169/180/181).
|
||||
|
||||
/** 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}`,
|
||||
/** "Lost your ticket? <phone>" footer; printed only when a phone is set. */
|
||||
lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`,
|
||||
/** 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}`,
|
||||
} as const;
|
||||
|
||||
/** 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,
|
||||
SELECT_CP852,
|
||||
ALIGN_CENTER,
|
||||
BOLD_ON,
|
||||
line(report.title),
|
||||
@@ -60,27 +174,77 @@ function renderReport(report: PrintReport): Buffer {
|
||||
]);
|
||||
}
|
||||
|
||||
/** Build the full ESC/POS byte stream for an entry ticket. */
|
||||
/** 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()));
|
||||
}
|
||||
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 → optional lost-ticket footer. 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. */
|
||||
function renderTicket(data: TicketData): Buffer {
|
||||
return Buffer.concat([
|
||||
INIT,
|
||||
ALIGN_CENTER,
|
||||
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("PARKING"),
|
||||
line(data.ticketId),
|
||||
DOUBLE_OFF,
|
||||
BOLD_OFF,
|
||||
line(),
|
||||
BOLD_ON,
|
||||
line(data.ticketId),
|
||||
BOLD_OFF,
|
||||
ALIGN_LEFT,
|
||||
line(),
|
||||
line(`Issued: ${data.issuedAt}`),
|
||||
line(STR.issuedAt(data.issuedAt)),
|
||||
// Contact footer (lost-ticket help) if a phone is set.
|
||||
...(data.header?.phone ? [line(STR.lostTicket(data.header.phone))] : []),
|
||||
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. */
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
@@ -223,6 +387,11 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live operator-actionable status, scraped from the device's own status page.
|
||||
* The board decodes the ESC/POS status bits itself, so we trust its Yes/No
|
||||
|
||||
@@ -190,9 +190,37 @@ export interface Snapshot {
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
readonly ticketId: string;
|
||||
readonly issuedAt: string; // ISO-8601
|
||||
/** Park identity for the header. Absent → driver prints the generic "PARKING". */
|
||||
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 {
|
||||
@@ -201,6 +229,8 @@ export interface PrinterDevice extends Device {
|
||||
* printed as-is; the driver adds a header/cut. Kept generic so the business
|
||||
* layer composes the content. See wiki/concepts/shift.md. */
|
||||
printReport(report: PrintReport): Promise<void>;
|
||||
/** Print a subscription card: a scannable QR of the code + holder/validity. */
|
||||
printSubscriptionCard(data: SubscriptionCardData): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PrintReport {
|
||||
|
||||
@@ -52,6 +52,10 @@ export type LedgerEventType =
|
||||
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
|
||||
| "shift_open"
|
||||
| "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";
|
||||
|
||||
/** How money was tendered (for payment events + the shift Z-report). */
|
||||
|
||||
Generated
+1267
-7
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ session projection.
|
||||
|
||||
## 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:
|
||||
|
||||
```
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- **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).
|
||||
- **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
|
||||
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, frontend, booth, realtime, ui]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
status: 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|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|Active Sessions]]** 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.
|
||||
|
||||
## The device-status footer
|
||||
|
||||
A **fixed footer** in the app shell shows the live status of every configured device — relays,
|
||||
readers, cameras, printers — one chip each (coloured dot + name + fault detail), with an "all ready
|
||||
/ N offline" roll-up. Fed by the unified [[device-status-monitoring|DeviceMonitor]] over the same
|
||||
`/api/ws` socket (`hello` carries the initial set; a `device-status` frame per change), held in the
|
||||
live store keyed by device id, with `GET /api/devices/status` as the seed/fallback. Visible on every
|
||||
screen, so the operator always sees the barrier relay's reachability and the printer's paper state.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, booth, exit, payment, threat-model]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
status: open
|
||||
---
|
||||
|
||||
# Booth Exit Flow — pay-at-booth, voucher vs. immediate exit
|
||||
|
||||
How the **manned booth** takes payment for a transient ticket and lets the car out. Complements the
|
||||
unattended reader path in [[parking-session]] / the exit flow: same signed events, a booth-driven
|
||||
trigger. Decided 2026-06-17.
|
||||
|
||||
## Operator flow
|
||||
|
||||
1. **Ticket input** on the booth screen. The operator scans (HID scanner types the id + Enter) or
|
||||
keys the ticket number.
|
||||
2. On submit, the booth **looks up the session** and opens a **modal**: entry time, exit time (now),
|
||||
**duration**, **total owed** (the [[tariff]] quote), tender (cash/card), and a checkbox
|
||||
**"Printo biletë dalje"** (print exit ticket).
|
||||
3. The operator takes payment → a signed `payment` event ([[parking-session]]). What happens next
|
||||
depends on the checkbox:
|
||||
- **Checked → print an exit voucher.** The customer carries it to a (distant) exit and
|
||||
**self-exits by scanning it** there; that scan runs the normal reader exit flow. The booth does
|
||||
NOT open the barrier.
|
||||
- **Unchecked → immediate exit.** When the modal closes after a successful payment, the booth
|
||||
**signs `vehicle_exit`, pulses the exit relay, and fires the exit snapshot** right away (booth is
|
||||
at/near the exit).
|
||||
|
||||
## Settled decisions (2026-06-17)
|
||||
|
||||
- **Voucher carries the SAME ticket id** (reprinted as the Code128 barcode). At the exit reader it
|
||||
runs the existing exit validation — which now finds the session **paid + within walk-back grace**,
|
||||
so it opens. No new identity or code type; the "biletë dalje" is a *paid reprint* of the entry
|
||||
ticket id. Reuses [[tariff|walk-back grace]] exactly.
|
||||
- **The checkbox default lives in `site_config`** (`exit_voucher_default`, a site-wide boolean edited
|
||||
in Site settings) — because it's booth geography, not per-ticket. The operator may override per
|
||||
transaction. (Per-exit-point config deferred until a site has both a near and a far exit.)
|
||||
- **Payment is never rolled back.** If the checkbox is OFF and `pulseOpen` fails (offline
|
||||
controller), the signed `payment` + `vehicle_exit` already stand (money was taken, the car is
|
||||
owed an exit). The booth surfaces a clear error and an **audited `anomaly`** so the operator opens
|
||||
manually — we never silently drop the payment, and never leave a paid car without an exit event.
|
||||
|
||||
## Threat-model notes ([[threat-model|operator as adversary]])
|
||||
|
||||
- The booth exit reuses the **same validation as the reader path** (paid + within grace, or free
|
||||
entry-grace) — there is no booth-only bypass that admits an unpaid car. An unpaid ticket sends the
|
||||
operator to take payment first.
|
||||
- Every booth action is a **signed ledger event attributed to the operator's session**: the payment,
|
||||
the exit, and any `anomaly` (failed open / override). A colluding operator can't wave a car out
|
||||
without leaving a signed, attributed trail visible to [[reconciliation]].
|
||||
- The voucher path keeps the **camera snapshot at the physical exit** (the self-scan fires it), so
|
||||
the evidence is captured where the car actually leaves, not where it paid.
|
||||
|
||||
## Active sessions & human-intervention barrier open
|
||||
|
||||
**The barrier state is ASSUMED, never confirmed.** We send "open" intent and never truly know the car
|
||||
cleared ([[barrier-not-a-door]], no wired loop/sensor feedback). So a signed `vehicle_exit` does NOT
|
||||
mean the car is gone — it may be stuck (damaged ticket / dead scanner, or the barrier re-closed on a
|
||||
phantom obstacle: an animal, a person, a cardboard box or bag in the wind). These edge cases need a
|
||||
**human in the booth** to open the barrier, leaving a signed trace.
|
||||
|
||||
**A session is "active" (shown in the booth Active Sessions list) while it is EITHER:**
|
||||
- **open + unpaid** — entered, no `vehicle_exit`, owing money. **Always shown** — a car that owes
|
||||
money never ages out; it's genuinely still inside until it pays, however long that takes. OR
|
||||
- **open + paid, still within grace** — paid but no exit recorded yet, `now ≤ graceExpiresAt`. OR
|
||||
- **exited but `now ≤ graceExpiresAt`** — paid and/or the voucher scanned, but still within the
|
||||
walk-back grace window. Because the barrier is unconfirmed, the car is presumed *possibly still
|
||||
present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the
|
||||
list** — only grace expiry does.
|
||||
|
||||
A session drops off the list once it is **past grace** and EITHER exited OR **paid** (presumed truly
|
||||
gone). The **paid age-out** is important: a paid session whose walk-back grace lapsed has left, so it
|
||||
is omitted **even if no `vehicle_exit` was ever signed**. Without this, a paid car that left via a
|
||||
manual barrier re-open (which historically signed no exit — see below) would linger **forever**
|
||||
(ticket T-397815c0, 2026-06-18). The signed log is untouched — this is purely the list's display
|
||||
filter (`PayStation.activeSessions()`).
|
||||
|
||||
### The one operator action — "Open barrier" (audited re-pulse)
|
||||
|
||||
For an active session, the operator can open the barrier as a **human intervention**. This:
|
||||
- **re-pulses an exit relay** (resolved site-wide, as the booth exit does), and
|
||||
- signs an **`anomaly`** (`source: booth`, attributed to the operator, reason "manual barrier open"), and
|
||||
- **closes the session IF it is still open** — i.e. if no `vehicle_exit` exists yet, the re-open *is*
|
||||
this car leaving, so it also signs a **`vehicle_exit`** (`source: manual`, reason "human-intervention
|
||||
exit"). If the session is **already exited** (the phantom re-close case — a second exit would
|
||||
double-count occupancy), it signs **no** second exit: anomaly only.
|
||||
|
||||
> **Refined 2026-06-18 (was "NEVER a `vehicle_exit`").** The original rule never signed an exit on a
|
||||
> re-open, on the assumption a normal `vehicle_exit` had already happened. But when the re-open was the
|
||||
> *only* way a car left (its walk-back grace had expired, so a normal exit was refused), the session
|
||||
> kept **no exit event** and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only
|
||||
> when the session is **still open**, preserving the no-double-count guarantee for the already-exited
|
||||
> case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any
|
||||
> paid session that still slips through.
|
||||
|
||||
**Guard — paid OR subscription, else no button.** The "Open barrier" action is shown/active for a
|
||||
session that **has a payment** (paid, or paid-and-exited-in-grace) **OR is a [[subscription]]
|
||||
occurrence** (prepaid — the operator must be able to assist a subscriber when the exit reader / card
|
||||
fails). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to
|
||||
the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally
|
||||
(server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force
|
||||
exit* for genuine disputes would be a separately-audited path — see Open.
|
||||
|
||||
### Subscription occurrences in the booth (built 2026-06-18)
|
||||
|
||||
A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by
|
||||
the **holder name**, not the raw `SUBSESS-…` key). Opening it shows the **pay/exit modal in
|
||||
subscription mode**: entry/duration + **PREPAID** (no amount — it is **never quoted or charged**),
|
||||
the entry/exit **snapshots**, and a single **Open barrier** action (the audited re-pulse). This is
|
||||
exactly the assist path for a **faulty exit reader or a missing/forgotten card/QR**. The session
|
||||
view (`lookup` / `activeSessions` in `pay-station.ts`) carries `subscription`, `subscriptionId`,
|
||||
`subscriptionHolder`, derived from the entry payload's `permit:true` / `permitId`.
|
||||
|
||||
This single mechanism covers both edge cases: a **damaged ticket / dead scanner** (find the still-open
|
||||
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
|
||||
**phantom-obstacle re-close** (the just-exited car is still in the list within grace → Open barrier).
|
||||
|
||||
## ⚠ Open question — walk-back grace renews on every payment (voucher overstay)
|
||||
|
||||
**Found 2026-06-17. Not yet fixed.** Scenario: customer pays at the booth, takes an exit voucher,
|
||||
then dawdles past the walk-back grace before reaching the exit.
|
||||
|
||||
What the code does today (`exit-flow.ts`, `pay-station.ts`):
|
||||
- The exit reader's grace check is `now − paidAt ≤ graceExitMin`, reading **the latest payment's**
|
||||
`graceExitMin`. Over the window → exit **refuses** ("top-up required"). ✓ *Correct — no free exit.*
|
||||
- The re-quote (`computeFee(enteredAt, now, …)`) always prices from **entry**, never from the last
|
||||
payment. So a top-up charges the **full** entry→now fee (minus what's paid is implicit via the
|
||||
ledger). ✓ *Correct — the timer does NOT restart; the customer pays the true total.*
|
||||
- BUT every `payment` writes its own `graceExitMin`, and the exit flow reads the **latest** one — so
|
||||
**each top-up grants a fresh, full grace window.** ✗ *This is the bug.*
|
||||
|
||||
**The leak is time, not money.** It is not a free-exit hole (the fee always catches up from entry).
|
||||
But the grace window — meant as a one-time walk-from-pay-to-gate allowance — is re-granted in full on
|
||||
every payment, so a customer could pay → wait → pay a tiny delta → get another full window → repeat,
|
||||
riding the gap between "paid" and "next increment accrues." With coarse [[tariff]] increments the
|
||||
abuse is bounded but real.
|
||||
|
||||
**Candidate fixes (business call — fairness vs. anti-abuse):**
|
||||
1. **Grace on top-up only when the top-up charged new money** (recommended). Kills the "tiny delta
|
||||
forever" loop while staying fair to a genuine overstay; re-price stays from entry.
|
||||
2. **Single non-renewing window** anchored to the FIRST payment — cleanest anti-abuse, but can unfairly
|
||||
trap someone who legitimately paid, walked, then hit a slow elevator after a top-up.
|
||||
3. **Cap total grace** granted per session regardless of payment count.
|
||||
|
||||
Decided halves: **refuse-on-expiry** and **reprice-from-entry** are deliberate and correct. The
|
||||
**grace-renews-fully-per-payment** consequence was an unintended side effect of reading `graceExitMin`
|
||||
off the latest payment. See [[tariff]] (walk-back grace) for the pricing side of the same question.
|
||||
|
||||
## As-built / open
|
||||
- Backend: `GET /api/session/:identity` (lookup + quote), `POST /api/exit { identity }` (validated
|
||||
booth exit), `site_config.exit_voucher_default`. Exit validation shared between the booth and the
|
||||
reader path (one code path, two triggers).
|
||||
- **Open: walk-back grace renews on every payment** — see the flagged section above (voucher overstay
|
||||
re-grants a full grace window; pick a fix before production).
|
||||
- Voucher print = reprint the ticket id barcode on the booth printer ([[ticket-encoding]]).
|
||||
- Open: a force-open **override** (lost ticket / equipment fault) — deferred; would be a separately
|
||||
audited signed event, not folded into the validated path.
|
||||
@@ -18,7 +18,7 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
|
||||
|
||||
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
|
||||
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
|
||||
- Permit concurrency (`maxConcurrent`, see [[permit]]) is the same kind of fold, scoped to one
|
||||
- Permit concurrency (`maxConcurrent`, see [[subscription]]) is the same kind of fold, scoped to one
|
||||
permit's open sessions.
|
||||
|
||||
## Full → refuse entry + FULL sign
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, device, monitoring, reliability, ui]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
status: open
|
||||
---
|
||||
|
||||
# Device status monitoring (the booth footer)
|
||||
|
||||
The booth shows a **fixed footer** with the live status of every configured device — relays,
|
||||
readers, cameras, printers — so an operator sees at a glance that the barrier relay is reachable,
|
||||
the exit scanner is up, and the ticket printer has paper. This generalises the printer-only
|
||||
[[printer-status-monitoring]] to **all four [[device-adapter-pattern|device categories]]**. A
|
||||
reliability control, not a threat-model one. (Built 2026-06-18.)
|
||||
|
||||
## What gets polled, and how
|
||||
|
||||
Every **enabled** row in `devices` is polled on an interval, regardless of category — the monitor
|
||||
talks only to the adapter interfaces ([[device-adapter-pattern]]), never a driver SDK:
|
||||
|
||||
- **Printers** → their rich `MonitorableDevice.readStatus()` (paper end / near-end, cover open,
|
||||
cutter error, off-line) — the same capability the existing [[printer-status-monitoring|PrinterMonitor]]
|
||||
uses. The footer surfaces the fault detail.
|
||||
- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every
|
||||
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
|
||||
model — a relay either answers or it doesn't.
|
||||
|
||||
Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail
|
||||
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
|
||||
`offline` (consistent with [[printer-status-monitoring]]'s fail-safe mapping); a driver that's no
|
||||
longer registered reads `offline` ("driver not registered") rather than vanishing.
|
||||
|
||||
## The monitor (server)
|
||||
|
||||
`DeviceMonitor` (`apps/server/src/device-monitor.ts`), modelled on the PrinterMonitor:
|
||||
|
||||
- re-reads the device set each tick (a newly-assigned/removed device appears/disappears without a
|
||||
restart); drops cached status for devices that are gone or disabled;
|
||||
- polls every `DEVICE_POLL_MS` (default **8000ms**), never overlapping ticks;
|
||||
- caches the latest unified status per device id;
|
||||
- emits a `device-status` bus event **only when a device's state or detail changes** (deduped).
|
||||
|
||||
> **Relationship to the PrinterMonitor.** Both run. The PrinterMonitor stays the authority for the
|
||||
> printer-specific live detail + its SSE stream (`/api/printers/status*`) that the entry flow may
|
||||
> later depend on for [[printer-roles-failover]]. The DeviceMonitor is the **unified footer feed**
|
||||
> across all categories. They poll independently (printers get probed by both — cheap HTTP reads);
|
||||
> the small duplication is deliberate, to avoid coupling the footer to printer internals. Could be
|
||||
> consolidated later if the overlap ever matters.
|
||||
|
||||
## API / live UI
|
||||
|
||||
- `GET /api/devices/status` — cached snapshot of all devices (no device round-trip). Any
|
||||
authenticated role (operational, not a setup action).
|
||||
- Live updates ride the **one booth [[booth-console|WebSocket]]** (`/api/ws`): the `hello` frame
|
||||
carries the initial device-status set; a `device-status` frame is pushed per change. The web
|
||||
[[booth-console|live store]] holds the set keyed by device id; the REST snapshot seeds it / fills
|
||||
in if the socket is briefly down.
|
||||
- **`DeviceFooter`** (`apps/web/src/ui/DeviceFooter.tsx`) renders one **compact** chip per device —
|
||||
a coloured dot + a **role label, never the vendor** — ordered access → reader → camera → printer,
|
||||
with a right-aligned roll-up ("N with issues" / "all ready"). Mounted in the app shell so it's
|
||||
visible on every screen.
|
||||
|
||||
### Label = role, not vendor (refinement 2026-06-18)
|
||||
|
||||
The chip shows **what the device does, not who made it**: the localised category + a role/direction
|
||||
suffix → `Lexuesi hyrje`, `Printer kabina`, `Kamera dalje`. The server sends a structured
|
||||
**`roleKind`** token (not a composed string), the client localises it:
|
||||
- **reader / camera** → the direction inherited from its bound relay (`directionOf()` in
|
||||
[[entry-exit-points|device-resolve]]): `entry | exit | both`.
|
||||
- **access controller** → `entry | exit | both` from its `relays[]`, or **`mixed`** when it spans
|
||||
more than one direction; `null` if it declares none yet.
|
||||
- **printer** → `lane` (entry-dispenser) | `booth` (booth-receipt) — the [[printer-roles-failover]] role.
|
||||
- `null` → the chip shows the category alone.
|
||||
|
||||
### Detail does NOT pollute the footer (refinement 2026-06-18)
|
||||
|
||||
Chips stay short — **no inline fault text**. A device that is `degraded`/`offline` is clickable (so
|
||||
is the roll-up); clicking opens a small **issues panel** anchored above the footer that lists only
|
||||
the problem devices with their role label, state, the `detail` string, and the last-checked time.
|
||||
`ready` chips are non-interactive. The panel closes on outside-click / Escape (a lightweight
|
||||
popover — no extra dependency; only Radix Dialog is installed).
|
||||
|
||||
## Verified (2026-06-18)
|
||||
|
||||
On a fresh DB seeded with a stub relay, a TCP reader, and two printers (one reachable, one not):
|
||||
relay + reader → `ready` via `healthCheck`; the unreachable printer → `offline` (with a detail
|
||||
string, never threw); the bus emitted once per device on first observation, and a second unchanged
|
||||
tick was silent (change-only emit). Server + web build clean.
|
||||
|
||||
## Open / not yet done
|
||||
|
||||
- **Reachability ≠ correctness.** `healthCheck()` says a relay/reader answers, not that it's wired
|
||||
to the right barrier or reading cards — that's a setup/precondition concern ([[first-run-setup]],
|
||||
the Dingtian [[access-controller-button-flow|precondition checks]]).
|
||||
- **No per-device history / alerting.** The footer is point-in-time; a flapping device isn't
|
||||
tracked over time. Reconciliation-style alerting is out of scope here.
|
||||
- **Cameras** only expose `healthCheck` reachability today; a "last snapshot age" health signal
|
||||
could be richer ([[lpr-camera]], [[opencv-anpr-service]]).
|
||||
- Possible later **consolidation** of PrinterMonitor + DeviceMonitor (see the note above).
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, frontend, i18n, localization]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
status: open
|
||||
---
|
||||
|
||||
# Internationalization (i18n)
|
||||
|
||||
The operator UI ships in **two languages: Albanian (default) and English**. Language is a
|
||||
**per-user preference stored server-side** and loaded on login — not a browser/localStorage setting,
|
||||
not a site-wide one. So an operator's choice follows their account and is restored on every login from
|
||||
any booth. (Decided + built 2026-06-18.)
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Albanian is the default and fallback.** English is the second language. A missing English key
|
||||
falls back to Albanian.
|
||||
- **Per-user, server-side preference.** `users.language` (`'sq' | 'en'`, default `'sq'`; migration
|
||||
0003). Returned from `/api/auth/login` and `/api/auth/me`, and changed via **`PUT /api/auth/language`**
|
||||
(self-service, any signed-in role). It is **deliberately NOT in the JWT** (identity/role only) — so
|
||||
changing language is a DB write + immediate `/me`, with no token refresh / re-login. See
|
||||
[[local-jwt-auth]].
|
||||
- **Library: react-i18next** (i18next). Chosen over a hand-rolled `t()` for pluralization,
|
||||
interpolation, and headroom beyond two languages. The active language is applied after `/me`
|
||||
resolves (App effect on `user.language`); the header **SQ/EN toggle** switches instantly *and*
|
||||
persists.
|
||||
- **Printed tickets/receipts stay Albanian.** Customer-facing paper is **independent** of the
|
||||
operator's UI language — an operator reading the UI in English still prints Albanian tickets. The
|
||||
print strings live in the device driver's `STR` table ([[ticket-encoding]], [[site-metadata]]); can
|
||||
become a `site_config.print_language` setting later if a site ever needs English receipts.
|
||||
|
||||
## As-built (2026-06-18)
|
||||
|
||||
- **Backend:** `users.language` + the three auth touch-points above (`apps/server/src/routes/auth.ts`).
|
||||
- **Frontend:** `apps/web/src/lib/i18n/` — `sq.ts` (default/fallback), `en.ts`, and `index.ts` (init +
|
||||
`setLanguage()`). **Type-safe key parity:** `Catalog` is the *shape* of `sq` with string-typed
|
||||
values, so TypeScript forces `en.ts` to supply every key (and the build fails on a missing/typo'd
|
||||
key). Keys are dot-namespaced by area (`common`, `nav`, `status`, `auth`, `booth`, `pay`, `shift`,
|
||||
`site`, `permits`, `tariff`).
|
||||
- **Translated screens:** the booth ([[booth-console]] — screen, pay/exit modal, active sessions,
|
||||
snapshots, status), Login, ShiftControl, SiteSettings, PermitManager, TariffComposer.
|
||||
|
||||
## Open / deferred
|
||||
|
||||
- **SetupWizard is NOT translated** (deliberate). Its content is mostly **server-provided** — driver
|
||||
labels and config-field labels/help come from the backend device-catalog API ([[device-registry]],
|
||||
[[first-run-setup]]). Translating only its static chrome would leave a half-English screen; it's
|
||||
deferred until **backend catalog i18n** is scoped, then chrome + catalog localize together.
|
||||
- **Server API error strings** are still English (surfaced raw in the UI). v1 relies on the
|
||||
client mapping known errors; a fuller approach would translate by error *code*, not message.
|
||||
- **Behaviour note (not a bug):** a *hard navigation* (new URL) re-bootstraps the language from the
|
||||
user's stored preference via `/me` — so an un-persisted toggle resets. Correct: the stored pref
|
||||
wins. The toggle persists via the PUT, so it survives once saved.
|
||||
@@ -43,7 +43,7 @@ A session needs a key that survives from entry to exit. Two populations, two key
|
||||
|
||||
- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]])
|
||||
or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field.
|
||||
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid
|
||||
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[subscription]] record. A valid
|
||||
permit means the session owes nothing — the PAY step is skipped (see below).
|
||||
|
||||
## Lifecycle (pay-on-foot / pay station model)
|
||||
@@ -71,7 +71,7 @@ States, as derived from events:
|
||||
| **CLOSED** | a matching `vehicle_exit` event exists |
|
||||
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
|
||||
|
||||
Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close.
|
||||
Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
|
||||
|
||||
## Edge cases the model must name (not yet designed in full)
|
||||
|
||||
@@ -106,7 +106,7 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
|
||||
fails. See [[device-input-flow]].
|
||||
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
|
||||
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
|
||||
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built.
|
||||
Lane resolved once (`readerLaneWithAccess`). See [[subscription]] as-built.
|
||||
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) →
|
||||
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
|
||||
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
|
||||
|
||||
@@ -7,6 +7,11 @@ updated: 2026-06-14
|
||||
|
||||
# Printer status monitoring
|
||||
|
||||
> **Generalised 2026-06-18:** the booth's all-device status **footer** is a separate, unified
|
||||
> monitor across every category (relays/readers/cameras/printers) — see
|
||||
> [[device-status-monitoring]]. This page remains the authority for the *printer-specific* rich
|
||||
> status (paper/cover/cutter) + its SSE stream; both monitors run.
|
||||
|
||||
The booth must know a printer is in trouble **before** a driver presses the entry button and no
|
||||
ticket comes out. So the system polls each printer's live status (paper out, cover open, cutter
|
||||
jam, off-line) and pushes changes to the operator UI. A reliability control, like
|
||||
|
||||
@@ -17,7 +17,7 @@ derived and rebuildable, never a separate ledger.
|
||||
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
|
||||
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
|
||||
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
|
||||
- **Stay analytics** — average/median duration, distribution; transient vs. [[permit]] split.
|
||||
- **Stay analytics** — average/median duration, distribution; transient vs. [[subscription]] split.
|
||||
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
|
||||
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
|
||||
the `anomaly` events + reconciliation findings ([[reconciliation]]).
|
||||
|
||||
+82
-4
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, domain, business, shifts, anti-fraud]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
updated: 2026-06-18
|
||||
status: open
|
||||
---
|
||||
|
||||
@@ -21,6 +21,29 @@ is **no operator and no shift**; what replaces it is the pay station's **cash-co
|
||||
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
|
||||
don't force one model across both.
|
||||
|
||||
## Site-wide single-open + the booth gate (decided + built 2026-06-18)
|
||||
|
||||
A shift is a **site-wide accountability period**: at most **one shift may be open at a time** across
|
||||
the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit
|
||||
falls inside exactly one operator's window. Consequences:
|
||||
|
||||
- **Login ≠ shift.** An operator may log in **off-shift** (e.g. to review their own past activity);
|
||||
logging in never opens a shift. Conversely a shift can't be opened by two people at once.
|
||||
- **Opening is refused when ANY shift is open** — whether the operator's own (double-open) or
|
||||
*another* operator's (handover not done). `ShiftService.open()` checks `currentOpenShift()` (the
|
||||
single site-wide open shift = most recent shift event on the whole chain is a `shift_open`), and
|
||||
throws `ShiftAlreadyOpenError` carrying `heldBy` so the UI can name who holds it. Operator B can
|
||||
only start once operator A closes — that's the handover.
|
||||
- **The booth money path is GATED on an open shift.** `/api/pay`, `/api/exit`, `/api/voucher`,
|
||||
`/api/barrier/reopen` run a `requireShift` preHandler that 409s `{ code: "no_shift" }` when none
|
||||
is open. Read-only lookups (`/api/session/:id`, `/api/sessions/active`, `/api/pay/quote`) stay
|
||||
ungated so the modal can still *display* a session and prompt "open a shift". The server is the
|
||||
enforcement point; the UI mirrors it (see [[booth-console]]).
|
||||
- **"Operate under someone else's shift" is deliberately disallowed.** B's takings would land in A's
|
||||
Z-report and corrupt the attribution, so B is fully blocked until B's own shift is open.
|
||||
- **Logs are per-shift.** The booth live feed shows only events from the open shift's window
|
||||
(`GET /api/events?since=<shiftStart>`); no shift open → no feed, just the "open a shift" prompt.
|
||||
|
||||
## A shift is NOT time-based
|
||||
|
||||
It is delimited by **explicit operator action**, never by a clock:
|
||||
@@ -40,7 +63,9 @@ login ————————————————————————
|
||||
## What End Shift does
|
||||
|
||||
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
|
||||
[[append-only-event-chain]]) between this shift's start mark and now.
|
||||
[[append-only-event-chain]]) between this shift's start mark and now. This includes a
|
||||
**[[subscription]] fee** an operator collects during the shift (sold/renewed at the booth → a
|
||||
signed `payment`, deferred build) — it folds into this set like any transient taking.
|
||||
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
|
||||
(the card line is omitted when there's no terminal).
|
||||
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
|
||||
@@ -56,8 +81,10 @@ no variance gate, no manager override.
|
||||
|
||||
- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event
|
||||
type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the
|
||||
event `identity`; a shift is **open** iff that operator's most recent shift event is a
|
||||
`shift_open`. `ShiftService` (`apps/server/src/shift-service.ts`).
|
||||
event `identity`. `ShiftService` (`apps/server/src/shift-service.ts`).
|
||||
> **Superseded 2026-06-18:** open-ness is now judged **site-wide** (`currentOpenShift()` — the most
|
||||
> recent shift event on the *whole* chain), not per-operator. See "Site-wide single-open" above.
|
||||
> `openShiftFor(operator)` survives only for `close()` (you close your own shift).
|
||||
- **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment
|
||||
time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the
|
||||
new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt
|
||||
@@ -70,6 +97,54 @@ no variance gate, no manager override.
|
||||
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
|
||||
verifyChain ok.
|
||||
|
||||
## Drawer balance — opening float, cash movements, carry-over (decided 2026-06-18)
|
||||
|
||||
The Z-report's payment totals answer "how much did this shift *take*?" — but a manned booth also has a
|
||||
**physical cash drawer** that carries across shifts. The drawer is tracked as a running balance over
|
||||
the signed chain, so each shift knows what it **inherited** and what it should **hand over**.
|
||||
|
||||
**The events:**
|
||||
- A new signed **`cash_movement`** event: the admin loads or removes drawer cash, `{ amountMinor
|
||||
(signed: + load, − removal), reason, operator }`. **Admin-only** (an operator takes payments but
|
||||
cannot move the float in/out). The opening-day load (+5000 ALL) and a mid-shift withdrawal (−5000)
|
||||
are both `cash_movement` events.
|
||||
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
|
||||
the drawer).
|
||||
|
||||
**The math — drawer is a fold over the chain BY TIME, not by operator** (a `cash_movement` is the
|
||||
admin's, not the shift operator's, so it can't key off `identity`):
|
||||
|
||||
```
|
||||
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
||||
+ Σ cash_movement amounts up to `at`
|
||||
```
|
||||
|
||||
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
|
||||
before this shift's start mark. It is **auto-inherited from the chain** (no operator entry). The
|
||||
first shift ever opens at **0**; the admin's load makes it 5000.
|
||||
|
||||
**The Z-report at close** reports the full drawer picture for the shift window `[start, end]`:
|
||||
`openingFloat`, `cashTakenMinor` (cash payments in-window), `cashAddedMinor` / `cashRemovedMinor`
|
||||
(movements in-window), and `expectedDrawerMinor = openingFloat + cashTaken + cashAdded − cashRemoved`.
|
||||
That `expectedDrawer` is exactly the **next** shift's opening float — the carry-over.
|
||||
|
||||
**Worked example (the canonical scenario):**
|
||||
|
||||
| Step | Event | Drawer |
|
||||
| --- | --- | --- |
|
||||
| Opening day | admin `cash_movement` +5000 | 5000 |
|
||||
| Shift 1 takes 6500 cash | payments | 11500 |
|
||||
| Shift 1 closes | Z: open 5000, took 6500, expected **11500** | 11500 |
|
||||
| Shift 2 opens | opening float = **11500** (inherited) | 11500 |
|
||||
| admin `cash_movement` −5000 | withdrawal | 6500 |
|
||||
| Shift 2 takes 4500 cash | payments | 11000 |
|
||||
| Shift 2 closes | Z: open 11500, took 4500, removed 5000, expected **11000** | 11000 |
|
||||
| Shift 3 opens | opening float = **11000** | … |
|
||||
|
||||
Card payments are excluded from the drawer (they settle to the bank, not the till). The drawer figure
|
||||
is **expected**, not counted — the optional blind-count enhancement below would record the *variance*
|
||||
against it.
|
||||
|
||||
## Where the fraud control actually lives
|
||||
|
||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||||
@@ -86,6 +161,9 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
|
||||
|
||||
## Open
|
||||
|
||||
- **Drawer carry-over (decided 2026-06-18, building):** opening float auto-inherits the prior shift's
|
||||
expected drawer; admin-only `cash_movement` events; Z-report reports the full drawer picture. See
|
||||
the Drawer balance section above.
|
||||
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
||||
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
||||
the money. Confirm that's the intended accountability (vs. by entry).
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, config, devices]
|
||||
sources: []
|
||||
updated: 2026-06-17
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Site Metadata (Park Identity)
|
||||
|
||||
Optional, admin-set identity/metadata for the park itself, beyond the operational
|
||||
`capacity` knob. Feeds the ticket/receipt header and admin display. All fields are
|
||||
**optional** — the lot runs fine with none set (the ticket falls back to a generic
|
||||
`PARKING` header).
|
||||
|
||||
## Where it lives
|
||||
|
||||
A single-row extension of the existing **`site_config`** table (`id` always 1) — the
|
||||
established home for site-wide knobs ([[capacity-occupancy]]). **Not** a new table:
|
||||
park identity is one-per-site, same cardinality as capacity, so it shares the row.
|
||||
|
||||
| Column | Purpose |
|
||||
| --- | --- |
|
||||
| `park_name` | Display name on the ticket header / UI (e.g. "Acme Parking"). |
|
||||
| `operator_name` | Legal entity operating the lot — for receipts; may differ from the display name. |
|
||||
| `nius` | **NIUS** — Albanian tax/identification number, printed on the receipt when set (commonly required). |
|
||||
| `address` | Free-text postal address (multi-line allowed; printed line-by-line). |
|
||||
| `phone` | Contact phone — also the ticket "Keni humbur biletën?" footer. |
|
||||
| `email` | Contact email (stored; not yet printed). |
|
||||
|
||||
All are **nullable `text`**. Added in migration `0001` (additive `ADD COLUMN`, no
|
||||
data loss). A **metadata change is not a schema change for the ticket id**, but
|
||||
adding these *fields* IS a schema change — done via a Drizzle migration.
|
||||
|
||||
> **Field history.** The first cut (2026-06-17) had `vat_number` + `registration_number`.
|
||||
> Renamed/trimmed the same day to a single `nius` column (Albanian deployments call the
|
||||
> tax id NIUS; registration number dropped as unused). Migration `0001` was regenerated
|
||||
> in place — it had not shipped beyond the dev DB, so there is no migration debt.
|
||||
|
||||
## Read / write path
|
||||
|
||||
- **API**: `GET /api/site-config` returns capacity + every metadata field (null when
|
||||
unset). `PUT /api/site-config` (**admin only**) accepts a **partial** body — only the
|
||||
fields present are updated; blank string → null (clears). `apps/server/src/routes/site.ts`.
|
||||
- **UI**: `apps/web/src/SiteSettings.tsx` — admin edits capacity + the metadata fields
|
||||
in one form (`saveSiteConfig`).
|
||||
- **API client**: `SiteConfig` type + `fetchSiteConfig` / `saveSiteConfig` in `apps/web/src/api.ts`.
|
||||
|
||||
## On the ticket
|
||||
|
||||
`renderTicket()` ([[rongta-printer]]) prints a header from a `TicketHeader` (the metadata):
|
||||
park name large (or `PARKING` if unset), then operator / `NIUS: <n>` / address lines
|
||||
that are present; a `Keni humbur biletën? <phone>` footer if a phone is set. The entry
|
||||
flow (`#ticketHeader()` in `apps/server/src/entry-flow.ts`) reads `site_config` per print.
|
||||
See [[ticket-encoding]].
|
||||
|
||||
## Localisation (Albanian)
|
||||
|
||||
The ticket prints in **Albanian** for now. Strings are centralised in a `STR` table in
|
||||
[[rongta-printer]] (`Printuar më:`, `Keni humbur biletën?`, `NIUS:`) so a real i18n layer
|
||||
(per-locale tables + a `t()` helper, covering the web UI too) can replace them later
|
||||
without touching the render functions — that broader site translation is the next step.
|
||||
|
||||
**Codepage (resolved 2026-06-17).** Albanian text needs `ë`/`ç`, which ASCII can't carry.
|
||||
The driver now selects **CP852 (Latin-2)** via `ESC t 18` in each print preamble and
|
||||
`line()` encodes text to CP852 (with an ASCII transliteration fallback for anything
|
||||
unmapped, and `?` as a last resort — never a wrong glyph). Verified at byte level: `ë` →
|
||||
`0x89` in "Printuar më" / "biletën" / a sample address.
|
||||
|
||||
## Open
|
||||
|
||||
- **Receipt vs entry ticket** — the same header is used for the entry ticket today;
|
||||
a paid receipt may want more (fee, tariff version, paid-at). Design with [[tariff]].
|
||||
- **Email** is stored but not yet printed (no use decided).
|
||||
- **Full i18n** — only the ticket is Albanian so far; the web UI is still English. A
|
||||
proper locale system (and admin language choice) is the broader task this seeds.
|
||||
- **CP852 coverage** — the map covers the common Albanian/Latin-2 letters; extend if a
|
||||
park name/address uses a glyph outside it (currently transliterated to ASCII).
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, pricing, design]
|
||||
sources: [parksql2017-legacy-schema]
|
||||
updated: 2026-06-17
|
||||
status: open
|
||||
---
|
||||
|
||||
# Tariff Time Tiers — happy hour, off-peak, weekend, seasonal
|
||||
|
||||
Design for **time-of-day / day-of-week / seasonal pricing** on top of the existing [[tariff]] engine.
|
||||
Resolves the `tariff.md` open question *"Time-of-day / weekday tiers — not in the block model yet."*
|
||||
Driven by two concrete operator asks: a **happy-hour** rate, and (from [[parksql2017-legacy-schema|the
|
||||
legacy schema]]) **vehicle/customer categories**.
|
||||
|
||||
> Status: **design, not built.** No schema/code committed yet — this records the chosen shape and
|
||||
> the rejected alternatives so implementation is a transcription.
|
||||
|
||||
## The two real-world models we looked at
|
||||
|
||||
1. **Legacy `BA_TicketPrice`** ([[parksql2017-legacy-schema]]): each rate-card row is scoped by
|
||||
`ValidFrom`/`ValidTo` (date window) **and** `ValidFromHour`/`ValidToHour` (daily hour window) **and**
|
||||
`TicketCategoryID`. Happy hour = a second price row valid 14:00–16:00. Off-peak/season = a row
|
||||
with a date or hour window. The active rate is selected by **(category, now-or-entry, date)**.
|
||||
2. **Research (verified):** rates modelled as **time segments nested inside recurring time frames**,
|
||||
where time frames = days-of-week / holidays / special-event days (US patent 10,762,723, 3-0
|
||||
verified). Industry APIs (INRIX `structured_rate`) carry `time_in`/`time_out` + `dow` per rate.
|
||||
Both point at the **same primitive**: a rate that is *active for a wall-clock window*.
|
||||
|
||||
Both converge: **happy hour is not a discount flag — it is a selector over which rate card is active
|
||||
for a given slice of wall-clock time.**
|
||||
|
||||
## The decision to make: which-rate selector vs. discount modifier
|
||||
|
||||
| Option | Shape | Verdict |
|
||||
| --- | --- | --- |
|
||||
| **A. Time-windowed rate cards** (recommended) | A stay is sliced at wall-clock boundaries; each slice priced by the rate card whose window covers it. Happy hour = a card with `window: {dow, fromHour, toHour}`. | Most general: one mechanism covers happy hour, early-bird, night flat, weekend, season. Matches both references. |
|
||||
| **B. Discount modifier on one ladder** | Keep one ladder; apply `−X%`/`−N min` when the clock is inside a window. | Simpler, but can't express "different ladder at night," daily caps interact badly, and it's a second pricing path. Rejected as the primary model. |
|
||||
|
||||
**Recommendation: A.** A discount-style happy hour (B) is then expressible *as* a windowed card (a
|
||||
cheaper ladder), so we don't lose it.
|
||||
|
||||
## The wall-clock slicing consequence (the hard part)
|
||||
|
||||
The current `computeFee(enteredAt, asOf, structure)` walks **elapsed** minutes through `blocks`. Time
|
||||
tiers add a **second clock**: the *wall-clock* time-of-day, which the elapsed walk doesn't track. A
|
||||
stay 13:30→15:30 that has happy hour 14:00–16:00 must be **split at 14:00**: 30 min normal + 90 min
|
||||
happy. So the fee function must:
|
||||
|
||||
1. Resolve the **applicable rate set** for the stay (all cards matching the category, ordered by
|
||||
precedence — see below).
|
||||
2. Walk the stay in wall-clock order, **switching the active card at each window boundary**, while
|
||||
keeping the **elapsed-duration position** in the block ladder continuous (so block steps and the
|
||||
daily cap still accrue across a window switch — a happy hour mid-stay must not reset the ladder).
|
||||
3. Keep it **pure, integer, offline, deterministic** — the same invariants the current engine and the
|
||||
[[append-only-event-chain|signed chain]] depend on. The `payment` event still records the
|
||||
`tariffVersionId`; the version now contains the windowed card set, so a past session reprices
|
||||
identically.
|
||||
|
||||
> Open edge: does the block ladder accrue by **elapsed time** (a 2h stay is in the 2nd block
|
||||
> regardless of windows) or **reset per window**? Legacy `IntervalChange` hints some sites reset.
|
||||
> **Lean: elapsed-continuous** (predictable, no double-charging), revisit if a site needs otherwise.
|
||||
|
||||
## Precedence (when windows overlap)
|
||||
|
||||
Multiple cards can match one instant (a weekday-evening card + a holiday card). Need a deterministic
|
||||
winner. Proposal, most-specific-wins, matching the research's "event rates override":
|
||||
`special-event/holiday > specific date range > day-of-week + hour > hour-only > default`. Ties broken
|
||||
by an explicit integer `priority`. This must be **total and pure** — no ambiguity the operator can't
|
||||
predict, no "depends on row order."
|
||||
|
||||
## Vehicle / customer category (the second new axis)
|
||||
|
||||
Legacy `BA_TicketCategory` prices by **category** (car/bus/VIP/…), orthogonal to time. Two ways:
|
||||
- **Multiple tariffs scoped by category** — the schema already reserves `tariffs.scope`
|
||||
(`site`/`zone`); add `category` cleanly, no migration. The session records which category it was
|
||||
priced under.
|
||||
- **Category as another window dimension** on the card. Simpler table, busier card.
|
||||
|
||||
**Lean: category as a tariff scope** (a category is a different rate *card*, not a different *window*
|
||||
of one). Deferred until a site actually needs non-car pricing, but the `scope` hook means **no
|
||||
migration when it lands**.
|
||||
|
||||
## Proposed data shape (illustrative)
|
||||
|
||||
Extend the `TariffStructure` JSON (still one immutable [[tariff]] version) with an optional ordered
|
||||
card list; absence = today's single-ladder behaviour (back-compatible):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"currency": "ALL",
|
||||
"defaultCard": { /* the existing blocks/cap/grace structure */ },
|
||||
"windowedCards": [
|
||||
{
|
||||
"name": "Happy hour",
|
||||
"priority": 10,
|
||||
"window": { "dow": [1,2,3,4,5], "fromHour": "14:00", "toHour": "16:00" },
|
||||
"blocks": [ /* cheaper ladder */ ],
|
||||
"dailyCapMinor": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A bare `defaultCard` (no `windowedCards`) is exactly today's tariff — so this ships additively and a
|
||||
site that never wants tiers never sees them. Keeps the **intuitive-for-operators** goal: the common
|
||||
case stays one rate card; tiers are opt-in.
|
||||
|
||||
## Open
|
||||
- Elapsed-continuous vs. per-window ladder reset (lean: elapsed-continuous).
|
||||
- Holiday/special-event calendar: a date list per version, or a separate editable calendar table?
|
||||
- Precedence model — confirm most-specific + explicit `priority` tiebreak.
|
||||
- Category axis — confirm "category = tariff scope" vs. window dimension (deferred).
|
||||
- UI: how to author windows without confusing operators (the notoriously-hard part — keep default
|
||||
card front-and-center, tiers as an "advanced" add).
|
||||
+35
-5
@@ -10,7 +10,7 @@ status: open
|
||||
|
||||
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
|
||||
not code** — the park owner builds and constantly edits the rate card at runtime (like a
|
||||
[[permit]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
|
||||
[[subscription]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
|
||||
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
|
||||
beyond the host).
|
||||
|
||||
@@ -124,11 +124,29 @@ time references**, not one:
|
||||
`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people
|
||||
who paid; too long gives free parking between pay and exit.
|
||||
|
||||
> **As-built correction (2026-06-17):** the overstay top-up reprices from **entry**, not `paidAt` —
|
||||
> `computeFee(enteredAt, now, …)` (so the timer never restarts; the customer pays the true entry→now
|
||||
> total). The line above (`f(paidAt, now, …)`) was the original sketch; the implementation uses entry.
|
||||
|
||||
### ⚠ Open question — walk-back grace renews on every payment
|
||||
|
||||
A consequence of the two-time-reference model, surfaced via the [[booth-exit-flow|booth exit /
|
||||
voucher]] path: every `payment` event stores its own `gracePeriodExit`, and the exit check reads the
|
||||
**latest** payment's value. So an **overstay top-up re-grants a full, fresh grace window** each time.
|
||||
The fee is correct (always recomputed from entry — no free exit), but the **walk-back grace doubles**
|
||||
(or repeats) on every top-up — a customer could pay → wait → pay a tiny delta → earn another window →
|
||||
repeat. The leak is **time, not money**, bounded by increment coarseness but real.
|
||||
|
||||
Candidate policies (business call): grant grace on a top-up **only when it charged new money**
|
||||
(recommended), a **single non-renewing window** from the first payment, or a **per-session grace
|
||||
cap**. Full analysis + the decided/undecided halves live in [[booth-exit-flow]]. Pick a policy before
|
||||
production.
|
||||
|
||||
## Permit holders
|
||||
|
||||
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription
|
||||
A valid [[subscription]] bypasses tariff computation entirely for the covered period (subscription
|
||||
already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for
|
||||
the uncovered time — an edge case to design with [[permit]].
|
||||
the uncovered time — an edge case to design with [[subscription]].
|
||||
|
||||
## Versioning — edits publish immutable, effective-dated versions
|
||||
|
||||
@@ -169,12 +187,24 @@ Unlike the event log, tariff data is **mutable master data** in the sense that n
|
||||
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
|
||||
[[open-questions]].
|
||||
|
||||
## Extensions under design
|
||||
|
||||
Two operator asks extend this engine; both have design pages (not yet built), grounded in
|
||||
[[parksql2017-legacy-schema|the legacy schema]] + external research:
|
||||
|
||||
- **Time-of-day / weekday / seasonal tiers** (happy hour, off-peak, weekend, vehicle category) —
|
||||
see [[tariff-time-tiers]]. Chosen shape: **time-windowed rate cards** selected by wall-clock window,
|
||||
layered additively on this structure (a bare default card = today's behaviour). The hard part is
|
||||
slicing a stay at window boundaries while keeping the block ladder + daily cap continuous.
|
||||
- **Validation & sponsorship** (merchant comps, coupons, **postpaid B2B** "enter/exit free, bill the
|
||||
business monthly") — see [[validation-sponsorship]]. A validation is a **typed modifier applied as a
|
||||
signed event** on a transient session, distinct from a [[subscription]]; postpaid sponsors accrue a
|
||||
monthly-invoiced liability derivable from the chain.
|
||||
|
||||
## Open
|
||||
|
||||
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
|
||||
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
|
||||
- **Time-of-day / weekday tiers** — not in the block model yet; add as a tier wrapper if a site
|
||||
needs day/night/weekend cards (deferred until asked).
|
||||
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
|
||||
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
|
||||
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
|
||||
|
||||
@@ -21,14 +21,35 @@ must have:
|
||||
- **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim
|
||||
someone else's cheaper session). Sequential **physical** stock numbering is a separate
|
||||
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
|
||||
- **All-numeric** (as-built 2026-06-17) — so ANY legacy 1D barcode scanner reads it and an operator
|
||||
can hand-key it. Random (not sequential), so "all-numeric" does not weaken the unguessable
|
||||
property. Format: **13 digits = 12 cryptographically-random digits + 1 Luhn check digit**
|
||||
(10^12 space → negligible collisions at lot scale; the Luhn digit lets manual entry reject a typo
|
||||
rather than fail as "session not found"). `newTicketId()` in `apps/server/src/entry-flow.ts`;
|
||||
validate with `validateTicketCode()` (gate MANUAL entry only — a scanned/looked-up id already in
|
||||
the ledger is authoritative regardless of format).
|
||||
- **Format is a property of minting, not the schema** — `identity` / `sessions.id` are free-form
|
||||
`text`, so changing the id format is a code change with **no migration**. Legacy `T-<uuid>` ids
|
||||
(pre-2026-06-17) remain valid keys and coexist with numeric ones.
|
||||
- **Single logical session** — scanning it at the pay station finds the open session; after payment
|
||||
it's the proof-of-paid the exit checks.
|
||||
|
||||
## Encoding: QR (preferred) — printed by the booth dispenser
|
||||
## Encoding: Code128 numeric barcode — printed by the booth dispenser
|
||||
|
||||
- The [[rongta-printer]] prints the ticket id as a **2D barcode (QR)** plus human-readable text and
|
||||
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap
|
||||
camera/imager to read.
|
||||
- The [[rongta-printer]] prints the ticket id as a **1D Code128 barcode** (the all-numeric code),
|
||||
with the **same code in large human-readable digits below it**, then the entry time. Code128 over
|
||||
QR for the primary symbology because the booth's reader hardware is unknown and a legacy 1D laser
|
||||
scanner is the lowest common denominator — and the printed digits mean total reader failure still
|
||||
leaves a hand-keyable code. A **QR for phone/imager scanning may be added later behind an admin
|
||||
toggle** (deferred — see Open).
|
||||
|
||||
> **As-built (2026-06-17).** `renderTicket()` in [[rongta-printer]]
|
||||
> (`packages/devices/src/drivers/printer-rongta.ts`) emits the Code128 via ESC/POS `GS k` (code set
|
||||
> B) — **rendered by the printer firmware**, so there is no image-rendering step and no new
|
||||
> dependency (keeps the MIT/Apache/BSD constraint). Resilience rationale: the booth's reader is
|
||||
> uncertain, so the id is carried in two independently-readable forms (1D barcode / printed digits).
|
||||
> The "operator scans with a phone" path reuses the
|
||||
> existing dispatch flow ([[entry-exit-readers]]) and is tracked separately (not yet built).
|
||||
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
|
||||
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
|
||||
payment ([[tariff]], pay-on-foot) → appends `payment`.
|
||||
@@ -51,7 +72,14 @@ isn't captured or is low-confidence (recognition is advisory — [[opencv-anpr-s
|
||||
|
||||
## Open
|
||||
|
||||
- QR symbology/error-correction level + what else prints (site name, tariff summary, help number).
|
||||
- Primary symbology **decided**: Code128 set B over the all-numeric id (as-built above). Still open:
|
||||
what *else* prints (site name, tariff summary, help number).
|
||||
- **Optional QR (deferred)** — an admin toggle to ALSO print a QR for phone/imager users. The
|
||||
`code128()`/`qrCode()` ESC/POS helpers were prototyped 2026-06-16; QR was dropped 2026-06-17 in
|
||||
favor of "1D barcode + hand-keyable numeric code" because the booth's reader hardware is unknown.
|
||||
Revisit when mobile scanning is wanted.
|
||||
- **Phone-scan fallback** (operator scans a ticket with a phone when a reader is down) — designed
|
||||
but not built: an authenticated route feeding the same dispatcher + a minimal mobile scan UI.
|
||||
- Scanner hardware (imager model; same unit at pay station and exit?).
|
||||
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
|
||||
amount).
|
||||
|
||||
@@ -39,8 +39,17 @@ Because each validation is signed and attributed (`issuedBy`), over-validation b
|
||||
merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is
|
||||
an anomaly), rather than invisible free parking.
|
||||
|
||||
## Postpaid sponsors
|
||||
|
||||
When the validating party is a **business with a postpaid agreement** (its customers park free, it's
|
||||
billed monthly) — not just a one-off discount — the **sponsor account + settlement** layer is in
|
||||
[[validation-sponsorship]]. That's the distinction between a discount (this page) and a *sponsored*
|
||||
session that accrues a receivable.
|
||||
|
||||
## Open
|
||||
|
||||
- Validation types the site needs (free hours / fixed amount / percentage / flat rate).
|
||||
- Validation types the site needs (free hours / fixed amount / percentage / flat rate) — superset in
|
||||
[[validation-sponsorship]] (`comp`/`percent`/`fixed`/`time-credit`/`rate-switch`).
|
||||
- Whether merchants self-serve (portal/terminal) or the operator applies it.
|
||||
- Caps (max discount, max per merchant/day).
|
||||
- Prepaid coupon pool vs. postpaid accrual — see [[validation-sponsorship]].
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, pricing, validation, design]
|
||||
sources: [parksql2017-legacy-schema]
|
||||
updated: 2026-06-17
|
||||
status: open
|
||||
---
|
||||
|
||||
# Validation & Sponsorship — merchant comps, coupons, postpaid B2B
|
||||
|
||||
Builds on [[validation-discounts]] (the signed-event discount mechanism) to add the layer it leaves
|
||||
open: **a sponsor account and postpaid B2B billing.** The driving case — **a nearby business with a
|
||||
postpaid agreement whose customers enter and exit freely, billed to the business monthly.**
|
||||
|
||||
> This page owns the **sponsor/account/settlement** model and the **permit-vs-validation
|
||||
> distinction**. The *how a discount is applied* mechanics (signed event, `due = max(0, fee −
|
||||
> discounts)`, attribution, anti-abuse) live in [[validation-discounts]] — not duplicated here.
|
||||
> Status: **design, not built.**
|
||||
|
||||
## Why this is NOT a permit (the key distinction)
|
||||
|
||||
| | [[subscription]] | Validation / sponsorship |
|
||||
| --- | --- | --- |
|
||||
| Subject | Known in advance; carries a credential (card/QR/plate) | Anonymous walk-in; identified only by the **ticket they were issued** |
|
||||
| When applied | At entry (credential opens the lane) | **After entry**, against an existing session — at a pay station, by a code, or by a sponsor rule |
|
||||
| Who pays | The subscriber, out-of-band | A **third party** (merchant/sponsor), or nobody (comp) |
|
||||
| Model fit | `permits` + credentials | New: a **validation event** on a session + a **sponsor account** |
|
||||
|
||||
A permit bypasses tariff computation; a validation **adjusts the computed fee** (or zeroes it). They
|
||||
compose — but they are different primitives.
|
||||
|
||||
## Two economic models (both real)
|
||||
|
||||
- **Prepaid** — merchant buys a pool of value up front (legacy `BA_Cupons`: printed single-use codes
|
||||
worth `DiscMinutes`; City Center research: merchant pre-buys time tickets 15 min→all-day).
|
||||
Reconciliation = count used codes against the pool.
|
||||
- **Postpaid** (the asked-for case) — merchant signs an agreement; their customers park free or
|
||||
discounted; the system **accrues each validation against a sponsor balance** and **invoices monthly**
|
||||
(City Center: "billed for the number of tickets validated each month," verified 3-0). No money moves
|
||||
at the lane.
|
||||
|
||||
The legacy system did **only prepaid coupons** — **the postpaid sponsor account is net-new** for this
|
||||
project.
|
||||
|
||||
## Modifier types (extends [[validation-discounts]])
|
||||
|
||||
The discount-type enum lives in [[validation-discounts]]; legacy `DiscType` (smallint) and research
|
||||
(Amano McGann / HUB J4M, abstained-not-refuted) confirm the set: `comp` / `percent` / `fixed` /
|
||||
`time-credit` (legacy `DiscMinutes`) / `rate-switch`. **Sponsorship adds one field** to a validation:
|
||||
a `sponsorId`. Full-comp + a sponsor = the "free entry/exit, bill the business" case.
|
||||
|
||||
## The sponsor-liability consequence (anti-fraud)
|
||||
|
||||
The validation is a signed event ([[validation-discounts]], [[append-only-event-chain]]); what
|
||||
**sponsorship** adds is that **free-to-the-parker is not free-to-the-ledger** — it is a *receivable
|
||||
from the sponsor*. Under the [[threat-model|operator-as-adversary]] model:
|
||||
|
||||
- A postpaid sponsor's "enter/exit freely" still **mints signed entry + exit events** (and snapshots)
|
||||
— the audit trail is identical to a paying car; only the **settlement target** differs.
|
||||
- The **sponsor's period liability = the sum of `sponsorId`-tagged validation events** over the
|
||||
period — derivable from the chain, reconcilable like a [[shift|shift Z-report]] and visible to
|
||||
[[reconciliation]] (a sponsor comping far more than plausible footfall is an anomaly).
|
||||
|
||||
## Proposed data shape (illustrative — design only)
|
||||
|
||||
```
|
||||
sponsors id, name, contact, mode {prepaid|postpaid},
|
||||
balance_minor (prepaid pool / postpaid accrual), billing_period, active
|
||||
validations id, session_id, sponsor_id?, type, amount_minor|minutes,
|
||||
code?, operator_id, created_at // append-only; one row per application
|
||||
(coupons) code, value_minutes|minor, single_use, used_at? // prepaid pool, optional
|
||||
```
|
||||
|
||||
- A **postpaid** sponsor: each full-comp validation appends a row and accrues `amount` to the
|
||||
sponsor; monthly invoice = sum over the period; exit is free at the lane.
|
||||
- **Free entry/exit "freely"**: either the sponsor issues credentials (then it's closer to a
|
||||
[[subscription]] — pick that path), or customers take a normal ticket and a sponsor rule / merchant code
|
||||
comps it at exit. The agreement wording decides which; **both are expressible.**
|
||||
|
||||
## Reconciliation & settlement
|
||||
- **Prepaid**: pool decrements; alert at low balance; no invoice.
|
||||
- **Postpaid**: accrue; **monthly statement** per sponsor (legacy/City Center cadence ~the 10th).
|
||||
Statement lines trace to signed validation events → disputes resolvable against the chain.
|
||||
|
||||
## Open
|
||||
- **"Enter/exit freely" mechanism**: sponsor-issued credentials ([[subscription]]-like) vs. ticket +
|
||||
comp-at-exit. Likely offer both; confirm the operator's actual deal shape.
|
||||
- Prepaid coupon format: printed codes (legacy) vs. QR vs. merchant web-validation portal.
|
||||
- Who may apply a validation, and the **per-operator cap** (a comp is a fraud vector — bound it and
|
||||
always sign it).
|
||||
- Invoicing: in-app statement only, or export for external billing? FX if sponsor bills in another
|
||||
currency (defer to [[tariff]] FX).
|
||||
- Partial-stay sponsorship (merchant covers first 2h, parker pays the rest) — `time-credit` or
|
||||
`rate-switch` covers it; confirm.
|
||||
@@ -19,7 +19,7 @@ The starting decision for the **business layer**, taken 2026-06-15 as the projec
|
||||
events. A cache table is allowed for query speed but is always rebuildable and never
|
||||
authoritative.
|
||||
2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first;
|
||||
layer [[permit]] holders on top as a second identity source that short-circuits payment
|
||||
layer [[subscription]] holders on top as a second identity source that short-circuits payment
|
||||
([[entry-exit-readers]]).
|
||||
3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a
|
||||
central station; the exit lane only validates the session is paid and within the walk-back
|
||||
@@ -48,7 +48,7 @@ pay-station and exit-validation flows. Schema (`packages/db`) + shared types fol
|
||||
|
||||
- Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]).
|
||||
- Tariff versioning (effective-dated) for historical repricing.
|
||||
- [[permit]] data model + lapsed-mid-stay handling.
|
||||
- [[subscription]] data model + lapsed-mid-stay handling.
|
||||
- Wire payment capture to a concrete pay-station terminal ([[open-questions]] #3) — kept abstract
|
||||
(payment = an independent signed event referencing a session) until procurement settles.
|
||||
- Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4
|
||||
|
||||
@@ -11,7 +11,7 @@ status: open
|
||||
The project's **QR-code reader** (GEE NFC LIMITED). A static optical scanner for **QR /
|
||||
DataMatrix / 1D barcode**, optional ID/IC card. This is the **[[ticket-encoding|QR ticket]]
|
||||
scanner** the design called for — read at the pay station and exit lane — and a path for **QR
|
||||
[[permit]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
|
||||
[[subscription]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
|
||||
(See [[gee-qr-er80|datasheet summary]] / `raw/`.)
|
||||
|
||||
## What it is (and isn't)
|
||||
@@ -39,7 +39,7 @@ GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2 chars>&
|
||||
This is **host-in-the-loop and SYNCHRONOUS**: the GET *is* the access query and **our reply is the
|
||||
decision** — it drives the reader's beep + output. So unlike a fire-and-forget reader, the endpoint
|
||||
must decide (valid/invalid, direction from `status`) and reply, then also emit a `DeviceReadEvent`
|
||||
on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[permit]]) to open the
|
||||
on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[subscription]]) to open the
|
||||
barrier. ([[device-input-flow]] is the analogous push pattern; this one also returns a verdict.)
|
||||
|
||||
> **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A
|
||||
|
||||
@@ -17,12 +17,12 @@ recognition **host-side on ordinary IP-camera snapshots**, replacing the dedicat
|
||||
|
||||
1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing
|
||||
`IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way
|
||||
a plate-bound [[permit]] is matched.
|
||||
a plate-bound [[subscription]] is matched.
|
||||
2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum
|
||||
`{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding).
|
||||
This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives
|
||||
in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at
|
||||
entry vs. exit (and vs. the [[permit]]'s known car) can. A plate that entered on a red hatchback
|
||||
entry vs. exit (and vs. the [[subscription]]'s known car) can. A plate that entered on a red hatchback
|
||||
but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role
|
||||
the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]].
|
||||
|
||||
@@ -64,7 +64,7 @@ guarantee is preserved. Recorded as an explicit exception in [[standing-decision
|
||||
## Anti-fraud / threat-model fit
|
||||
|
||||
- **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or
|
||||
vs. a [[permit]]'s registered car → anomaly. Doesn't *block* on its own (recognition is
|
||||
vs. a [[subscription]]'s registered car → anomaly. Doesn't *block* on its own (recognition is
|
||||
probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record.
|
||||
- The recognition result and the source image both attach to the signed [[append-only-event-chain]]
|
||||
entry, so the *evidence* is tamper-evident even though recognition itself is host-side and
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
type: entity
|
||||
tags: [parking, domain, business, subscriptions, identity]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Permit (Subscription)
|
||||
|
||||
A **subscription**: a known holder authorized to enter/exit without paying per-stay, for a covered
|
||||
period. The second of the "two populations" ([[entry-exit-readers]]); a valid permit
|
||||
**short-circuits the payment step** of a [[parking-session]] ([[session-model]]). Transient is
|
||||
built first; permits layer on top.
|
||||
|
||||
## Credentials (how a permit is presented) — confirmed with operator 2026-06-15
|
||||
|
||||
A permit is recognized by a credential read at the lane. Two kinds, mapping to the two identity
|
||||
paths:
|
||||
|
||||
- **RF tag / chip / card.** An RFID/proximity credential. Read **host-side** (reader → host →
|
||||
`pulseOpen`): autonomy isn't required (resolved below), and the [[dingtian-relay]] has no onboard
|
||||
card list anyway, so there's no need to route RF into a controller. A Wiegand-out reader is still
|
||||
fine and keeps a future autonomous path open ([[entry-exit-readers]]), but isn't required.
|
||||
- **QR code.** Read by the **optical reader** — inherently **host-side** ([[entry-exit-readers]]:
|
||||
pure optical/network readers are invisible to a controller). Host decodes the QR → looks up the
|
||||
permit → decides.
|
||||
|
||||
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
|
||||
already in the model) and whose value is the credential id.
|
||||
|
||||
## Two optional, independent bindings — confirmed 2026-06-15
|
||||
|
||||
A permit has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
|
||||
neither — the four combinations are all valid.
|
||||
|
||||
### 1. Car-count binding (default: 1)
|
||||
|
||||
- **Optional.** By default a permit is bound to **1 car at a time**. The admin may raise the limit
|
||||
(a household, a company fleet) or **unbind it entirely** (no cap on how many cars use it).
|
||||
- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the
|
||||
[[parking-session]] projection: at entry, count the permit's currently-open sessions; if
|
||||
`< maxConcurrent` (or unbound) allow, else reject (allowance full). This is exactly why
|
||||
sessions-as-projection matters — "how many of this permit's cars are inside right now" is a fold
|
||||
over open entry/exit events, **not a counter someone can edit**.
|
||||
|
||||
### 2. Plate binding (default: off)
|
||||
|
||||
- **Optional.** By default a permit is **not** plate-bound — any car may use it (identity is the
|
||||
card/QR). The admin may bind it to a set of specific licence plates.
|
||||
- When **bound**, an allowed plate is an **accepted identity in its own right** — a valid
|
||||
**card/QR OR a matching plate** opens the lane (either, not a second factor):
|
||||
|
||||
```
|
||||
entry: read card/QR → find permit → car-count ok → open
|
||||
OR LPR plate ∈ permit's bound plates → find permit → car-count ok → open
|
||||
```
|
||||
|
||||
- **Accepted tradeoff:** card-OR-plate is the most convenient but does **not** prevent
|
||||
card-sharing (a lent card still opens). Fine for a trusted permit population; the signed
|
||||
[[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible
|
||||
to [[reconciliation]] after the fact.
|
||||
- **Plate-spoofing defence:** a printed copy of a registered plate on a *different* car is caught
|
||||
not here but by the [[opencv-anpr-service]]'s **vehicle-attribute verification** — the seen car
|
||||
must reconcile with the permit's known car, not just the plate string.
|
||||
|
||||
> The two are independent: a plate-bound permit may have no car cap; a car-capped permit may accept
|
||||
> any plate. The binding fields are simply absent/null when a constraint isn't applied.
|
||||
|
||||
## Data model (first cut — to firm up with [[session-model]])
|
||||
|
||||
A `permits` table (and supporting rows). Unlike the event log, reference/master data like permits
|
||||
**is** mutable (an admin grants/revokes/renews) — but every *use* of a permit still produces a
|
||||
signed `vehicle_entry`/`vehicle_exit` event in the [[append-only-event-chain]], so the audit trail
|
||||
stays append-only even though the permit record itself is editable.
|
||||
|
||||
| Field | Notes |
|
||||
| --- | --- |
|
||||
| `id`, `holderName`/contact | the subscriber |
|
||||
| `credentials[]` | one or more: `{ kind: 'rf' \| 'qr', value }` |
|
||||
| `maxConcurrent` | car-count binding; **default 1**, raise for fleets, or `null` = unbound |
|
||||
| `plates[]` | plate binding; **default empty/false** = any car; when set, these plates are accepted identities |
|
||||
| `validFrom`, `validTo` | coverage window |
|
||||
| `status` | active / suspended / revoked |
|
||||
|
||||
> Both bindings are nullable/empty by default — a bare permit is "1 car at a time, any plate,
|
||||
> identified by its card/QR".
|
||||
|
||||
## Interaction with the session model
|
||||
|
||||
- **Entry:** credential read → permit lookup → valid (active, in window, plate allowed **if
|
||||
plate-bound**, concurrent cars `< maxConcurrent` **if car-bound**) → signed `vehicle_entry`
|
||||
(source = `wiegand`/`qr`/`lpr`), open barrier. No ticket, no fee. (A bare permit applies neither
|
||||
extra check — just active + in window.)
|
||||
- **Exit:** credential/plate read → matching open permit session → signed `vehicle_exit`, open. No
|
||||
payment required.
|
||||
- **Lapsed mid-stay:** permit expires while a car is parked → the uncovered time falls back to the
|
||||
transient [[tariff]] (edge case to design).
|
||||
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
|
||||
refused, per policy (OPEN).
|
||||
|
||||
## As-built (2026-06-15)
|
||||
|
||||
`apps/server/src/permit-flow.ts`, reached via the **read dispatcher**
|
||||
(`read-dispatch.ts`): a credential read routes to the permit flow if it **matches a permit**
|
||||
(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler
|
||||
serves both populations ([[entry-exit-readers]]), disambiguated by *what the credential is*.
|
||||
|
||||
- **Direction is inferred from session state for that car** — the read credential value is the
|
||||
per-car session key. No open session for that car → **ENTRY** (check `maxConcurrent`, sign
|
||||
`vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_exit`, open, close). A fleet
|
||||
permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an
|
||||
inside car is its exit, never a second entry).
|
||||
- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's
|
||||
`vehicle_entry` events whose car has no later exit; reject at the limit (`null` = unbound).
|
||||
- **Validity** (active + within `validFrom`/`validTo`) and **plate-OR-card identity** as designed.
|
||||
No ticket, no fee — the permit is the authorization; every use is still a signed ledger event
|
||||
carrying `permitId`.
|
||||
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
|
||||
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
|
||||
|
||||
**Admin CRUD** (`apps/server/src/routes/permits.ts` + `apps/web/src/PermitManager.tsx`): a permit is
|
||||
an **aggregate** (the row + its credentials + bound plates); create/update treat it as one unit
|
||||
(child sets are replaced on update). `GET /api/permits` (any signed-in role — for lookup),
|
||||
`POST/PUT/DELETE /api/permits[/:id]` + `POST /api/permits/:id/revoke` (**admin only**). Validation:
|
||||
`maxConcurrent` is a positive int or `null` (unbound); a permit must have **at least one credential
|
||||
or one bound plate** (else nothing identifies it). Revoke is the soft, common case (keeps history,
|
||||
barred at the barrier); DELETE hard-removes — past ledger events that reference the permit are
|
||||
untouched (the audit trail is append-only and independent). Verified via inject (validation, child
|
||||
replacement, RBAC, revoke/delete).
|
||||
|
||||
## Resolved (2026-06-15)
|
||||
|
||||
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
|
||||
unbound) and plate-binding (`plates[]`, **default off** = any car). Either, both, or neither.
|
||||
- **Plate vs. credential:** when plate-bound, **card/QR OR matching plate** — either is accepted
|
||||
identity (not a second factor); card-sharing not prevented by design, caught by
|
||||
[[reconciliation]] after.
|
||||
- **Autonomy:** **host-in-the-loop for everything** — no onboard card list needed, so the
|
||||
[[dingtian-relay]] stays sufficient (no new controller). Permit entry **fails closed** if the
|
||||
host is down ([[fail-state-safety]]). One code path for transient + permit.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Reader hardware** — confirm the RF reader and the QR/optical reader models (procurement;
|
||||
relates to [[bom]] and [[open-questions]]). RF need not be Wiegand now that autonomy isn't
|
||||
required, but a Wiegand-out reader keeps options open.
|
||||
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm
|
||||
with operator.
|
||||
@@ -2,14 +2,17 @@
|
||||
type: entity
|
||||
tags: [parking, stack, frontend]
|
||||
sources: [parking-system-architecture]
|
||||
updated: 2026-06-14
|
||||
updated: 2026-06-18
|
||||
---
|
||||
|
||||
# React + Vite SPA
|
||||
|
||||
The frontend: a React single-page app built with Vite, **served by [[fastify]]** (MIT). Plain
|
||||
React was chosen over an admin framework — see [[refine]], which was dropped because the
|
||||
operator UI is simple enough that a framework's abstractions cost more than they save.
|
||||
(See [[parking-system-architecture]] §2.)
|
||||
The frontend: a React single-page app built with Vite, **served by [[fastify]]** (MIT).
|
||||
(See [[parking-system-architecture]] §2.) Part of the [[technology-stack]].
|
||||
|
||||
Part of the [[technology-stack]].
|
||||
> **Updated 2026-06-18:** the original "plain React, no framework" choice (an admin *framework*
|
||||
> like [[refine]] was rejected) still holds — but the live operator console outgrew bare
|
||||
> `useState` and now layers in **TanStack Query + Router, Zustand, Tailwind v4, Radix, and
|
||||
> react-i18next**. These are libraries, not an admin framework, and each earns its place (live
|
||||
> updates, routing, the terminal theme, [[i18n]]). The full operator-UI architecture — including the
|
||||
> single `/api/ws` live feed — is in [[booth-console]].
|
||||
|
||||
@@ -38,7 +38,14 @@ many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
|
||||
## Ticket rendering
|
||||
|
||||
`printTicket(TicketData)` builds ESC/POS: `ESC @` init, centered/bold/double-size header,
|
||||
lane, ticket id, issued-at, feed + partial cut (`GS V B`). CP437/ASCII subset.
|
||||
lane, ticket id, issued-at, feed + partial cut (`GS V B`). CP437/ASCII subset. The entry ticket
|
||||
encodes the id as a **1D Code128** barcode (`GS k`).
|
||||
|
||||
**`printSubscriptionCard(SubscriptionCardData)`** (added 2026-06-18) renders a **2D QR** of the
|
||||
[[subscription]] code via ESC/POS **`GS ( k`** (model 2, EC level M) — firmware-rendered, no bitmap
|
||||
dependency — plus the code as text + holder/validity. Used for the auto-printed + reprintable
|
||||
subscription card. (Verified: the `GS ( k` store/print byte sequences + the embedded code appear on
|
||||
the wire against a TCP capture.)
|
||||
|
||||
## Status
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
---
|
||||
type: entity
|
||||
tags: [parking, domain, business, subscriptions, identity, pricing]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
status: open
|
||||
---
|
||||
|
||||
# Subscription
|
||||
|
||||
A **subscriber**: a known holder who parks on a **recurring plan** (e.g. **10,000 ALL / month**)
|
||||
instead of paying per stay. The second of the "two populations" ([[entry-exit-readers]]); a valid
|
||||
subscription **short-circuits the payment step** of a [[parking-session]] ([[session-model]]).
|
||||
Transient is built first; subscriptions layer on top.
|
||||
|
||||
> **Renamed 2026-06-18 (was "Permit").** The operator term is **subscription / abonim**, not
|
||||
> "permit / lejet". The master-data **tables/routes/UI/types were renamed** permit→subscription
|
||||
> (migration `0004`). The **signed ledger keeps its `permitId` payload field** — that is immutable,
|
||||
> hash-chained history, so renaming it would break verification of past events. So: *code & data =
|
||||
> "subscription"; the on-chain field name stays `permitId`.* See the schema note in `schema.ts`.
|
||||
|
||||
## Pricing — recurring monthly plan (built 2026-06-18)
|
||||
|
||||
Each subscription records its **own price**, so an individual and a company fleet can differ:
|
||||
|
||||
- `priceMinor` — the recurring price in **minor units** (integer; e.g. `1000000` = 10,000.00).
|
||||
`null` = no price set (a comp / legacy subscription).
|
||||
- `period` — the billing period. **`"monthly"` only** today (the enum is widened later if a site
|
||||
ever needs weekly/annual).
|
||||
- `currency` — ISO-4217 of `priceMinor` (e.g. `"ALL"`); required when a price is set.
|
||||
|
||||
A **site default monthly price** lives in `site_config.subscription_monthly_price_minor` — it
|
||||
merely **pre-fills** the new-subscription form; each subscription still stores its own value and may
|
||||
override.
|
||||
|
||||
### Multi-month: pay N months → extend `validTo` (built 2026-06-18)
|
||||
|
||||
A customer paying for **more than one month** is handled by the **coverage window**, not by separate
|
||||
records. The form takes a **`months`** count; with `validFrom` set, the server computes **`validTo =
|
||||
validFrom + N months`** (whole-month add, with day-overflow clamp — e.g. Jan 31 + 3mo → Apr 30). One
|
||||
subscription row, one window. The amount the operator should collect is **N × the monthly price**
|
||||
(the form previews `end date · total`); collection into the ledger is still deferred (below).
|
||||
|
||||
- `months` is **input-only** — it's not stored; the stored truth is `validFrom`/`validTo`. Renewing
|
||||
for more months is just editing the window (set a new `months` or an explicit `validTo`).
|
||||
- The validity check is unchanged: a session is allowed while the subscription is **active and
|
||||
`now` ∈ [validFrom, validTo]** — so a 3-month window simply stays valid for three months.
|
||||
- An explicit **`validTo` override** is still accepted (manual end date) when `months` isn't used.
|
||||
|
||||
### Collecting the fee is a SHIFT transaction (decided 2026-06-18, deferred build)
|
||||
|
||||
Selling/renewing a subscription is a **financial transaction a common operator makes during their
|
||||
[[shift]]** — the subscriber pays the monthly fee at the booth like any other customer. So it is
|
||||
**not** an admin-only master-data edit; the money must land in **that operator's shift**: their
|
||||
drawer (if cash) and their [[shift|Z-report]].
|
||||
|
||||
The clean way (the model already supports it): collection writes a signed **`payment`** ledger event
|
||||
— same shape the transient pay-station uses (`{ amountMinor, currency, tender }`) — at collection
|
||||
time, tagged with `{ subscriptionId }` so it's identifiable as subscription revenue.
|
||||
|
||||
- It folds into the shift automatically: the Z-report sums `payment` events in `[start, end]` **by
|
||||
payment time**, and the drawer fold adds **cash** tenders (card settles to the bank) — no new
|
||||
summing logic needed. The fee lands in **whichever shift was open when it was taken**, attributed
|
||||
to that operator. (See [[shift]] "drawer balance".)
|
||||
- **Admin** still edits the subscription master data (price, window, credentials); the **operator**
|
||||
takes the money. Two different acts.
|
||||
- A subscription's own [[parking-session|entry/exit]] events stay **free** (no per-stay `payment`) —
|
||||
only the *plan fee* is a payment, decoupled from any individual stay.
|
||||
|
||||
> **Deferred build.** Today we only *record* the agreed price + coverage window
|
||||
> (`validFrom`/`validTo`); no collection event is written yet, so subscription revenue does not flow
|
||||
> into the drawer/Z-report or [[reconciliation]]. Open detail when built: whether to model it as a
|
||||
> plain `payment` (simplest, folds today) or a distinct `subscription_payment` type (clearer in
|
||||
> reports, but the shift/drawer fold would need to count it too). Leaning **plain `payment` +
|
||||
> `subscriptionId` tag**. (Decision 2026-06-18: store price now, collect-in-shift later.)
|
||||
|
||||
## Credentials (how a subscription is presented) — confirmed 2026-06-15
|
||||
|
||||
Recognized by a credential read at the barrier. The operator **chooses the credential type** per
|
||||
subscription. Two kinds, mapping to the two identity paths, and **either can be combined with
|
||||
LPR/ANPR plate identity** (the plate binding below):
|
||||
|
||||
- **QR code — the only type live today (2026-06-18).** Read by the optical reader — inherently
|
||||
**host-side** ([[entry-exit-readers]]). Host decodes the QR → looks up the subscription → decides.
|
||||
A subscription's QR can be **printed**. The new-subscription form **defaults to QR**.
|
||||
- **The code is AUTO-GENERATED server-side** (`SUB-<15× base32>`, crypto-random, checked
|
||||
globally-unique). The operator never types it and the customer can't pick it — anti-fraud
|
||||
(a chosen value could be guessable or collide). The UI sends a blank QR credential; the server
|
||||
mints the value and returns it (so the UI can print it). **An RF credential, by contrast, carries
|
||||
the physical card id, so it is operator-entered.**
|
||||
- **Reader output = TCP/IP full string** (decided 2026-06-18, the [[gee-qr-er80|host-in-the-loop
|
||||
QR reader]] path): the reader delivers the whole decoded string, so the code length is free
|
||||
(unguessable token). *If a site ever wires the reader as **Wiegand 26/34** instead, a scanned
|
||||
QR truncates to a 24-/32-bit number — the generated code would then have to be a numeric id in
|
||||
that range. Not our path today.* (Manufacturer reader: ID/IC/NFC + QR/barcode; Wiegand 26/34 /
|
||||
TCP/IP / USB / RS485; 125 kHz + 13.56 MHz — one device covers QR **and** future RFID.)
|
||||
- **The card is PRINTED so the operator can hand it over.** On creation the server **auto-prints**
|
||||
a subscription card on the booth printer ([[rongta-printer]], role `booth-receipt`, failing over
|
||||
to the dispenser): park header → a **real scannable QR** of the code → the code as text (hand-key
|
||||
fallback) → holder + validity window. Printing is **best-effort** — a print failure never fails
|
||||
the create (the subscription + code are saved); the response returns `{ printed, printError }` and
|
||||
the UI warns + offers **"Print code"** (reprint via `POST /api/subscriptions/:id/print`) for a
|
||||
failed print / lost card / re-hand. The QR is rendered by the printer firmware via ESC/POS
|
||||
**`GS ( k`** (model-2, error-correction M) — added to the Rongta driver
|
||||
(`printSubscriptionCard`), no image/bitmap dependency (same approach as the Code128 ticket).
|
||||
- **RF tag / chip / card — selectable later, NOT live yet.** An RFID/proximity credential, read
|
||||
**host-side** (reader → host → `pulseOpen`). **LIVE since 2026-06-18** — the operator selects RFID
|
||||
and **reads the card off a physical reader** (see "Enrolling a card" below) rather than typing the
|
||||
number. The GEE readers are combo QR + RFID (ID/IC/NFC), so the same device captures both. A
|
||||
Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]); the
|
||||
[[dingtian-relay]] has no onboard card list.
|
||||
- **Plate (LPR/ANPR) — NOT YET IMPLEMENTED.** When plate-bound (below), a matching plate read is an
|
||||
accepted identity too. The vision/ANPR service that produces plate reads is future work
|
||||
([[opencv-anpr-service]] / [[lpr-camera]]); until it exists, plate binding has no live source.
|
||||
|
||||
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
|
||||
already in the model) and whose value is the credential id.
|
||||
|
||||
### Enrolling a card — "Read card" capture (built 2026-06-18)
|
||||
|
||||
RFID values are awkward to type, so the operator **presents the physical card to a chosen reader**
|
||||
and the system captures it into the credential. The catch is that the readers are also serving live
|
||||
traffic, so capture must **borrow one reader briefly without blocking the other**:
|
||||
|
||||
- **`CredentialCapture`** (in-memory, single-site): the operator picks a reader and **arms** it
|
||||
(`POST /api/subscriptions/capture/arm {deviceId}`). It is **single-shot + a ~30 s TTL**.
|
||||
- In the reader route (`qr-reader.ts`), each read first checks `tryConsume(deviceId, value)`: if
|
||||
**this** reader is the armed one, the value is captured and the read is **NOT dispatched to the
|
||||
access flow** (no barrier opens for a card being enrolled), then capture auto-disarms. A read on
|
||||
**any other reader dispatches normally** — the live entry/exit flow on the other barrier is never
|
||||
blocked. (Accepted trade: while armed, a real customer at the *armed* reader is captured instead of
|
||||
admitted — kept tiny by single-shot + TTL.)
|
||||
- The booth form **polls** `GET /api/subscriptions/capture` (idle | armed | captured | expired);
|
||||
on `captured` it drops the value into the RFID field. `POST …/capture/cancel` disarms.
|
||||
- Verified end-to-end (12/12): captured-not-dispatched (no ledger write), single-shot, the other
|
||||
reader still drives a live `vehicle_exit` while armed, value retrievable, cancel/expiry.
|
||||
|
||||
> The same mechanism would work to capture a **QR** too, but QR codes are server-generated + printed,
|
||||
> so capture is RFID-only in practice (QR has nothing to read off a card).
|
||||
|
||||
### Multiple credentials, and entry decoupled from exit (2026-06-18)
|
||||
|
||||
A subscription is a one-to-many aggregate: it may hold **several credentials at once** — e.g. a QR
|
||||
**and** an RFID card (and later NFC). Each is its own `subscription_credentials` row; any of them
|
||||
resolves the same subscription at the barrier. (NFC works today as an `rf` credential on the combo
|
||||
GEE reader; a distinct `nfc` `kind` is a small future labelling-only addition.)
|
||||
|
||||
Crucially, **entry and exit are NOT bound to the same credential.** Originally the session was keyed
|
||||
by the exact credential value read, so you had to leave with whatever you arrived with — an
|
||||
*accidental* coupling. Now sessions are keyed by a **subscription occurrence** (`SUBSESS-<subId>-<uuid>`),
|
||||
so you can **enter with the QR and exit with the card**. The mechanics (barrier-decides-direction,
|
||||
FIFO close, fleet support) are in "As-built" below.
|
||||
|
||||
## Two optional, independent bindings — confirmed 2026-06-15
|
||||
|
||||
A subscription has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
|
||||
neither.
|
||||
|
||||
### 1. Car-count binding (default: 1)
|
||||
|
||||
- **Optional.** By default bound to **1 car at a time**. The admin may raise the limit (a household, a
|
||||
company fleet) or **unbind it entirely** (no cap).
|
||||
- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the [[parking-session]]
|
||||
projection: at entry, count the subscription's currently-open sessions; if `< maxConcurrent` (or
|
||||
unbound) allow, else reject. A fold over the signed ledger, **not a counter someone can edit**.
|
||||
|
||||
### 2. Plate binding (default: off)
|
||||
|
||||
- **Optional.** By default not plate-bound — any car may use it (identity is the card/QR). The admin
|
||||
may bind it to a set of specific plates; a matching plate then **is an accepted identity**
|
||||
(card/QR **OR** plate, not a second factor).
|
||||
- **Accepted tradeoff:** card-OR-plate doesn't prevent card-sharing; the signed
|
||||
[[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible to
|
||||
[[reconciliation]]. Plate-spoofing (a printed plate on a different car) is caught by the
|
||||
[[opencv-anpr-service]]'s vehicle-attribute verification, not here.
|
||||
|
||||
## Time-of-day access windows — DESIGN NOTE, NOT YET IMPLEMENTED (2026-06-18)
|
||||
|
||||
A subscription may be valid **only during certain hours of the day**, behaving as a normal transient
|
||||
customer outside them. The motivating case: an **overnight subscriber** allowed in on their
|
||||
subscription **19:00 → 07:00**, but charged the normal [[tariff]] if they park during the day.
|
||||
|
||||
Intended behaviour (to design + build later):
|
||||
|
||||
- The subscription carries one or more **recurring daily time windows** (e.g. `[{ from: "19:00",
|
||||
to: "07:00", days: [...] }]`). Windows may **wrap past midnight** (19:00→07:00 spans two calendar
|
||||
days) — the check must handle the wrap.
|
||||
- **At ENTRY**, evaluate the window against the host clock ([[clock-integrity]]):
|
||||
- **inside the window** → subscription entry (no ticket, no fee), exactly as today;
|
||||
- **outside the window** → the car is treated as a **normal transient**: it takes a ticket and
|
||||
pays the [[tariff]] on the way out. The subscription is simply *not used* for this stay.
|
||||
- **The boundary cases need a decision** (flagged, not resolved):
|
||||
- *Enters inside the window, exits outside it* (parks past 07:00): is the whole stay free
|
||||
(entry-time decides), or is the over-window time charged transient (like
|
||||
[[tariff|lapsed-mid-stay]])? Leaning **entry-time decides** for simplicity, but confirm.
|
||||
- *Day-of-week scope* (weekdays vs. weekends), holidays.
|
||||
- Interaction with `maxConcurrent` and plate binding (orthogonal — should still apply).
|
||||
- **Data:** a child table (e.g. `subscription_windows`) or a JSON column on `subscriptions`; TBD with
|
||||
the implementation. Legacy precedent exists — the ParkSQL2017 schema had
|
||||
`MembershipPlansTime` / `ActiveDays` ([[parksql2017-legacy-schema]] §"time-/day-restricted
|
||||
memberships"), confirming this is a real market need.
|
||||
|
||||
> **Explicitly postponed.** For now this is documentation only — no schema, no enforcement. A
|
||||
> subscription is valid whenever it is active and within `validFrom`/`validTo`, all day.
|
||||
|
||||
## Data model (as-built 2026-06-18)
|
||||
|
||||
Tables (mutable master data; every *use* still produces a signed `vehicle_entry`/`vehicle_exit`):
|
||||
|
||||
| Table / field | Notes |
|
||||
| --- | --- |
|
||||
| `subscriptions.id`, `holderName`, `contact` | the subscriber |
|
||||
| `subscriptions.priceMinor` / `period` / `currency` | recurring plan (monthly); null price = unset |
|
||||
| `subscriptions.maxConcurrent` | car-count binding; **default 1**, raise for fleets, `null` = unbound |
|
||||
| `subscriptions.validFrom` / `validTo` / `status` | coverage window; active / suspended / revoked |
|
||||
| `subscription_credentials[]` | `{ kind: 'rf' \| 'qr', value }` |
|
||||
| `subscription_plates[]` | bound plates (accepted identities when set) |
|
||||
|
||||
## Interaction with the session model
|
||||
|
||||
- **Entry:** credential read → subscription lookup → valid (active, in window, plate allowed **if
|
||||
plate-bound**, concurrent cars `< maxConcurrent` **if car-bound**) → signed `vehicle_entry`
|
||||
(`source = wiegand/qr/lpr`), open barrier. No ticket, no fee.
|
||||
- **Exit:** credential/plate read → matching open subscription session → signed `vehicle_exit`, open.
|
||||
- **Lapsed mid-stay:** subscription expires while parked → uncovered time falls back to the transient
|
||||
[[tariff]] (edge case to design — and the same question the time-window boundary raises above).
|
||||
- **Revoked:** a revoked subscription fails the entry check → treated as transient or refused (OPEN).
|
||||
|
||||
## As-built (2026-06-15, renamed + priced 2026-06-18)
|
||||
|
||||
`apps/server/src/subscription-flow.ts` (was `permit-flow.ts`), reached via the **read dispatcher**
|
||||
(`read-dispatch.ts`): a credential read routes to the subscription flow if it **matches a
|
||||
subscription** (card/QR credential, or a bound plate) — otherwise to the transient exit flow.
|
||||
|
||||
- **Any credential opens/closes — sessions keyed by SUBSCRIPTION, not credential** (changed
|
||||
2026-06-18). A subscriber can **enter with their QR and exit with their RFID card** (or any mix).
|
||||
Entry mints a fresh **per-occurrence** session id (`SUBSESS-<subId>-<uuid>`, the ledger `identity`)
|
||||
with `payload.permitId = subId`; the credential read is decoupled from the session key. See "Entry
|
||||
decoupled from exit" below.
|
||||
- **Direction = the BARRIER the reader sits at.** An entry-lane read is an ENTRY, an exit-lane read
|
||||
is an EXIT; a `"both"` barrier infers from open state (open occurrence → exit, else entry). This is
|
||||
what lets a **fleet** (`maxConcurrent > 1`) admit several cars (each entry-lane read is an entry)
|
||||
*and* exit any of them with any credential.
|
||||
- **Exit closes the OLDEST open occurrence (FIFO).** Per-car identity within a fleet isn't tracked
|
||||
(it never was, once credentials are shared) — a read closes one occurrence, oldest first. An exit
|
||||
read with nothing open is a no-op anti-passback signal (signed `anomaly`).
|
||||
- **`maxConcurrent`** enforced as a fold over the signed ledger by occurrence (`payload.permitId`
|
||||
match). Refusals (revoked / out-of-window / at-capacity / exit-with-nothing-open) are signed
|
||||
`anomaly` events.
|
||||
- **Admin CRUD** (`apps/server/src/routes/subscriptions.ts` + `apps/web/src/SubscriptionManager.tsx`):
|
||||
a subscription is an **aggregate** (row + credentials + bound plates + price). `GET
|
||||
/api/subscriptions` (any signed-in role — for lookup), `POST/PUT/DELETE /api/subscriptions[/:id]` +
|
||||
`POST /api/subscriptions/:id/revoke` (**admin only**). Validation: `maxConcurrent` positive int or
|
||||
`null`; `priceMinor` non-negative int (currency required when set); at least one credential or one
|
||||
bound plate.
|
||||
- **Pricing** stored on each subscription (`priceMinor`/`period`/`currency`), pre-filled from
|
||||
`site_config.subscription_monthly_price_minor`; **fee collection into the ledger is deferred**
|
||||
(see Pricing above).
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]],
|
||||
[[open-questions]]).
|
||||
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm.
|
||||
3. **Subscription-fee collection** — a **shift transaction** (operator takes the monthly fee at the
|
||||
booth → signed `payment` → folds into their drawer/Z-report). Deferred build; see Pricing.
|
||||
4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy
|
||||
above (see the design note).
|
||||
+14
-4
@@ -1,13 +1,13 @@
|
||||
---
|
||||
type: overview
|
||||
tags: [parking, index]
|
||||
updated: 2026-06-14
|
||||
updated: 2026-06-18
|
||||
---
|
||||
|
||||
# Index
|
||||
|
||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||
Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
|
||||
Counts: 4 sources · 19 entities · 42 concepts · 5 decision records.
|
||||
|
||||
## Overview & navigation
|
||||
- [[overview]] — the top-level synthesis and entry point.
|
||||
@@ -18,6 +18,7 @@ Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
|
||||
- [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions.
|
||||
- [[gee-qr-er80]] — datasheet: GEE QR access reader (QR/DM/1D; Wiegand/RS-232/485/USB/TCP; Linux).
|
||||
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
|
||||
- [[parksql2017-legacy-schema]] — predecessor SQL Server schema (Albanian market): legacy tariff/discount/membership/shift/fiscal model; confirms blocks, adds time-windows + categories, lacks postpaid sponsors.
|
||||
|
||||
## Entities — technology stack
|
||||
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
||||
@@ -63,6 +64,7 @@ Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
|
||||
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
||||
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
||||
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
||||
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||
|
||||
@@ -78,19 +80,27 @@ Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
|
||||
## Concepts — business domain
|
||||
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||
- [[shift]] — manned-only accountability period; explicit Start/End (not time-based); End → signed + printed Z-report (cash + POS).
|
||||
- [[tariff-time-tiers]] — design: happy-hour/off-peak/weekend/seasonal + vehicle categories via time-windowed rate cards.
|
||||
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
||||
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts.
|
||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
||||
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
||||
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time.
|
||||
- [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit.
|
||||
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
|
||||
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
|
||||
- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt.
|
||||
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
|
||||
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
|
||||
- [[permit]] — subscription; RF/QR or plate identity, registered-cars + max-concurrent, host-in-loop; short-circuits payment.
|
||||
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
|
||||
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness).
|
||||
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
|
||||
|
||||
## Concepts — frontend / operator UI
|
||||
- [[booth-console]] — operator-UI architecture: TanStack Query/Router + Zustand + Tailwind terminal theme; one /api/ws live feed (anti-CSWSH); shift control + device-status footer.
|
||||
- [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian.
|
||||
|
||||
## Dev environment (reference)
|
||||
- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin.
|
||||
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
|
||||
|
||||
+100
@@ -744,3 +744,103 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
||||
builds; no test suite in-repo.
|
||||
- Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages
|
||||
(device-events, device-input-flow, ticket-encoding, etc.) — flagged for a later lint pass.
|
||||
|
||||
## [2026-06-16] build | Scannable ticket — QR + Code128 on the Rongta dispenser
|
||||
|
||||
`renderTicket()` now emits the ticket id as a printer-generated QR (ESC/POS `GS ( k`, model 2, ECC M) AND a Code128 1D barcode (`GS k`, set B), plus the human-readable id. Three independently-readable forms so a dead reader is recoverable (imager / 1D laser / phone camera / hand-keyed). No image rendering, no new dependency. Phone-scan operator fallback deferred (reuses the existing dispatch path). See [[ticket-encoding]].
|
||||
|
||||
## [2026-06-17] build | Ticket id -> all-numeric 13-digit (12 random + Luhn); barcode-only ticket
|
||||
|
||||
Replaced the `T-<uuid>` ticket id with a 13-digit all-numeric code (12 crypto-random digits + Luhn check) in `newTicketId()` so ANY legacy 1D barcode scanner reads it and the operator can hand-key it on total reader failure. Random keeps the unguessable anti-fraud property; Luhn lets manual entry reject typos (`validateTicketCode()`). `renderTicket()` now prints a centered Code128 barcode, the code in large digits below, then the issue time — QR dropped (may return as an admin toggle for mobile users). NOT a schema change: `identity`/`sessions.id` are free-form text; legacy ids coexist. See [[ticket-encoding]].
|
||||
|
||||
## [2026-06-17] build | Park metadata in site_config + ticket header
|
||||
|
||||
Extended `site_config` (single-row) with optional park identity: `park_name`, `operator_name`, `vat_number`, `registration_number`, `address`, `phone`, `email` — all nullable text (Drizzle migration 0001, additive). `GET`/`PUT /api/site-config` now read/write the full config (PUT is a partial patch; admin only); `SiteSettings.tsx` gained the fields. `renderTicket()` prints a header (park name large or "PARKING", then operator/VAT/Reg/address, plus a "Lost ticket? <phone>" footer) sourced from `site_config` via `EntryFlow.#ticketHeader()`. Open: non-ASCII (accent) chars need a printer codepage. See [[site-metadata]], [[ticket-encoding]].
|
||||
|
||||
## [2026-06-17] build | Ticket in Albanian; VAT->NIUS, drop registration; CP852 codepage
|
||||
|
||||
Ticket header now prints in Albanian and uses NIUS instead of VAT. Renamed `site_config.vat_number` -> `nius` and DROPPED `registration_number` (regenerated migration 0001 in place; only the dev DB had it, so no migration debt; dev DB reset + re-migrated). `renderTicket()`: NIUS line (only if set), "Printuar më:" before the timestamp, "Keni humbur biletën? <phone>" footer; strings centralised in a `STR` table for future i18n. Added CP852 (Latin-2) codepage support (`ESC t 18` + a Unicode->CP852 `line()` encoder with ASCII fallback) so `ë`/`ç` render. Touched: schema, migration, routes/site.ts, web api.ts + SiteSettings.tsx, devices interfaces + printer-rongta.ts, entry-flow.ts. Byte-verified ë->0x89. See [[site-metadata]], [[ticket-encoding]].
|
||||
|
||||
## [2026-06-17] ingest | ParkSQL2017 legacy schema + tariff research
|
||||
|
||||
Ingested `raw/parksql2017-legacy-schema.sql` (predecessor SQL Server 2017 schema, decoded from UTF-16; Albanian market — NIVF fiscal codes, Cupons, LostPrice1..4). Source summary in [[parksql2017-legacy-schema]]. Combined with a deep-research run (5 claims verified 3-0/2-0; synthesis + 20 claims aborted on a session limit — treat those as unverified, not refuted). Filed two design pages: [[tariff-time-tiers]] (happy-hour/off-peak/weekend/seasonal + vehicle categories via time-windowed rate cards; the hard part is wall-clock stay-slicing with a continuous block ladder) and [[validation-sponsorship]] (sponsor accounts + postpaid B2B billing; distinct from [[permit]]). Reconciled with the existing [[validation-discounts]] (cross-linked, no duplication — that page owns the signed-event discount mechanism, the new one owns sponsor/settlement). Updated [[tariff]] (new "Extensions under design" section; removed the now-addressed time-tier open item). Legacy confirms our stepped ladder + per-rate lost penalty + typed session discount; adds time-of-day windows + category axis; lacks any postpaid sponsor model (net-new). Flagged legacy anti-patterns we deliberately reject: float money, mutable rate rows, in-row image BLOBs.
|
||||
|
||||
## [2026-06-17] build | Booth actions — ticket input, pay/exit modal, voucher, snapshots
|
||||
|
||||
Made the booth screen operational (was a passive monitor). Backend: `site_config.exit_voucher_default` (additive migration 0002); `GET /api/session/:identity` (lookup + quote in one read), `POST /api/exit` (booth-driven, VALIDATED exit — reuses ExitFlow's paid+grace checks, no booth bypass; signs vehicle_exit + pulses an exit relay resolved via firstRelayByDirection; payment never rolled back, relay-open failure → signed anomaly + opened:false), `POST /api/voucher` (reprint paid ticket id barcode on the booth printer). Refactored ExitFlow into shared #signExit/#fireExitSnapshot/#closeSessionCache so the reader and booth paths are one validated code path. Frontend: ticket input on /booth (HID-scanner-friendly), Radix pay/exit modal (entry/now/duration/total + tender + 'Printo biletë dalje' checkbox defaulting from site_config), entry/exit SnapshotStrip (thumbnails → zoom). Verified end-to-end in-browser: scan → modal shows ALL 200 + real entry photo → pay → "barrier opened"; ledger recorded entry→payment→vehicle_exit in order; chain verify ok after. Decision recorded in [[booth-exit-flow]]. Unpaid exit correctly 409-refused (threat-model). NOT yet covered: voucher print success path (dev printers physically offline), automated tests.
|
||||
|
||||
## [2026-06-17] query | Walk-back grace renews on every payment (voucher overstay)
|
||||
|
||||
User flagged: customer pays, takes an exit voucher, dawdles past grace. Traced exit-flow.ts + pay-station.ts. Findings: refuse-on-expiry ✓ (no free exit) and reprice-from-entry ✓ (timer never restarts — `computeFee(enteredAt, now)`, NOT from paidAt) are both correct and deliberate. BUG: each `payment` writes its own `graceExitMin` and the exit flow reads the LATEST one, so every top-up re-grants a full walk-back window → grace doubles/repeats. Leak is TIME not money (fee always catches up from entry), bounded by increment coarseness but real. Flagged as an open question in [[booth-exit-flow]] (full analysis + 3 candidate fixes) and [[tariff]] (cross-ref + corrected the original `f(paidAt,now)` sketch to the as-built entry-based reprice). Decision deferred — fairness vs. anti-abuse business call. Recommended fix: grant grace on top-up only when it charged new money.
|
||||
|
||||
## [2026-06-18] build | Active sessions panel + audited barrier re-open
|
||||
|
||||
Operator escape hatch for stuck cars (damaged ticket / dead scanner / phantom barrier re-close). Key model from operator: the barrier state is ASSUMED not confirmed, so a session is "active" while OPEN or exited-but-within-grace — payment and a successful voucher scan do NOT remove it; only grace expiry does. Backend: PayStation.activeSessions() (one ledger fold, open OR within-grace, newest first), GET /api/sessions/active; ExitFlow.reopenBarrier() + POST /api/barrier/reopen — re-pulses an exit relay and signs an attributed `anomaly` (barrierReopen, operator), NEVER a 2nd vehicle_exit. Guard: no payment → 409 refuse (no-unpaid-bypass), enforced server-side AND the UI hides the button. Frontend: ActiveSessions panel on /booth (live via WS invalidation + 15s poll for grace expiry), row click → pay/exit modal, "Open barrier" only on paid rows. Verified in-browser: in-grace session stayed listed as "exiting" with the button; unpaid rows had none; click → audited anomaly #53 [op=boothtest], vehicle_exit count stayed 1 (no double-count); chain verify ok. Design recorded in [[booth-exit-flow]] (Active sessions & human-intervention barrier open). Standing gap: still no automated tests.
|
||||
|
||||
## [2026-06-18] build | Drawer balance — opening float carry-over + admin cash movements
|
||||
|
||||
Cash drawer that carries across shifts. New signed `cash_movement` ledger event type (shared); admin-only POST /api/cash-movement {amountMinor signed +load/-remove, reason}. ShiftService: #drawerBalanceAt(time) folds cash payments + cash_movements BY TIME (not operator — the movement is the admin's); open() auto-inherits openingFloat = drawerBalanceAt(start) and records it on shift_open; close() Z-report adds openingFloat/cashAdded/cashRemoved/expectedDrawer (= opening + taken + added − removed = next shift's opening float). Card payments excluded (settle to bank). GET /api/shift/current returns live drawerMinor. Frontend: ShiftControl shows live drawer + admin Load/Remove form + full Z-report drawer block (admin gate via router context). Verified the canonical scenario on a FRESH DB: load 5000 → shift1 takes 6500 → expected 11500 → shift2 inherits 11500, admin removes 5000, takes 4500 → expected 11000 → shift3 inherits 11000; chain ok. Also verified through the real UI (load/remove → Z-report opening 11200 removed 5000 expected 6200; both cash_movements signed+attributed; chain ok). Decision + worked example in [[shift]] (Drawer balance section). Standing gap: still no automated tests.
|
||||
|
||||
## [2026-06-17] build | Live booth WebSocket feed (/api/ws)
|
||||
|
||||
Added @fastify/websocket. EventLog.append fires a read-side onAppended callback after each durable insert (never touching the sign/chain path); device-events gained a `ledger` channel (emitLedger). New GET /api/ws fans out ledger + recomputed occupancy + printer-status to authenticated booth clients. Auth: JWT cookie (same as REST) + an Origin allowlist (WS_ALLOWED_ORIGINS) that REPLACES CSRF — a browser WebSocket can't send the double-submit header, so without an Origin check the read-only feed is open to Cross-Site WebSocket Hijacking (found + fixed by automated security review). See [[booth-console]], [[append-only-event-chain]].
|
||||
|
||||
## [2026-06-17] build | Frontend foundation — Tailwind terminal theme, Query/Router/Zustand, live booth screen
|
||||
|
||||
Operator UI outgrew plain React. Added TanStack Query (server state, wraps apiFetch), TanStack Router (role-guarded routes), Zustand (small client state: WS status + live feed), Tailwind v4 with a Bloomberg-terminal theme + Radix primitives. A /api/ws client invalidates Query caches on ledger pushes. Built the live /booth screen (occupancy gauge + streaming entry/exit/payment ticker). Vite proxies the WS upgrade. SUPERSEDES the "plain React, no framework" note on [[react-vite-spa]]. See [[booth-console]].
|
||||
|
||||
## [2026-06-18] build | i18n — Albanian default + English, per-user server-stored preference
|
||||
|
||||
Two languages via react-i18next, Albanian default/fallback. Language is a per-user preference: users.language (migration 0003), returned from login/me, changed via PUT /api/auth/language (NOT in the JWT — no re-login). Loaded on login, restored from any booth; SQ/EN header toggle persists. Type-safe key parity (en mirrors sq or the build fails). Translated booth + Login/Shift/Site/Permits/Tariff. SetupWizard deferred (server-provided catalog strings need backend i18n). Printed tickets stay Albanian (customer-facing). See [[i18n]], [[booth-console]].
|
||||
|
||||
## [2026-06-18] lint | Reconcile wiki with the session's work
|
||||
|
||||
Audited wiki vs. the session: three major builds (live WebSocket, frontend foundation, i18n) had NO log entry and NO concept page. Filed [[i18n]] (resolved a dangling code-comment link) and [[booth-console]] (operator-UI architecture: stack, /api/ws live feed, anti-CSWSH, booth screen). Updated stale [[react-vite-spa]] (the "plain React, no framework" claim is now qualified). Backfilled the three missing build log entries. Standing gaps flagged across pages: NO automated tests (front or back); ATECC608 not yet wired (software-HMAC signing is tamper-evident, not tamper-proof); pre-existing admin screens not on the terminal theme.
|
||||
|
||||
## [2026-06-18] ingest | Shift gating — site-wide single-open, booth money-path gate, per-shift logs
|
||||
|
||||
Built the shift-enforcement model. A shift is now **site-wide single-open** (was per-operator): `ShiftService.currentOpenShift()` reads the most recent shift event on the whole chain; `open()` refuses if ANY shift is open and throws `ShiftAlreadyOpenError{heldBy}`. Login stays decoupled from shifts (operator can log in off-shift to review). The booth money path is **gated**: `/api/pay`, `/api/exit`, `/api/voucher`, `/api/barrier/reopen` get a `requireShift` preHandler → 409 `{code:"no_shift"}`; read-only lookups stay open so the modal can display + prompt. `GET /api/shift/current` now returns the site-wide `{open:{startedAt,operator},isMine}`. Logs are **per-shift** via `GET /api/events?since=<shiftStart>`. UI: header shift button (open / close-mine / disabled-when-other), pay-modal gate banner with one-click open, gated Active-Sessions re-open, shift-scoped live feed; shared `useShift()` Query invalidated by the WS on shift/cash events. Updated [[shift]] (new "Site-wide single-open + booth gate" section; superseded the per-operator as-built note) and [[booth-console]] (header control + gate). Verified the invariant + chain integrity on a fresh migrated DB (11/11 assertions). Builds clean across db/server/web.
|
||||
|
||||
## [2026-06-18] ingest | Device-status footer — unified monitor across all categories
|
||||
|
||||
Generalised printer-only status monitoring to a booth-wide DEVICE-STATUS FOOTER covering relays/readers/cameras/printers. New `DeviceMonitor` (`apps/server/src/device-monitor.ts`) polls every enabled device each tick (default 8s): printers via rich `readStatus()`, all others via the generic `healthCheck()` reachability probe, flattened to one traffic-light (ready/degraded/offline)+detail, deduped (emits on change only), fail-toward-offline (a throw/timeout → offline, never false-healthy). New `device-status` bus event + `GET /api/devices/status` snapshot; live updates ride the existing `/api/ws` (`hello` now carries the initial device set; `device-status` frame per change). Web: live-store `devices` map (setDevices/upsertDevice), WS handler wired, new `DeviceFooter` chip-per-device with an "all ready / N offline" roll-up, mounted in the app shell; `devices` i18n namespace (sq/en). The PrinterMonitor + its SSE stream stay as the printer-specific authority (both run — see the note in [[device-status-monitoring]]). Filed [[device-status-monitoring]] (resolves the code link), cross-linked [[printer-status-monitoring]] + [[booth-console]], indexed (concepts 41→42). Verified on a fresh DB (relay+reader → ready via healthCheck; unreachable printer → offline with detail, no throw; emit-once-then-silent) — 9/9; server+web build clean.
|
||||
|
||||
## [2026-06-18] refine | Device footer — role-only labels + click-to-see-issues
|
||||
|
||||
Two refinements to the device-status footer. (1) Chips label by ROLE, not vendor: the server sends a structured `roleKind` token per device (reader/camera → direction inherited from the bound relay via `directionOf()`; access → entry/exit/both, or "mixed" across relays; printer → lane/booth) and the client localises category+role → "Lexuesi hyrje", "Printer kabina", "Kamera dalje". Dropped `label`/`role`/driverId from the chip. (2) Fault detail no longer pollutes the footer: chips are compact (dot + label); a degraded/offline chip (or the "N with issues" roll-up) is clickable and opens a small issues panel above the footer listing only the problem devices with state/detail/checked-time (outside-click/Esc to close; no new dependency). i18n `devices.role.*` + issues keys (sq/en). Verified roleKind resolution on a fresh DB (access→mixed, reader(exit)→exit, camera(entry)→entry, printers→lane/booth) 7/7; server+web build clean. Updated [[device-status-monitoring]].
|
||||
|
||||
## [2026-06-18] fix | Stuck active session — paid ticket that never got a vehicle_exit (T-397815c0)
|
||||
|
||||
Investigated a paid ticket stuck forever in the Active Sessions tab. Root cause (confirmed from the live ledger): the car left via a **manual barrier re-open**, which by design signed an `anomaly` but **never a `vehicle_exit`** — so `activeSessions()` saw it as permanently `open` (the grace-expiry eviction only applied to *exited* sessions). The normal exit that would have signed the exit was refused because walk-back grace (5 min) had expired ~17h earlier. Two fixes: (1) `ExitFlow.reopenBarrier` now signs a `vehicle_exit` (`source:manual`) **when the session is still open**, closing it — while still NOT double-signing an already-exited session (phantom re-close). (2) `PayStation.activeSessions()` ages out a **paid** open session past grace even with no exit (unpaid open sessions never age out — a car owing money stays). Plus a one-off corrective: appended a signed `vehicle_exit` (index 68, `correction:true`) for T-397815c0 through EventLog (chain verified `{ok:true}`), clearing it from the list. Verified both fixes on a fresh DB (9/9; chain intact). Updated [[booth-exit-flow]] (active-session definition + the re-open rule, was "NEVER a vehicle_exit").
|
||||
|
||||
## [2026-06-18] ingest | Permit → Subscription rename + monthly pricing (timeframes deferred)
|
||||
|
||||
Renamed the "permit" feature to "subscription" (operator term: abonim) and added recurring monthly pricing. FULL rename of mutable master data: tables permits→subscriptions, permit_credentials→subscription_credentials, permit_plates→subscription_plates, sessions.permit_id→subscription_id (data-preserving ALTER RENAMEs, migration 0004); server permit-flow.ts→subscription-flow.ts (SubscriptionFlow), routes/permits.ts→routes/subscriptions.ts (/api/subscriptions), web PermitManager→SubscriptionManager, api types, i18n (sq "Abonimet"/en "Subscriptions"). The signed ledger `permitId` payload field is INTENTIONALLY kept (immutable hash-chained history — renaming would break verification of past events); code/data are "subscription", the on-chain field stays `permitId`. Pricing: per-subscription priceMinor + period("monthly") + currency, with a site default (site_config.subscription_monthly_price_minor) pre-filling the form; collecting the fee into the ledger/shift is DEFERRED (wiki note only). Time-of-day access windows (e.g. overnight subscriber 19:00–07:00, transient outside) documented as a design note in [[subscription]] — NOT implemented; legacy precedent in [[parksql2017-legacy-schema]] (MembershipPlansTime). Renamed [[entities/permit|permit]]→[[subscription]] and swept all [[permit]] wikilinks across the wiki (log.md historical entries left as-was). Verified end-to-end on a fresh migrated DB (schema+pricing, card entry/exit, maxConcurrent cap, on-chain permitId carries the sub id, chain verify) 6/6; migration also applied cleanly to a copy of the live DB (18 sessions preserved). Full monorepo builds clean.
|
||||
|
||||
## [2026-06-18] note | Subscription-fee collection is a SHIFT transaction
|
||||
|
||||
Clarified (user): collecting/renewing a subscription's monthly fee is a financial transaction a common operator makes DURING their shift — it must reflect in THAT shift's drawer + Z-report, not be an admin-only edit. Updated [[subscription]] (Pricing → "Collecting the fee is a SHIFT transaction"): model it as a signed `payment` event (same `{amountMinor,currency,tender}` shape) tagged `{subscriptionId}` at collection time, so it folds into the open shift automatically (Z-report sums payments by time; drawer adds cash tenders) with no new summing logic. Admin edits the master data; operator takes the money. Subscription entry/exit stay free — only the plan fee is a payment. Still DEFERRED build; cross-linked from [[shift]] ("What End Shift does"). Open: plain `payment`+tag vs. a distinct `subscription_payment` type (leaning plain).
|
||||
|
||||
## [2026-06-18] note | Subscription credential type — operator chooses, QR-only for now
|
||||
|
||||
The subscription form lets the operator choose the credential type; for now only QR is live. UI change only: the new-credential default is now QR (was RF), and the RFID option is shown DISABLED ("soon", `subs.rfCardTagSoon`) so the choice is visible. Backend + schema keep accepting `kind:'rf'|'qr'` unchanged — re-enabling RFID later is just dropping `disabled` (no migration). Updated [[subscription]] Credentials section.
|
||||
|
||||
## [2026-06-18] feat | Subscription QR auto-generation + multi-month coverage
|
||||
|
||||
QR credentials are now AUTO-GENERATED server-side (`SUB-<15×base32>`, crypto-random, globally-unique-checked) — the operator/customer never picks the code (anti-fraud); the UI sends a blank QR credential and the server mints+returns the value to print. RF credentials still carry the operator-entered card id. Reader output decided = TCP/IP full string (host-in-the-loop), so the code length is free; noted the Wiegand-26/34 numeric-truncation alternative if ever wired that way (+ the manufacturer reader's ID/IC/NFC+QR / Wiegand/TCP/USB/RS485 / 125kHz+13.56MHz spec — one device covers QR and future RFID). Multi-month: the form takes a `months` count → server sets `validTo = validFrom + N months` (day-clamp), one record/one window, total = N×monthly (collection still deferred); explicit `validTo` override still works; `months` is input-only (truth is validFrom/validTo). Backend: routes/subscriptions.ts (newQrCode/addMonths/resolveValidTo, validate RF-needs-value + months-needs-validFrom). Web: SubscriptionManager (QR shown auto-gen/read-only, months field + live coverage+total preview), api types, i18n (sq/en). Verified via buildServer+inject 9/9 (autogen, uniqueness, RF-blank reject, Jan31+3mo→Apr30, supplied-value preserved). Updated [[subscription]]. No new migration (uses existing columns).
|
||||
|
||||
## [2026-06-18] feat | Subscription QR card — printed on creation + reprint, real QR rendering
|
||||
|
||||
The auto-generated subscription QR is now PRINTED so the operator can hand it to the customer. Added real 2D QR rendering to the [[rongta-printer]] driver via ESC/POS `GS ( k` (model 2, EC level M; firmware-rendered, no bitmap dep) — new `PrinterDevice.printSubscriptionCard(SubscriptionCardData)`; the card is park header → scannable QR of the code → code text (hand-key fallback) → holder + validity. Server: `printSubscriptionCard()` in booth-print.ts (booth-receipt printer, failover to dispenser); create AUTO-PRINTS best-effort (a print failure never fails the create — response returns `{printed, printError}`); new `POST /api/subscriptions/:id/print` reprint (operator-or-admin; 409 if no QR credential, 503 if no printer). Web: SubscriptionManager surfaces the print outcome on save and a "Print code" button per QR subscription; api types + i18n (sq/en). Verified on the wire via buildServer+inject + a TCP capture (9/9: auto-print, well-formed GS ( k QR bytes with the embedded code, reprint re-sends, no-QR→409). Updated [[subscription]] + [[rongta-printer]]. No migration.
|
||||
|
||||
## [2026-06-18] feat | Subscription RFID enrollment — "Read card" capture on a chosen reader
|
||||
|
||||
Enabled RFID subscription credentials with a card-enrollment flow. The operator picks a reader and presents the physical card; the value is captured into the credential instead of being typed. New in-memory `CredentialCapture` (single-shot + ~30s TTL): `arm(deviceId)`; `routes/qr-reader.ts` checks `tryConsume()` on each read — an armed reader's read is captured and NOT dispatched (no barrier for an enrolled card), then auto-disarms; reads on the OTHER reader dispatch normally, so its live entry/exit flow is never blocked. Routes (operator/admin): `GET /api/subscriptions/readers` (picker), `POST /capture/arm`, `GET /capture` (poll: idle|armed|captured|expired), `POST /capture/cancel`. Web: RFID re-enabled in the form (was disabled "soon"); "Read card" → reader picker → arm → poll → fills the value; i18n (sq/en). The GEE readers are combo QR+RFID (ID/IC/NFC), same endpoint, so one device captures both. Verified via buildServer+inject + reader-scan simulation (12/12: captured-not-dispatched, single-shot, other reader still drives a live vehicle_exit while armed, value retrievable, cancel). Updated [[subscription]]. No migration.
|
||||
|
||||
## [2026-06-18] feat | Subscriptions — enter with one credential, exit with another (+ FIFO fleets)
|
||||
|
||||
Decoupled subscription exit from the entry credential. Previously the session was keyed by the exact credential value read (an accidental coupling → must exit with the same QR/RFID you entered with). Now sessions are keyed by a per-occurrence id (`SUBSESS-<subId>-<uuid>`, the ledger `identity`; `payload.permitId`=subId), so ANY of a subscription's credentials (QR/RFID/NFC/plate) opens or closes. Direction is now decided by the BARRIER the reader sits at (entry-lane→entry, exit-lane→exit; a "both" barrier infers from open state) — this lets a FLEET (maxConcurrent>1) admit several cars (each entry-lane read is an entry) yet exit any of them with any credential; exit closes the OLDEST open occurrence (FIFO). Per-car identity within a fleet isn't tracked (never was once credentials are shared). `#openOccurrences()` replaced `#carHasOpenSession`/`#subscriptionOpenCount`. Exit with nothing open → signed anomaly (anti-passback). Verified 11/11 (enter-QR/exit-RFID + reverse, fleet 2-in mixed-credential FIFO out, capacity, anti-passback, chain intact). Updated [[subscription]] (multi-credential + entry-decoupled-from-exit). No migration.
|
||||
|
||||
## [2026-06-18] fix | Subscription occurrences in the booth — prepaid, barrier-open assist (not transient)
|
||||
|
||||
A subscription occurrence (SUBSESS-…) showed in Active Sessions but was wrongly treated as an unpaid transient: the modal tried to quote/charge it and the "open barrier" button only appeared for PAID sessions, so a subscriber with a faulty exit reader / missing card couldn't be assisted. Fix: `pay-station.ts` lookup/activeSessions now flag `subscription`/`subscriptionId`/`subscriptionHolder` from the entry payload (permit:true/permitId) and DON'T quote a subscription (amountMinor null). `exit-flow.ts` reopenBarrier now authorizes `paidAt != null || subscription` (prepaid). UI: the pay modal renders a SUBSCRIPTION mode (PREPAID badge, snapshots, single Open-barrier action, no tender/voucher) and the active row badges "abonim" + shows the holder name; both labelled by holder, not the raw key. Also SHORTENED the occurrence id (was SUBSESS-<subId>-<uuid>, ~80 chars) to `SUBSESS-<12hex>` — the subscriptionId lives in the payload (which every fold matches on), so it needn't be embedded in the key. Verified 9/9 (subscription flagged + not charged in lookup/active, reopen works without payment, unpaid-transient guard intact). Updated [[booth-exit-flow]]. No migration.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,87 @@
|
||||
---
|
||||
type: source
|
||||
tags: [parking, legacy, pricing, schema, fiscalization]
|
||||
sources: [parksql2017-legacy-schema]
|
||||
updated: 2026-06-17
|
||||
---
|
||||
|
||||
# ParkSQL2017 — Legacy Parking System Schema (source summary)
|
||||
|
||||
A SQL Server 2017 schema dump (`raw/parksql2017-legacy-schema.sql`, scripted 2024-10-07) of an
|
||||
**existing/predecessor parking system** in the same Albanian market this project targets. It is the
|
||||
single most concrete reference we have for how the prior generation modelled **tariffs, discounts,
|
||||
memberships, sessions, shifts, and fiscalization** — a real, deployed data model rather than vendor
|
||||
marketing. Treat it as evidence of what worked and what to improve, not as a spec to copy (it has
|
||||
clear anti-patterns, e.g. money as `float`).
|
||||
|
||||
> Albanian-context tells: `BA_TicketFisc.nivf` (NIVF fiscalization code), the `Cupons` spelling,
|
||||
> `LostPrice1..4` tiers. `BA_` = business-app table prefix; `SYS_` = system/auth tables.
|
||||
|
||||
## Table map (24 tables)
|
||||
|
||||
**Pricing / tariff**
|
||||
- **`BA_TicketPrice`** — the rate-card *header*. Key fields: `Code`, `Name`, `TicketCategoryID`,
|
||||
`ParkID`, **`ValidFrom`/`ValidTo`** (date window), **`ValidFromHour`/`ValidToHour`** (time-of-day
|
||||
window), `FixedPrice` (flat option), `IntervalType` (2-char unit, e.g. MI/HR/DY), `Interval`
|
||||
(increment size), **`LostPenalty`**, `IsDefault`, `IsActive`. → A rate card is scoped by
|
||||
**(category × date-range × hour-range)**. This is the **happy-hour / time-of-day mechanism**.
|
||||
- **`BA_TicketPriceHours`** — the *stepped ladder* (child of TicketPrice): rows of
|
||||
`HourFrom`, `HourTo`, `Price`. → equivalent to this project's tariff `blocks[]`.
|
||||
- **`BA_TicketCategory`** — vehicle/customer category (`Code`, `Name`, `IsDefault`,
|
||||
`IntervalChange`). → a **pricing axis by category** the current model lacks.
|
||||
- **`BA_TicketFisc`** — `TicketID` → `nivf` (Albanian fiscalization code per ticket).
|
||||
|
||||
**Discounts / validation**
|
||||
- **`BA_Cupons`** — `CODE`, **`DiscMinutes`** (discount as *free minutes*), `IsUsed`, `LastUsed`,
|
||||
`IsPrinted`. → validation = a **single-use coupon code worth N free minutes**; reconciled by
|
||||
counting used codes (**prepaid** model, no merchant account/ledger).
|
||||
|
||||
**Sessions**
|
||||
- **`BA_ParkRecords`** — the transient parking session (ticket cars). Carries lifecycle
|
||||
(`InTime`/`OutTime`/`ExitTime`, `In/Out Mode/Addr/OperatorID`, `In/Out ShiftID`), money
|
||||
(`OrgCharge`, `Charge`, `Discount`, `FreeMin`, `IsPaid`), **discount detail** (`DiscMinutes`,
|
||||
`DiscType` smallint, `DisTicketSerial`), entry/exit plate+image columns, and
|
||||
`ManualOpenReason` / `ExpiredTime/DateApproval` audit fields.
|
||||
- **`BA_MembersCheckINOUT`** — per-event check-in/out log for *members* (cards), separate from
|
||||
ticket sessions.
|
||||
- **`BA_ManualCheckINOUT`** — every manual barrier open, with `Reason` + `OperatorID` + image.
|
||||
|
||||
**Memberships (≈ this project's permits)**
|
||||
- **`BA_Members`** — the member (card+plate identity, contact, `isVIP`).
|
||||
- **`BA_Memberships`** — an issued subscription: `PlanID`, `StartDate`/`EndDate`, `Price`,
|
||||
`CalculatedPrice`, `Paid`, `AllowedDays`.
|
||||
- **`BA_MembershipPlans`** — plan template: `Type`, `Duration`, `Price`, **`ActiveDays`**.
|
||||
- **`BA_MembershipPlansTime`** — **`StartTime`/`EndTime`** windows per plan → memberships valid only
|
||||
in specific **hours** (commuter/day-shift permits).
|
||||
|
||||
**Site / ops / auth**
|
||||
- **`BA_Park`** — a lot: capacity (`ParkingPlaces`/`FreePlaces`), LED sign addr, default ticket/lost
|
||||
category codes, **`FreeMinutes`**, `DiscMinutesTicket`, `DiscMinutesApp`, **`LostPrice1..4`**.
|
||||
- **`BA_Shifts`** — cashier shift / Z-report: open/close, `Charged`, `TicketCharges`,
|
||||
`CardCharges`, entry/exit + manual counts, **`Reconciled`**, `UserID`, `MachineID`.
|
||||
- **`BA_CashRegister`**, `SYS_Configs` (company/fiscal/ticket header+footer, `AllowTimeExeed`,
|
||||
`ImageDays`), `SYS_User`/`SYS_Role`/`SYS_Rights`/`SYS_UserRights`, `SYS_Controls`,
|
||||
`SYS_Language` (DB-driven i18n).
|
||||
|
||||
## What it confirms for our design
|
||||
1. **Stepped ladder** — `BA_TicketPriceHours` (HourFrom/HourTo/Price) ≈ our `blocks[]`. Good signal.
|
||||
2. **Per-rate lost penalty** + site-level lost tiers (`LostPrice1..4`) ≈ our `lostTicketMinor` (+ the
|
||||
admin-override idea).
|
||||
3. **Typed discount on the session record** (`DiscType`) ≈ the research's "typed validation modifier."
|
||||
4. **Manual-open + reason logging at the schema level** ≈ our [[threat-model|operator-as-adversary]] audit need.
|
||||
|
||||
## What it adds (genuinely new vs. our current model)
|
||||
1. **Time-of-day + date windows on the rate card** (`ValidFromHour`/`ValidToHour`,
|
||||
`ValidFrom`/`ValidTo`) — the shipped way to do **happy hour / seasonal**. See [[tariff-time-tiers]].
|
||||
2. **Vehicle/customer category as a pricing axis** (`BA_TicketCategory`). See [[tariff-time-tiers]].
|
||||
3. **Time-/day-restricted memberships** (`MembershipPlansTime`, `ActiveDays`) — a [[subscription]] gap.
|
||||
|
||||
## Anti-patterns to NOT copy
|
||||
- **Money as `float`** everywhere (`Charge`, `Price`, `LostPenalty`) — drifts across a revenue
|
||||
ledger. Our integer-minor-units rule is the deliberate fix. (rejected alternative)
|
||||
- **Mutable rate rows** (`Updated`/`UpdatedBy` in place) — a past session can't reliably reprice
|
||||
against the rate then in force. Our immutable effective-dated [[tariff]] versions fix this.
|
||||
- **No merchant/sponsor account or postpaid ledger** — only prepaid printed coupons. The B2B
|
||||
postpaid case is net-new; see [[validation-sponsorship]].
|
||||
- **Images stored as `image` BLOBs in-row** — we keep snapshot *bytes* out of the event row and
|
||||
store a reference instead ([[append-only-event-chain]]).
|
||||
Reference in New Issue
Block a user