Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfa76346d6 | |||
| c9a2ef81a9 | |||
| 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": {
|
"hooks": {
|
||||||
"PreToolUse": [
|
"PreToolUse": []
|
||||||
{
|
|
||||||
"matcher": "Bash",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": "Read|Glob",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,13 +86,3 @@ For the full reasoning behind each, follow the links from `wiki/overview.md`.
|
|||||||
|
|
||||||
- TypeScript throughout. Match the style of surrounding code.
|
- TypeScript throughout. Match the style of surrounding code.
|
||||||
- Confirm before destructive or outward-facing actions. Commit/push only when asked.
|
- Confirm before destructive or outward-facing actions. Commit/push only when asked.
|
||||||
|
|
||||||
## graphify
|
|
||||||
|
|
||||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
|
||||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
|
||||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
|
||||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
# Generate one with: openssl rand -hex 32
|
# Generate one with: openssl rand -hex 32
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
|
||||||
|
# dev, but set a dedicated key before production. Events store the key that signed
|
||||||
|
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
||||||
|
EVENT_SIGNING_KEY=
|
||||||
|
|
||||||
# Optional ----------------------------------------------------------------
|
# Optional ----------------------------------------------------------------
|
||||||
# PORT=3000
|
# PORT=3000
|
||||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||||
@@ -18,3 +25,7 @@ JWT_SECRET=
|
|||||||
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
||||||
# ADMIN_USER=admin
|
# ADMIN_USER=admin
|
||||||
# ADMIN_PASS=
|
# ADMIN_PASS=
|
||||||
|
|
||||||
|
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
|
||||||
|
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
|
||||||
|
WS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@fastify/cors": "11.2.0",
|
"@fastify/cors": "11.2.0",
|
||||||
"@fastify/jwt": "10.1.0",
|
"@fastify/jwt": "10.1.0",
|
||||||
"@fastify/static": "9.1.3",
|
"@fastify/static": "9.1.3",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@parking/db": "workspace:*",
|
"@parking/db": "workspace:*",
|
||||||
"@parking/devices": "workspace:*",
|
"@parking/devices": "workspace:*",
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
|
|||||||
@@ -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 { EventEmitter } from "node:events";
|
||||||
import type { PrinterStatus } from "@parking/devices";
|
import type { PrinterStatus } from "@parking/devices";
|
||||||
|
import type { LedgerEventRow } from "@parking/db";
|
||||||
|
|
||||||
// Internal event bus for device-originated events (button presses, etc.).
|
// Internal event bus for device-originated events (button presses, etc.).
|
||||||
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||||
@@ -16,7 +17,7 @@ export interface DeviceInputEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
|
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
|
||||||
// Drives identity-based flows (exit validation, permits, pay-station lookup). `kind`
|
// Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
|
||||||
// mirrors IdentitySource. See parking-session.md.
|
// mirrors IdentitySource. See parking-session.md.
|
||||||
export interface DeviceReadEvent {
|
export interface DeviceReadEvent {
|
||||||
readonly driverId: string;
|
readonly driverId: string;
|
||||||
@@ -34,7 +35,7 @@ export interface DeviceReadEvent {
|
|||||||
export interface ReadOutcome {
|
export interface ReadOutcome {
|
||||||
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||||
readonly accepted: boolean;
|
readonly accepted: boolean;
|
||||||
/** Which way it went, when known (permit/exit infer this). */
|
/** Which way it went, when known (subscription/exit infer this). */
|
||||||
readonly direction?: "entry" | "exit";
|
readonly direction?: "entry" | "exit";
|
||||||
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
||||||
readonly reason?: string;
|
readonly reason?: string;
|
||||||
@@ -48,6 +49,33 @@ export interface PrinterStatusEvent {
|
|||||||
readonly status: PrinterStatus;
|
readonly status: PrinterStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unified live status of ANY configured device — what the booth footer shows.
|
||||||
|
* Every enabled device is polled: printers via their rich `readStatus()`
|
||||||
|
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
|
||||||
|
* reachability probe. `state` is the common traffic-light; `detail` carries the
|
||||||
|
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
|
||||||
|
* and wiki/concepts/device-status-monitoring.md.
|
||||||
|
*/
|
||||||
|
export interface DeviceStatusEvent {
|
||||||
|
readonly deviceId: string; // devices id
|
||||||
|
readonly driverId: string;
|
||||||
|
readonly category: "access" | "reader" | "camera" | "printer";
|
||||||
|
/**
|
||||||
|
* The device's ROLE descriptor for the footer label — NOT the vendor. A
|
||||||
|
* direction-style token the client localises and pairs with the category, so the
|
||||||
|
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
|
||||||
|
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
|
||||||
|
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
|
||||||
|
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
|
||||||
|
* - undetermined: null (chip shows the category alone)
|
||||||
|
*/
|
||||||
|
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||||||
|
readonly state: "ready" | "degraded" | "offline";
|
||||||
|
readonly detail?: string;
|
||||||
|
readonly checkedAt: string; // ISO-8601
|
||||||
|
}
|
||||||
|
|
||||||
class DeviceEventBus extends EventEmitter {
|
class DeviceEventBus extends EventEmitter {
|
||||||
emitInput(event: DeviceInputEvent): void {
|
emitInput(event: DeviceInputEvent): void {
|
||||||
this.emit("input", event);
|
this.emit("input", event);
|
||||||
@@ -74,6 +102,32 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("printer-status", cb);
|
this.on("printer-status", cb);
|
||||||
return () => this.off("printer-status", cb);
|
return () => this.off("printer-status", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
|
||||||
|
* (all categories — relays, readers, cameras, printers). Drives the booth
|
||||||
|
* device-status footer over the WS. */
|
||||||
|
emitDeviceStatus(event: DeviceStatusEvent): void {
|
||||||
|
this.emit("device-status", event);
|
||||||
|
}
|
||||||
|
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
|
||||||
|
this.on("device-status", cb);
|
||||||
|
return () => this.off("device-status", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
|
||||||
|
* payment, void, …). The payload is the persisted row — business facts only, no
|
||||||
|
* secrets — so it is safe to fan out to authenticated booth clients over the WS.
|
||||||
|
* This is a read-side notification ONLY: it never feeds back into append/sign/
|
||||||
|
* chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts.
|
||||||
|
*/
|
||||||
|
emitLedger(event: LedgerEventRow): void {
|
||||||
|
this.emit("ledger", event);
|
||||||
|
}
|
||||||
|
onLedger(cb: (event: LedgerEventRow) => void): () => void {
|
||||||
|
this.on("ledger", cb);
|
||||||
|
return () => this.off("ledger", cb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -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 { randomInt } from "node:crypto";
|
||||||
import { sessions, type Db, type DeviceRow } from "@parking/db";
|
import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
NoPrinterAvailableError,
|
NoPrinterAvailableError,
|
||||||
printWithFailover,
|
printWithFailover,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type PrinterDevice,
|
type PrinterDevice,
|
||||||
type PrinterInstance,
|
type PrinterInstance,
|
||||||
type TicketData,
|
type TicketData,
|
||||||
|
type TicketHeader,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceInputEvent } from "./device-events.js";
|
import type { DeviceInputEvent } from "./device-events.js";
|
||||||
@@ -72,9 +73,9 @@ export class EntryFlow {
|
|||||||
|
|
||||||
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
||||||
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||||
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT
|
// no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
|
||||||
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
||||||
// subscribers aren't locked out. "Full" is a soft policy seam for valet over-
|
// they aren't locked out. "Full" is a soft policy seam for valet over-
|
||||||
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||||
const occ = getOccupancy(this.#db);
|
const occ = getOccupancy(this.#db);
|
||||||
if (occ.full) {
|
if (occ.full) {
|
||||||
@@ -91,7 +92,7 @@ export class EntryFlow {
|
|||||||
const printers = this.#loadPrinters();
|
const printers = this.#loadPrinters();
|
||||||
|
|
||||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||||
const ticket: TicketData = { ticketId, issuedAt };
|
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||||
try {
|
try {
|
||||||
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||||
d.printTicket(ticket),
|
d.printTicket(ticket),
|
||||||
@@ -182,9 +183,67 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Park identity for the ticket header, from site_config (all fields optional;
|
||||||
|
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
|
||||||
|
#ticketHeader(): TicketHeader | undefined {
|
||||||
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (!row) return undefined;
|
||||||
|
return {
|
||||||
|
parkName: row.parkName,
|
||||||
|
operatorName: row.operatorName,
|
||||||
|
nius: row.nius,
|
||||||
|
address: row.address,
|
||||||
|
phone: row.phone,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */
|
/**
|
||||||
|
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
|
||||||
|
*
|
||||||
|
* Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check
|
||||||
|
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
|
||||||
|
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
|
||||||
|
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
|
||||||
|
* — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so
|
||||||
|
* collisions are negligible at lot scale; the unique constraints on
|
||||||
|
* ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual
|
||||||
|
* entry reject a typo (validateTicketCode) instead of failing as "session not found".
|
||||||
|
*/
|
||||||
function newTicketId(): string {
|
function newTicketId(): string {
|
||||||
return `T-${randomUUID()}`;
|
let body = "";
|
||||||
|
for (let i = 0; i < 12; i += 1) body += String(randomInt(10));
|
||||||
|
return body + luhnCheckDigit(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Luhn (mod-10) check digit for an all-digit string. */
|
||||||
|
function luhnCheckDigit(digits: string): string {
|
||||||
|
let sum = 0;
|
||||||
|
// Walk right-to-left; the check digit sits at position 0 from the right, so the
|
||||||
|
// last body digit is an "even" position that gets doubled.
|
||||||
|
let double = true;
|
||||||
|
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
||||||
|
let d = digits.charCodeAt(i) - 48;
|
||||||
|
if (double) {
|
||||||
|
d *= 2;
|
||||||
|
if (d > 9) d -= 9;
|
||||||
|
}
|
||||||
|
sum += d;
|
||||||
|
double = !double;
|
||||||
|
}
|
||||||
|
return String((10 - (sum % 10)) % 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
|
||||||
|
* Lets a manual-entry path (operator types the code off the ticket when readers are
|
||||||
|
* down) reject a typo up front. A scanned/looked-up id that predates this format
|
||||||
|
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
|
||||||
|
* never reject an id that already exists in the ledger. See ticket-encoding.md.
|
||||||
|
*/
|
||||||
|
export function validateTicketCode(code: string): boolean {
|
||||||
|
if (!/^\d{13}$/.test(code)) return false;
|
||||||
|
const body = code.slice(0, 12);
|
||||||
|
return luhnCheckDigit(body) === code[12];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,15 +81,34 @@ export function hashEvent(canonical: string): string {
|
|||||||
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
|
||||||
|
* Returns undefined when the key that signed an event is not available. */
|
||||||
|
export type SignerResolver = (keyId: string) => Signer | undefined;
|
||||||
|
|
||||||
export class EventLog {
|
export class EventLog {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #signer: Signer;
|
readonly #signer: Signer;
|
||||||
|
/** Picks the verifying signer per event keyId; lets a chain span key rotations
|
||||||
|
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
|
||||||
|
* callers that don't pass one (single-key chains, tests). */
|
||||||
|
readonly #resolveVerifier: SignerResolver;
|
||||||
|
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
|
||||||
|
* to fan the event out to live booth clients (WS). It is best-effort and must
|
||||||
|
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
|
||||||
|
readonly #onAppended?: (row: LedgerEventRow) => void;
|
||||||
/** Serialize appends: each waits for the previous to finish. */
|
/** Serialize appends: each waits for the previous to finish. */
|
||||||
#tail: Promise<unknown> = Promise.resolve();
|
#tail: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
constructor(db: Db, signer: Signer) {
|
constructor(
|
||||||
|
db: Db,
|
||||||
|
signer: Signer,
|
||||||
|
resolveVerifier?: SignerResolver,
|
||||||
|
onAppended?: (row: LedgerEventRow) => void,
|
||||||
|
) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#signer = signer;
|
this.#signer = signer;
|
||||||
|
this.#resolveVerifier = resolveVerifier ?? (() => signer);
|
||||||
|
this.#onAppended = onAppended;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
||||||
@@ -97,7 +116,16 @@ export class EventLog {
|
|||||||
const run = this.#tail.then(() => this.#appendNow(input));
|
const run = this.#tail.then(() => this.#appendNow(input));
|
||||||
// Keep the chain going even if one append rejects (don't wedge the lock).
|
// Keep the chain going even if one append rejects (don't wedge the lock).
|
||||||
this.#tail = run.catch(() => undefined);
|
this.#tail = run.catch(() => undefined);
|
||||||
return run;
|
// Read-side notification, AFTER the row is durably written. Wrapped so a
|
||||||
|
// failing sink can never reject the append or break the chain lock above.
|
||||||
|
return run.then((row) => {
|
||||||
|
try {
|
||||||
|
this.#onAppended?.(row);
|
||||||
|
} catch {
|
||||||
|
// best-effort fan-out only — swallow.
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#appendNow(input: AppendInput): LedgerEventRow {
|
#appendNow(input: AppendInput): LedgerEventRow {
|
||||||
@@ -146,7 +174,13 @@ export class EventLog {
|
|||||||
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
|
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
|
||||||
* first detected break, or { ok: true }. This is what reconciliation and an
|
* first detected break, or { ok: true }. This is what reconciliation and an
|
||||||
* integrity self-check call. Catches: tampered content, reordering, a deleted
|
* integrity self-check call. Catches: tampered content, reordering, a deleted
|
||||||
* row (index gap), and a forged/invalid signature.
|
* row (index gap), a forged/invalid signature, and an event signed under a key
|
||||||
|
* that is no longer configured.
|
||||||
|
*
|
||||||
|
* Each row is verified against the signer for ITS OWN `keyId`, not the current
|
||||||
|
* append signer — so a chain that spans a key rotation (e.g. early events under
|
||||||
|
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
|
||||||
|
* verifies end to end. See signer.buildVerifier.
|
||||||
*/
|
*/
|
||||||
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
||||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
@@ -159,8 +193,16 @@ export class EventLog {
|
|||||||
if ((row.prevHash ?? null) !== prevHash) {
|
if ((row.prevHash ?? null) !== prevHash) {
|
||||||
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
|
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
|
||||||
}
|
}
|
||||||
|
const verifier = this.#resolveVerifier(row.keyId);
|
||||||
|
if (!verifier) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
index: row.index,
|
||||||
|
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
const canonical = canonicalize(row);
|
const canonical = canonicalize(row);
|
||||||
if (!this.#signer.verify(canonical, row.signature)) {
|
if (!verifier.verify(canonical, row.signature)) {
|
||||||
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
|
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
|
||||||
}
|
}
|
||||||
prevHash = hashEvent(canonical);
|
prevHash = hashEvent(canonical);
|
||||||
|
|||||||
+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 { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
import type { ResolvedRelay } from "./device-resolve.js";
|
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
import { snapshotAsync } from "./snapshot.js";
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
import type { LedgerPayload } from "@parking/shared";
|
import { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
@@ -30,9 +30,32 @@ interface SessionView {
|
|||||||
readonly enteredAt: string;
|
readonly enteredAt: string;
|
||||||
readonly open: boolean; // no vehicle_exit yet
|
readonly open: boolean; // no vehicle_exit yet
|
||||||
readonly paidAt: string | null; // latest payment time, if any
|
readonly paidAt: string | null; // latest payment time, if any
|
||||||
|
/** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to
|
||||||
|
* exit / re-open without a `payment`. */
|
||||||
|
readonly subscription: boolean;
|
||||||
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
||||||
|
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
|
||||||
|
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
|
||||||
|
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
|
||||||
|
// active tariff resolves (then we fall back to the normal paid check).
|
||||||
|
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
|
||||||
|
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
|
||||||
|
* the barrier didn't open (payment stands; operator opens manually). */
|
||||||
|
export type BoothExitResult =
|
||||||
|
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||||
|
| { ok: true; opened: true }
|
||||||
|
| { ok: true; opened: false; reason: string };
|
||||||
|
|
||||||
|
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
|
||||||
|
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
|
||||||
|
* intervention was recorded (signed anomaly) but the relay did not fire. */
|
||||||
|
export type BoothReopenResult =
|
||||||
|
| { ok: false; reason: string }
|
||||||
|
| { ok: true; opened: boolean; reason?: string };
|
||||||
|
|
||||||
export class ExitFlow {
|
export class ExitFlow {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -45,8 +68,189 @@ export class ExitFlow {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
|
||||||
|
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
|
||||||
|
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
|
||||||
|
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
|
||||||
|
* relay site-wide, pulses it, and fires the exit snapshot.
|
||||||
|
*
|
||||||
|
* Returns a discriminated result so the route can react precisely:
|
||||||
|
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
|
||||||
|
* — nothing is signed beyond the existing anomaly; the operator takes payment.
|
||||||
|
* - { ok: true, opened: true } on a clean exit.
|
||||||
|
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
|
||||||
|
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
|
||||||
|
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
||||||
|
* operator opens manually. Payment is never rolled back.
|
||||||
|
*/
|
||||||
|
async exitForBooth(identity: string): Promise<BoothExitResult> {
|
||||||
|
const id = identity.trim();
|
||||||
|
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
||||||
|
|
||||||
|
const key = `booth:${id}`;
|
||||||
|
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
const view = this.#sessionFor(id);
|
||||||
|
|
||||||
|
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
|
||||||
|
// path) so a booth attempt on a bad ticket is auditable.
|
||||||
|
if (!view || !view.open) {
|
||||||
|
const 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
|
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
|
||||||
* read dispatcher from the reader's binding, which has ruled out a permit match). */
|
* read dispatcher from the reader's binding, which has ruled out a subscription match). */
|
||||||
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
const key = `${e.deviceId}:${e.value}`;
|
const key = `${e.deviceId}:${e.value}`;
|
||||||
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||||
@@ -76,6 +280,28 @@ export class ExitFlow {
|
|||||||
return { accepted: false, direction: "exit", reason };
|
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?
|
// PAID + within walk-back grace?
|
||||||
const paid = view.paidAt != null;
|
const paid = view.paidAt != null;
|
||||||
const withinGrace =
|
const withinGrace =
|
||||||
@@ -96,37 +322,73 @@ export class ExitFlow {
|
|||||||
return { accepted: false, direction: "exit", reason };
|
return { accepted: false, direction: "exit", reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid: sign the exit BEFORE opening, then open, then update the cache.
|
// Valid (a real payment within walk-back grace): sign + open.
|
||||||
await this.#log.append({
|
return this.#signExitAndOpen(resolved, e);
|
||||||
type: "vehicle_exit",
|
}
|
||||||
direction: "exit",
|
|
||||||
source: e.kind === "plate" ? "lpr" : "ticket",
|
/** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
|
||||||
identity: e.value,
|
* Shared by the paid-exit and free-entry-grace paths. The caller has already
|
||||||
payload: { sessionRef: e.value },
|
* established the session is allowed out (and, for grace, minted the $0 payment). */
|
||||||
});
|
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
|
||||||
|
|
||||||
const access = this.#buildAccess(resolved.controller);
|
const access = this.#buildAccess(resolved.controller);
|
||||||
if (access) await access.pulseOpen(resolved.relay);
|
if (access) await access.pulseOpen(resolved.relay);
|
||||||
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
||||||
|
|
||||||
// SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate).
|
this.#fireExitSnapshot(e.value);
|
||||||
|
this.#closeSessionCache(e.value);
|
||||||
|
return { accepted: true, direction: "exit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
|
||||||
|
* or "manual" (a human-intervention barrier re-open that closes an open session —
|
||||||
|
* see reopenBarrier). */
|
||||||
|
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "vehicle_exit",
|
||||||
|
direction: "exit",
|
||||||
|
source,
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
...(source === "manual" ? { reason: "human-intervention exit (manual barrier open)" } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
|
||||||
|
#fireExitSnapshot(identity: string): void {
|
||||||
void snapshotAsync({
|
void snapshotAsync({
|
||||||
db: this.#db,
|
db: this.#db,
|
||||||
direction: "exit",
|
direction: "exit",
|
||||||
identity: e.value,
|
identity,
|
||||||
logger: this.#logger,
|
logger: this.#logger,
|
||||||
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
|
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the (rebuildable) session projection cache to closed. */
|
||||||
|
#closeSessionCache(identity: string): void {
|
||||||
try {
|
try {
|
||||||
this.#db
|
this.#db
|
||||||
.update(sessions)
|
.update(sessions)
|
||||||
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||||
.where(eq(sessions.id, e.value))
|
.where(eq(sessions.id, identity))
|
||||||
.run();
|
.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
return { accepted: true, direction: "exit" };
|
}
|
||||||
|
|
||||||
|
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
|
||||||
|
* The payment + exit STAND; this tells the operator to open manually. */
|
||||||
|
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity,
|
||||||
|
payload: { 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). */
|
/** 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 {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
open: !exited,
|
open: !exited,
|
||||||
paidAt,
|
paidAt,
|
||||||
|
subscription,
|
||||||
graceExitMin,
|
graceExitMin,
|
||||||
|
freeGrace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||||
|
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
|
||||||
|
#tariffVersionFor(at: string) {
|
||||||
|
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (!tariff) return null;
|
||||||
|
const versions = this.#db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all();
|
||||||
|
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a live access adapter from a resolved controller row, or null. */
|
/** Build a live access adapter from a resolved controller row, or null. */
|
||||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
@@ -35,6 +35,57 @@ export interface Quote {
|
|||||||
readonly graceExitMin: number;
|
readonly graceExitMin: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row in the booth Active Sessions list. A session is "active" while it is
|
||||||
|
* still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a
|
||||||
|
* paid/exited car is presumed possibly-still-present until grace expires. The
|
||||||
|
* "Open barrier" action is offered only when `paidAt != null` (no payment, no
|
||||||
|
* button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */
|
||||||
|
export interface ActiveSession {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly source: string | null;
|
||||||
|
readonly enteredAt: string;
|
||||||
|
/** null while still inside; set once a vehicle_exit is signed (may still be present). */
|
||||||
|
readonly exitedAt: string | null;
|
||||||
|
readonly open: boolean;
|
||||||
|
readonly paidAt: string | null;
|
||||||
|
/** Amount owed now (open + unpaid only; null otherwise / no tariff). */
|
||||||
|
readonly amountMinor: number | null;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly withinGrace: boolean;
|
||||||
|
readonly graceExpiresAt: string | null;
|
||||||
|
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
|
||||||
|
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
|
||||||
|
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
|
||||||
|
readonly subscription: boolean;
|
||||||
|
/** The subscription id (on-chain `permitId`), when `subscription` is true. */
|
||||||
|
readonly subscriptionId: string | null;
|
||||||
|
/** The subscriber's holder name (for a friendly label instead of the raw key). */
|
||||||
|
readonly subscriptionHolder: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Booth session view: everything the pay/exit modal needs in one read. */
|
||||||
|
export interface SessionLookup {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly found: boolean;
|
||||||
|
/** Open = entered, no exit yet. */
|
||||||
|
readonly open: boolean;
|
||||||
|
readonly enteredAt: string | null;
|
||||||
|
readonly exitedAt: string | null;
|
||||||
|
/** Latest payment time, if paid. */
|
||||||
|
readonly paidAt: string | null;
|
||||||
|
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
||||||
|
readonly amountMinor: number | null;
|
||||||
|
readonly currency: string | null;
|
||||||
|
/** True when paid AND still within the walk-back grace window. */
|
||||||
|
readonly withinGrace: boolean;
|
||||||
|
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||||
|
readonly graceExpiresAt: string | null;
|
||||||
|
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
|
||||||
|
readonly subscription: boolean;
|
||||||
|
readonly subscriptionId: string | null;
|
||||||
|
readonly subscriptionHolder: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class PayStation {
|
export class PayStation {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -109,6 +160,192 @@ export class PayStation {
|
|||||||
return { amountMinor, currency: q.currency };
|
return { amountMinor, currency: q.currency };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-read session view for the booth pay/exit modal: entry/exit times, paid
|
||||||
|
* state, amount owed now, and walk-back-grace status. Read-only — folds the
|
||||||
|
* signed ledger (authoritative). A quote failure (no tariff) leaves amount null
|
||||||
|
* rather than throwing, so the modal can still show the session.
|
||||||
|
*/
|
||||||
|
lookup(identity: string): SessionLookup {
|
||||||
|
const id = identity.trim();
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, id))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) {
|
||||||
|
return {
|
||||||
|
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||||
|
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
|
||||||
|
subscription: false, subscriptionId: null, subscriptionHolder: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
|
||||||
|
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
|
||||||
|
const exitRow = rows.find((r) => r.type === "vehicle_exit");
|
||||||
|
const open = !exitRow;
|
||||||
|
|
||||||
|
let paidAt: string | null = null;
|
||||||
|
let graceExitMin: number | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type === "payment") {
|
||||||
|
paidAt = r.occurredAt;
|
||||||
|
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||||
|
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const graceExpiresAt =
|
||||||
|
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
|
||||||
|
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
|
||||||
|
|
||||||
|
// Amount owed now (best-effort; null if no tariff resolves). Only meaningful while
|
||||||
|
// open AND transient — a subscription is prepaid, never quoted/charged.
|
||||||
|
let amountMinor: number | null = null;
|
||||||
|
let currency: string | null = null;
|
||||||
|
if (open && !isSubscription) {
|
||||||
|
try {
|
||||||
|
const q = this.quote(id);
|
||||||
|
amountMinor = q.amountMinor;
|
||||||
|
currency = q.currency;
|
||||||
|
} catch {
|
||||||
|
/* no active tariff — leave null; modal shows session without a price */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: id, found: true, open,
|
||||||
|
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||||
|
paidAt, amountMinor, currency, withinGrace, graceExpiresAt,
|
||||||
|
subscription: isSubscription, subscriptionId,
|
||||||
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
|
||||||
|
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
|
||||||
|
* until grace expires). One ledger scan, grouped by identity (cheaper than N
|
||||||
|
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
|
||||||
|
* (authoritative — not the sessions projection cache, which can drift).
|
||||||
|
* See wiki/concepts/booth-exit-flow.md.
|
||||||
|
*/
|
||||||
|
activeSessions(): ActiveSession[] {
|
||||||
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
|
|
||||||
|
// Group the relevant events per identity in one pass.
|
||||||
|
type Acc = {
|
||||||
|
enteredAt?: string;
|
||||||
|
source: string | null;
|
||||||
|
exitedAt?: string;
|
||||||
|
paidAt?: string;
|
||||||
|
graceExitMin?: number;
|
||||||
|
subscriptionId?: string | null;
|
||||||
|
};
|
||||||
|
const byId = new Map<string, Acc>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const id = r.identity;
|
||||||
|
if (!id) continue;
|
||||||
|
if (r.type === "vehicle_entry") {
|
||||||
|
const a = byId.get(id) ?? { source: r.source ?? null };
|
||||||
|
a.enteredAt = r.occurredAt;
|
||||||
|
a.source = r.source ?? a.source;
|
||||||
|
// Subscription occurrence? The entry payload carries permit:true + permitId
|
||||||
|
// (the on-chain field). Mark it so the booth never tries to charge it.
|
||||||
|
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
|
||||||
|
byId.set(id, a);
|
||||||
|
} else if (r.type === "vehicle_exit") {
|
||||||
|
const a = byId.get(id);
|
||||||
|
if (a) a.exitedAt = r.occurredAt;
|
||||||
|
} else if (r.type === "payment") {
|
||||||
|
const a = byId.get(id);
|
||||||
|
if (a) {
|
||||||
|
a.paidAt = r.occurredAt;
|
||||||
|
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||||
|
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const out: ActiveSession[] = [];
|
||||||
|
for (const [identity, a] of byId) {
|
||||||
|
if (!a.enteredAt) continue; // no entry → not a real session
|
||||||
|
const open = a.exitedAt == null;
|
||||||
|
const graceExpiresAt =
|
||||||
|
a.paidAt && a.graceExitMin != null
|
||||||
|
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
|
||||||
|
: null;
|
||||||
|
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
|
||||||
|
const paid = a.paidAt != null;
|
||||||
|
|
||||||
|
// ACTIVE membership:
|
||||||
|
// - exited + within grace → still shown (barrier unconfirmed, may be present);
|
||||||
|
// - exited + past grace → presumed gone, omitted;
|
||||||
|
// - open + UNPAID → always shown (a car owing money never ages out —
|
||||||
|
// it's genuinely still inside until it pays, however long that takes);
|
||||||
|
// - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace
|
||||||
|
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
|
||||||
|
// manual barrier re-open before that path closed the session, or a historical
|
||||||
|
// session like T-397815c0) it would otherwise linger forever. The signed log
|
||||||
|
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
|
||||||
|
if (!open && !withinGrace) continue;
|
||||||
|
if (open && paid && graceExpiresAt != null && !withinGrace) continue;
|
||||||
|
|
||||||
|
const isSubscription = a.subscriptionId !== undefined;
|
||||||
|
|
||||||
|
// Amount owed now: only meaningful for an open + unpaid TRANSIENT session. A
|
||||||
|
// subscription is prepaid — never quote/charge it.
|
||||||
|
let amountMinor: number | null = null;
|
||||||
|
let currency: string | null = null;
|
||||||
|
if (open && a.paidAt == null && !isSubscription) {
|
||||||
|
try {
|
||||||
|
const q = this.quote(identity);
|
||||||
|
amountMinor = q.amountMinor;
|
||||||
|
currency = q.currency;
|
||||||
|
} catch {
|
||||||
|
/* no active tariff — leave null */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
identity,
|
||||||
|
source: a.source,
|
||||||
|
enteredAt: a.enteredAt,
|
||||||
|
exitedAt: a.exitedAt ?? null,
|
||||||
|
open,
|
||||||
|
paidAt: a.paidAt ?? null,
|
||||||
|
amountMinor,
|
||||||
|
currency,
|
||||||
|
withinGrace,
|
||||||
|
graceExpiresAt,
|
||||||
|
subscription: isSubscription,
|
||||||
|
subscriptionId: a.subscriptionId ?? null,
|
||||||
|
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest entry first.
|
||||||
|
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subscriber's holder name for a subscription id (for a friendly UI label),
|
||||||
|
* or null. Best-effort: a deleted subscription just yields null. */
|
||||||
|
#holderOf(subscriptionId: string | null): string | null {
|
||||||
|
if (!subscriptionId) return null;
|
||||||
|
try {
|
||||||
|
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||||
|
return row?.holderName ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
||||||
#openEntry(identity: string) {
|
#openEntry(identity: string) {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
|
|||||||
@@ -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 { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { ExitFlow } from "./exit-flow.js";
|
import type { ExitFlow } from "./exit-flow.js";
|
||||||
import type { PermitFlow } from "./permit-flow.js";
|
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
import { relayForDevice } from "./device-resolve.js";
|
import { relayForDevice } from "./device-resolve.js";
|
||||||
|
|
||||||
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||||||
// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the
|
// can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||||
// credential is (decision 2026-06-15):
|
// credential is (decision 2026-06-15):
|
||||||
// - matches a permit (card/QR/bound plate) → PERMIT flow,
|
// - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
|
||||||
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||||
//
|
//
|
||||||
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
|
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
|
||||||
// resolves to exactly the barrier it sits at, and the direction is inherited from
|
// resolves to exactly the barrier it sits at, and the direction is inherited from
|
||||||
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
|
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
|
||||||
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||||
// reader the exit side; "both" defers to the flow's own inference (permit: session
|
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
||||||
// state; transient: exit).
|
// session state; transient: exit).
|
||||||
|
|
||||||
export class ReadDispatcher {
|
export class ReadDispatcher {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #exit: ExitFlow;
|
readonly #exit: ExitFlow;
|
||||||
readonly #permit: PermitFlow;
|
readonly #subscription: SubscriptionFlow;
|
||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) {
|
constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#exit = exit;
|
this.#exit = exit;
|
||||||
this.#permit = permit;
|
this.#subscription = subscription;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,13 +41,13 @@ export class ReadDispatcher {
|
|||||||
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
|
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const permit = this.#permit.match(e);
|
const sub = this.#subscription.match(e);
|
||||||
if (permit) {
|
if (sub) {
|
||||||
return this.#permit.run(resolved, e, permit);
|
return this.#subscription.run(resolved, e, sub);
|
||||||
}
|
}
|
||||||
// Not a permit → transient ticket exit. An ENTRY reader can't produce a transient
|
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
||||||
// exit (transient entry is the button flow, not a reader), so reject+log rather
|
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
||||||
// than treat an entry scan as an exit.
|
// rather than treat an entry scan as an exit.
|
||||||
if (resolved.direction === "entry") {
|
if (resolved.direction === "entry") {
|
||||||
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ interface LoginBody {
|
|||||||
password: string;
|
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> {
|
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
|
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
|
||||||
const { username, password } = req.body ?? {};
|
const { username, password } = req.body ?? {};
|
||||||
@@ -41,7 +47,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
csrf,
|
csrf,
|
||||||
});
|
});
|
||||||
setAuthCookies(reply, token, csrf);
|
setAuthCookies(reply, token, csrf);
|
||||||
return { id: user.id, username: user.username, role: user.role };
|
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
||||||
|
// read from the DB, so changing it needs no token refresh.
|
||||||
|
return { id: user.id, username: user.username, role: user.role, language: user.language };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/logout", async (_req, reply) => {
|
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 };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Who am I — used by the SPA to bootstrap session state on load.
|
// Who am I — used by the SPA to bootstrap session state on load. Reads the live
|
||||||
|
// `language` preference from the DB (not the token).
|
||||||
app.get(
|
app.get(
|
||||||
"/api/auth/me",
|
"/api/auth/me",
|
||||||
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
|
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const { sub, username, role } = req.user;
|
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 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 { requireRole } from "../auth.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
@@ -17,12 +17,22 @@ export async function eventRoutes(
|
|||||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
|
||||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
||||||
app.get<{ Querystring: { limit?: string } }>(
|
// Optional `since` (ISO) scopes the page to events at/after that instant — the
|
||||||
|
// booth passes the current shift's start so the live feed shows ONLY this shift's
|
||||||
|
// activity (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
||||||
|
app.get<{ Querystring: { limit?: string; since?: string } }>(
|
||||||
"/api/events",
|
"/api/events",
|
||||||
{ preHandler: guard },
|
{ preHandler: guard },
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||||
const rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all();
|
const since = (req.query.since ?? "").trim();
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(since ? gte(ledgerEvents.occurredAt, since) : undefined)
|
||||||
|
.orderBy(desc(ledgerEvents.index))
|
||||||
|
.limit(limit)
|
||||||
|
.all();
|
||||||
return { events: rows };
|
return { events: rows };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
import {
|
import {
|
||||||
NoOpenSessionError,
|
NoOpenSessionError,
|
||||||
NoTariffError,
|
NoTariffError,
|
||||||
type PayStation,
|
type PayStation,
|
||||||
} from "../pay-station.js";
|
} from "../pay-station.js";
|
||||||
|
import type { ExitFlow } from "../exit-flow.js";
|
||||||
|
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
||||||
|
import { printExitVoucher } from "../booth-print.js";
|
||||||
|
|
||||||
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session
|
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
|
||||||
// then takes payment; the payment becomes a signed ledger event. PCI scope stays
|
// when the booth is at/near the exit — open the barrier. The payment becomes a
|
||||||
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender`
|
// signed ledger event; PCI scope stays OUT of the app (card capture is a standalone
|
||||||
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md.
|
// P2PE terminal; `tender` just records cash vs. card). The booth exit reuses the
|
||||||
|
// SAME validation as the reader path — no booth-only bypass admits an unpaid car.
|
||||||
|
// See wiki/concepts/tariff.md, parking-session.md, booth-exit-flow.md, bom.md.
|
||||||
|
|
||||||
interface QuoteQuery {
|
interface QuoteQuery {
|
||||||
identity: string;
|
identity: string;
|
||||||
@@ -20,11 +27,97 @@ interface PayBody {
|
|||||||
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
|
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
|
||||||
overrideMinor?: number;
|
overrideMinor?: number;
|
||||||
}
|
}
|
||||||
|
interface ExitBody {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
interface VoucherBody {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> {
|
export async function payRoutes(
|
||||||
// Cashier/operator/admin operate the pay station; readonly may not.
|
app: FastifyInstance,
|
||||||
|
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");
|
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.)
|
// Quote: what does this session owe right now? (No side effect.)
|
||||||
app.get<{ Querystring: QuoteQuery }>(
|
app.get<{ Querystring: QuoteQuery }>(
|
||||||
"/api/pay/quote",
|
"/api/pay/quote",
|
||||||
@@ -43,7 +136,7 @@ export async function payRoutes(app: FastifyInstance, payStation: PayStation): P
|
|||||||
// Pay: take payment and append the signed `payment` event.
|
// Pay: take payment and append the signed `payment` event.
|
||||||
app.post<{ Body: PayBody }>(
|
app.post<{ Body: PayBody }>(
|
||||||
"/api/pay",
|
"/api/pay",
|
||||||
{ preHandler: guard },
|
{ preHandler: [guard, requireShift] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { identity, tender, overrideMinor } = req.body ?? {};
|
const { identity, tender, overrideMinor } = req.body ?? {};
|
||||||
if (!identity || (tender !== "cash" && tender !== "card")) {
|
if (!identity || (tender !== "cash" && tender !== "card")) {
|
||||||
@@ -60,6 +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) {
|
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 { eq, devices, type Db } from "@parking/db";
|
||||||
import type { DeviceReadEvent } from "../device-events.js";
|
import type { DeviceReadEvent } from "../device-events.js";
|
||||||
import type { ReadDispatcher } from "../read-dispatch.js";
|
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||||
|
import type { CredentialCapture } from "../credential-capture.js";
|
||||||
|
|
||||||
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
||||||
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||||
@@ -30,6 +31,7 @@ export async function qrReaderRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
dispatcher: ReadDispatcher,
|
dispatcher: ReadDispatcher,
|
||||||
|
capture: CredentialCapture,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||||
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||||
@@ -59,10 +61,19 @@ export async function qrReaderRoutes(
|
|||||||
// Map the reader's serial → its assigned lane_devices row id (the dispatcher
|
// Map the reader's serial → its assigned lane_devices row id (the dispatcher
|
||||||
// resolves the lane from that row). If unassigned, deviceId stays the serial so
|
// resolves the lane from that row). If unassigned, deviceId stays the serial so
|
||||||
// the dispatcher simply finds no lane and rejects (status:0) — never crashes.
|
// the dispatcher simply finds no lane and rejects (status:0) — never crashes.
|
||||||
const deviceId = readerRowIdForSerial(serial) ?? serial;
|
const matchedRowId = readerRowIdForSerial(serial);
|
||||||
|
const deviceId = matchedRowId ?? serial;
|
||||||
|
|
||||||
let accepted = false;
|
let accepted = false;
|
||||||
if (cardid) {
|
if (cardid) {
|
||||||
|
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
|
||||||
|
// value for the subscription form and do NOT run the access flow (we must not
|
||||||
|
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
|
||||||
|
// Reads from the OTHER reader are untouched and dispatch normally below.
|
||||||
|
if (capture.tryConsume(deviceId, cardid)) {
|
||||||
|
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
|
||||||
|
accepted = true; // beep "ok" so the operator knows the card was read
|
||||||
|
} else {
|
||||||
const read: DeviceReadEvent = {
|
const read: DeviceReadEvent = {
|
||||||
driverId: "gee-qr-reader",
|
driverId: "gee-qr-reader",
|
||||||
deviceId,
|
deviceId,
|
||||||
@@ -73,11 +84,19 @@ export async function qrReaderRoutes(
|
|||||||
try {
|
try {
|
||||||
const outcome = await dispatcher.dispatch(read);
|
const outcome = await dispatcher.dispatch(read);
|
||||||
accepted = outcome.accepted;
|
accepted = outcome.accepted;
|
||||||
if (!accepted) app.log.info(`QR ${cardid} rejected: ${outcome.reason ?? "?"}`);
|
// Per-read diagnostic: which reader (serial) sent it, which configured device
|
||||||
|
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
|
||||||
|
// the logs (e.g. an entry-side scan resolving to the exit relay).
|
||||||
|
app.log.info(
|
||||||
|
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
|
||||||
|
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
||||||
|
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
|
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
|
||||||
// output 0 = Access (drive the reader's access line on a valid read).
|
// output 0 = Access (drive the reader's access line on a valid read).
|
||||||
|
|||||||
+172
-82
@@ -51,6 +51,127 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
||||||
|
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||||
|
type ConfigureOutcome =
|
||||||
|
| { config: Record<string, unknown>; warnings: string[] }
|
||||||
|
| { error: { code: number; message: string } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate + configure a device, returning the config to persist. Runs the same
|
||||||
|
* pipeline for both create and edit: validate the driver config, fix
|
||||||
|
* preconditions, harden (relay password + protocol lockdown), and set up input
|
||||||
|
* push (Digest creds + push URLs). Each step is a device write (the device
|
||||||
|
* reboots on apply). The caller owns the DB row; this never touches the DB.
|
||||||
|
*
|
||||||
|
* `id` is the assignment id (stable across an edit) — it's baked into the push
|
||||||
|
* URL, so editing in place keeps the device pushing to the same path.
|
||||||
|
* `existingConfig` carries forward secrets the client never sees on edit
|
||||||
|
* (push/relay passwords), so a PATCH that omits them doesn't wipe them.
|
||||||
|
*/
|
||||||
|
async function configureDevice(
|
||||||
|
app: FastifyInstance,
|
||||||
|
args: {
|
||||||
|
id: string;
|
||||||
|
driverId: string;
|
||||||
|
config: DeviceConfig;
|
||||||
|
backendIp?: string;
|
||||||
|
existingConfig?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<ConfigureOutcome> {
|
||||||
|
const { id, driverId, config, backendIp, existingConfig } = args;
|
||||||
|
|
||||||
|
// Start from any machine-only secrets already on the row (push/relay passwords
|
||||||
|
// are redacted out of the client's copy, so an edit would otherwise drop them),
|
||||||
|
// then layer the submitted config on top.
|
||||||
|
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
|
||||||
|
// The web password the admin typed is a DESIRED value, not a stored fact:
|
||||||
|
// it's passed to the driver (via create(config) below) as the rotation
|
||||||
|
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
|
||||||
|
// secrets.webPassword gets saved — otherwise a failed rotation would leave
|
||||||
|
// the DB claiming a password the device never accepted (login stays old).
|
||||||
|
delete fullConfig.webPassword;
|
||||||
|
// webPasswordCurrent is an input-only credential (the OLD password used to
|
||||||
|
// authorize the change) — never persist it as typed.
|
||||||
|
delete fullConfig.webPasswordCurrent;
|
||||||
|
// Residual-risk warnings from device hardening (shown to the admin; the
|
||||||
|
// save still succeeds — these are "configured, but note X" advisories).
|
||||||
|
const hardenWarnings: string[] = [];
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, config); // validates required fields
|
||||||
|
} catch (err) {
|
||||||
|
return { error: { code: 400, message: (err as Error).message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure the device on save (before persisting, so we don't store a row
|
||||||
|
// for a device we couldn't configure):
|
||||||
|
// 1. fix preconditions (e.g. disable input_link_relay so a button press
|
||||||
|
// doesn't auto-fire its relay — host must decide first),
|
||||||
|
// 2. harden (relay password + disable unused protocol channels), and
|
||||||
|
// 3. set up input push (Digest creds + push URLs).
|
||||||
|
// Each step is a device config write (the device reboots on apply).
|
||||||
|
try {
|
||||||
|
if (hasPreconditions(device)) {
|
||||||
|
const fixed = await device.fixPreconditions();
|
||||||
|
if (!fixed.ok) {
|
||||||
|
const unfixable = fixed.issues.find((i) => !i.fixable);
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: 502,
|
||||||
|
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHardenable(device)) {
|
||||||
|
const { secrets, warnings } = await device.harden();
|
||||||
|
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
||||||
|
// Surface residual-risk warnings (e.g. firmware that won't disable the
|
||||||
|
// password-less string protocol) so the admin can act (web-UI step).
|
||||||
|
for (const w of warnings ?? []) {
|
||||||
|
app.log.warn(`harden(${driverId} ${id}): ${w}`);
|
||||||
|
hardenWarnings.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPushConfig(device)) {
|
||||||
|
const host = String(config.host ?? "");
|
||||||
|
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
|
||||||
|
const pushHost = backendIp ?? backendIpForDevice(host);
|
||||||
|
if (!pushHost) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: 400,
|
||||||
|
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pushUser = "dingtian";
|
||||||
|
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
||||||
|
// (longer is silently truncated → auth mismatch), so keep it short.
|
||||||
|
const pushPassword = randomBytes(12).toString("hex");
|
||||||
|
await device.configureInputPush({
|
||||||
|
host: pushHost,
|
||||||
|
port: backendPort(),
|
||||||
|
pathBase: `/api/devices/${driverId}/${id}/input`,
|
||||||
|
auth: { user: pushUser, password: pushPassword },
|
||||||
|
});
|
||||||
|
fullConfig.pushUser = pushUser;
|
||||||
|
fullConfig.pushPassword = pushPassword;
|
||||||
|
// Record the backend IP the device was told to push to — lets us detect
|
||||||
|
// a later mismatch if the host's IP changes.
|
||||||
|
fullConfig.backendIp = pushHost;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: fullConfig, warnings: hardenWarnings };
|
||||||
|
}
|
||||||
|
|
||||||
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
@@ -166,100 +287,69 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const fullConfig: Record<string, unknown> = { ...config };
|
const outcome = await configureDevice(app, { id, driverId, config, backendIp });
|
||||||
// The web password the admin typed is a DESIRED value, not a stored fact:
|
if ("error" in outcome) {
|
||||||
// it's passed to the driver (via create(config) below) as the rotation
|
return reply.code(outcome.error.code).send({ error: outcome.error.message });
|
||||||
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
|
|
||||||
// secrets.webPassword gets saved — otherwise a failed rotation would leave
|
|
||||||
// the DB claiming a password the device never accepted (login stays old).
|
|
||||||
delete fullConfig.webPassword;
|
|
||||||
// webPasswordCurrent is an input-only credential (the OLD password used to
|
|
||||||
// authorize the change) — never persist it as typed.
|
|
||||||
delete fullConfig.webPasswordCurrent;
|
|
||||||
// Residual-risk warnings from device hardening (shown to the admin; the
|
|
||||||
// save still succeeds — these are "configured, but note X" advisories).
|
|
||||||
const hardenWarnings: string[] = [];
|
|
||||||
|
|
||||||
let device;
|
|
||||||
try {
|
|
||||||
device = registry.create(driverId, config); // validates required fields
|
|
||||||
} catch (err) {
|
|
||||||
return reply.code(400).send({ error: (err as Error).message });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure the device on save (before persisting, so we don't store a row
|
|
||||||
// for a device we couldn't configure):
|
|
||||||
// 1. fix preconditions (e.g. disable input_link_relay so a button press
|
|
||||||
// doesn't auto-fire its relay — host must decide first),
|
|
||||||
// 2. harden (relay password + disable unused protocol channels), and
|
|
||||||
// 3. set up input push (Digest creds + push URLs).
|
|
||||||
// Each step is a device config write (the device reboots on apply).
|
|
||||||
try {
|
|
||||||
if (hasPreconditions(device)) {
|
|
||||||
const fixed = await device.fixPreconditions();
|
|
||||||
if (!fixed.ok) {
|
|
||||||
const unfixable = fixed.issues.find((i) => !i.fixable);
|
|
||||||
return reply.code(502).send({
|
|
||||||
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isHardenable(device)) {
|
|
||||||
const { secrets, warnings } = await device.harden();
|
|
||||||
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
|
||||||
// Surface residual-risk warnings (e.g. firmware that won't disable the
|
|
||||||
// password-less string protocol) so the admin can act (web-UI step).
|
|
||||||
for (const w of warnings ?? []) {
|
|
||||||
app.log.warn(`harden(${driverId} ${id}): ${w}`);
|
|
||||||
hardenWarnings.push(w);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasPushConfig(device)) {
|
|
||||||
const host = String(config.host ?? "");
|
|
||||||
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
|
|
||||||
const pushHost = backendIp ?? backendIpForDevice(host);
|
|
||||||
if (!pushHost) {
|
|
||||||
return reply.code(400).send({
|
|
||||||
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const pushUser = "dingtian";
|
|
||||||
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
|
||||||
// (longer is silently truncated → auth mismatch), so keep it short.
|
|
||||||
const pushPassword = randomBytes(12).toString("hex");
|
|
||||||
await device.configureInputPush({
|
|
||||||
host: pushHost,
|
|
||||||
port: backendPort(),
|
|
||||||
pathBase: `/api/devices/${driverId}/${id}/input`,
|
|
||||||
auth: { user: pushUser, password: pushPassword },
|
|
||||||
});
|
|
||||||
fullConfig.pushUser = pushUser;
|
|
||||||
fullConfig.pushPassword = pushPassword;
|
|
||||||
// Record the backend IP the device was told to push to — lets us detect
|
|
||||||
// a later mismatch if the host's IP changes.
|
|
||||||
fullConfig.backendIp = pushHost;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
return reply
|
|
||||||
.code(502)
|
|
||||||
.send({ error: `device configuration failed: ${(err as Error).message}` });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = {
|
const row = {
|
||||||
id,
|
id,
|
||||||
category,
|
category,
|
||||||
driverId,
|
driverId,
|
||||||
config: fullConfig,
|
config: outcome.config,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
await db.insert(devices).values(row);
|
await db.insert(devices).values(row);
|
||||||
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
...row,
|
...row,
|
||||||
config: redactSecrets(fullConfig),
|
config: redactSecrets(outcome.config),
|
||||||
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
|
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Edit an assigned device in place. Same configure pipeline as assign, but it
|
||||||
|
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
|
||||||
|
// since the id is baked into the device's input-push URL
|
||||||
|
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
|
||||||
|
// break push until reconfigured; PATCH re-runs harden/push against the same id.
|
||||||
|
// The category and driver are fixed at create time (an edit can't change what
|
||||||
|
// KIND of device a slot is); only config changes. Admin-only.
|
||||||
|
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
|
||||||
|
"/api/setup/assign/:id",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.id, req.params.id))
|
||||||
|
.get();
|
||||||
|
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
||||||
|
|
||||||
|
const { config, backendIp } = req.body;
|
||||||
|
const outcome = await configureDevice(app, {
|
||||||
|
id: existing.id,
|
||||||
|
driverId: existing.driverId,
|
||||||
|
config,
|
||||||
|
backendIp,
|
||||||
|
// Carry forward machine-only secrets the client never received, so an
|
||||||
|
// edit that omits them doesn't blank out push/relay passwords.
|
||||||
|
existingConfig: existing.config,
|
||||||
|
});
|
||||||
|
if ("error" in outcome) {
|
||||||
|
return reply.code(outcome.error.code).send({ error: outcome.error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
|
||||||
|
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
|
||||||
|
return reply.code(200).send({
|
||||||
|
id: existing.id,
|
||||||
|
category: existing.category,
|
||||||
|
driverId: existing.driverId,
|
||||||
|
config: redactSecrets(outcome.config),
|
||||||
|
enabled: existing.enabled,
|
||||||
|
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
import {
|
import {
|
||||||
|
InvalidCashMovementError,
|
||||||
NoOpenShiftError,
|
NoOpenShiftError,
|
||||||
ShiftAlreadyOpenError,
|
ShiftAlreadyOpenError,
|
||||||
type ShiftService,
|
type ShiftService,
|
||||||
} from "../shift-service.js";
|
} from "../shift-service.js";
|
||||||
|
|
||||||
|
interface CashMovementBody {
|
||||||
|
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
|
||||||
|
amountMinor: number;
|
||||||
|
reason?: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
@@ -14,13 +22,43 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
|||||||
// Cashier/operator/admin run shifts; readonly can't.
|
// Cashier/operator/admin run shifts; readonly can't.
|
||||||
const guard = requireRole("admin", "operator", "cashier");
|
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) => {
|
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
|
||||||
const operator = req.user.username;
|
const me = req.user.username;
|
||||||
const open = shift.openShiftFor(operator);
|
const open = shift.currentOpenShift();
|
||||||
return { operator, open: open ? { startedAt: open.occurredAt } : null };
|
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) => {
|
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
return await shift.open(req.user.username);
|
return await shift.open(req.user.username);
|
||||||
|
|||||||
@@ -7,9 +7,49 @@ import { getOccupancy } from "../occupancy.js";
|
|||||||
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
|
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
|
||||||
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|
||||||
interface SiteConfigBody {
|
// Optional park-metadata text fields (all nullable). Trimmed; "" → null.
|
||||||
|
const TEXT_FIELDS = [
|
||||||
|
"parkName",
|
||||||
|
"operatorName",
|
||||||
|
"nius",
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
] as const;
|
||||||
|
type TextField = (typeof TEXT_FIELDS)[number];
|
||||||
|
|
||||||
|
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||||
/** Nominal capacity; null = no limit. */
|
/** Nominal capacity; null = no limit. */
|
||||||
|
capacity?: number | null;
|
||||||
|
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||||||
|
exitVoucherDefault?: boolean;
|
||||||
|
/** Site default monthly subscription price in minor units (pre-fills the form). */
|
||||||
|
subscriptionMonthlyPriceMinor?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||||
|
* + every metadata field. */
|
||||||
|
type SiteConfig = {
|
||||||
capacity: number | null;
|
capacity: number | null;
|
||||||
|
exitVoucherDefault: boolean;
|
||||||
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
|
} & Record<TextField, string | null>;
|
||||||
|
|
||||||
|
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||||
|
const out = {
|
||||||
|
capacity: row?.capacity ?? null,
|
||||||
|
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||||
|
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||||
|
} as SiteConfig;
|
||||||
|
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim a text field; empty string becomes null so blank input clears it. */
|
||||||
|
function normText(v: unknown): string | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const s = String(v).trim();
|
||||||
|
return s === "" ? null : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
@@ -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.
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||||
|
|
||||||
// Read site config (capacity).
|
// Read site config (capacity + park metadata).
|
||||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
return { capacity: row?.capacity ?? null };
|
return toSiteConfig(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set capacity (admin). null or 0+ integer.
|
// Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
|
||||||
|
// (only the fields PRESENT in the body are updated; absent fields are untouched).
|
||||||
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
|
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
|
||||||
const { capacity } = req.body ?? ({} as SiteConfigBody);
|
const body = req.body ?? ({} as SiteConfigBody);
|
||||||
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
|
|
||||||
|
const patch: Partial<typeof siteConfig.$inferInsert> = {};
|
||||||
|
if ("capacity" in body) {
|
||||||
|
const c = body.capacity;
|
||||||
|
if (c != null && (!Number.isInteger(c) || c < 0)) {
|
||||||
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
|
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
|
||||||
}
|
}
|
||||||
|
patch.capacity = c ?? null;
|
||||||
|
}
|
||||||
|
if ("exitVoucherDefault" in body) {
|
||||||
|
if (typeof body.exitVoucherDefault !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: "exitVoucherDefault must be a boolean" });
|
||||||
|
}
|
||||||
|
patch.exitVoucherDefault = body.exitVoucherDefault;
|
||||||
|
}
|
||||||
|
if ("subscriptionMonthlyPriceMinor" in body) {
|
||||||
|
const p = body.subscriptionMonthlyPriceMinor;
|
||||||
|
if (p != null && (!Number.isInteger(p) || p < 0)) {
|
||||||
|
return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" });
|
||||||
|
}
|
||||||
|
patch.subscriptionMonthlyPriceMinor = p ?? null;
|
||||||
|
}
|
||||||
|
for (const f of TEXT_FIELDS) {
|
||||||
|
if (f in body) patch[f] = normText(body[f]);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
const updatedAt = new Date().toISOString();
|
const updatedAt = new Date().toISOString();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
} else {
|
} else {
|
||||||
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run();
|
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||||
}
|
}
|
||||||
return { capacity: capacity ?? null };
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return toSiteConfig(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,12 +62,37 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
if (problems.length) {
|
if (problems.length) {
|
||||||
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// effectiveFrom must NOT be in the past. A version is selected by
|
||||||
|
// "latest effectiveFrom <= entry time", so a backdated effectiveFrom would
|
||||||
|
// retroactively reprice already-entered sessions — exactly the immutability
|
||||||
|
// the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid
|
||||||
|
// backdating: a new version applies only from publish (now) forward; a future
|
||||||
|
// effectiveFrom (scheduling a price change) is allowed. A small skew tolerance
|
||||||
|
// absorbs client/server clock drift + request round-trip. Once a car has
|
||||||
|
// entered, no later publish can reprice it (no effectiveFrom can predate it).
|
||||||
|
const now = Date.now();
|
||||||
|
const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack
|
||||||
|
let effective = new Date().toISOString();
|
||||||
|
if (effectiveFrom != null) {
|
||||||
|
const t = Date.parse(effectiveFrom);
|
||||||
|
if (Number.isNaN(t)) {
|
||||||
|
return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" });
|
||||||
|
}
|
||||||
|
if (t < now - SKEW_MS) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
effective = new Date(t).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
const tariffId = ensureSiteTariff();
|
const tariffId = ensureSiteTariff();
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const row = {
|
const row = {
|
||||||
id,
|
id,
|
||||||
tariffId,
|
tariffId,
|
||||||
effectiveFrom: effectiveFrom ?? new Date().toISOString(),
|
effectiveFrom: effective,
|
||||||
currency,
|
currency,
|
||||||
structure: structure as unknown as Record<string, unknown>,
|
structure: structure as unknown as Record<string, unknown>,
|
||||||
createdBy: req.user?.username ?? null,
|
createdBy: req.user?.username ?? null,
|
||||||
|
|||||||
@@ -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 cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
import jwt from "@fastify/jwt";
|
||||||
|
import websocket from "@fastify/websocket";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
@@ -9,16 +10,18 @@ import { EntryFlow } from "./entry-flow.js";
|
|||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
import { PayStation } from "./pay-station.js";
|
import { PayStation } from "./pay-station.js";
|
||||||
import { PermitFlow } from "./permit-flow.js";
|
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
import { ShiftService } from "./shift-service.js";
|
import { ShiftService } from "./shift-service.js";
|
||||||
import { ReadDispatcher } from "./read-dispatch.js";
|
import { ReadDispatcher } from "./read-dispatch.js";
|
||||||
|
import { CredentialCapture } from "./credential-capture.js";
|
||||||
import { PrinterMonitor } from "./printer-monitor.js";
|
import { PrinterMonitor } from "./printer-monitor.js";
|
||||||
import { buildSigner } from "./signer.js";
|
import { DeviceMonitor } from "./device-monitor.js";
|
||||||
|
import { buildSigner, buildVerifier } from "./signer.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { payRoutes } from "./routes/pay.js";
|
import { payRoutes } from "./routes/pay.js";
|
||||||
import { permitRoutes } from "./routes/permits.js";
|
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||||
import { shiftRoutes } from "./routes/shift.js";
|
import { shiftRoutes } from "./routes/shift.js";
|
||||||
import { siteRoutes } from "./routes/site.js";
|
import { siteRoutes } from "./routes/site.js";
|
||||||
@@ -26,6 +29,8 @@ import { snapshotRoutes } from "./routes/snapshots.js";
|
|||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
|
import { deviceStatusRoutes } from "./routes/device-status.js";
|
||||||
|
import { wsRoutes } from "./routes/ws.js";
|
||||||
|
|
||||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
||||||
// plugins emitting onto a shared internal event bus; auth is fully local
|
// plugins emitting onto a shared internal event bus; auth is fully local
|
||||||
@@ -44,6 +49,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
await app.register(cookie);
|
await app.register(cookie);
|
||||||
|
|
||||||
|
// WebSocket support for the live booth feed (/api/ws). Registered before the
|
||||||
|
// routes so the `{ websocket: true }` route option is available.
|
||||||
|
await app.register(websocket);
|
||||||
|
|
||||||
// Local JWT signing with a local secret — no external identity provider.
|
// Local JWT signing with a local secret — no external identity provider.
|
||||||
// Fail fast rather than fall back to a known default: a booth machine started
|
// Fail fast rather than fall back to a known default: a booth machine started
|
||||||
// without a real secret would sign tokens anyone could forge (incl. an admin
|
// without a real secret would sign tokens anyone could forge (incl. an admin
|
||||||
@@ -80,15 +89,32 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onReady", async () => printerMonitor.start());
|
app.addHook("onReady", async () => printerMonitor.start());
|
||||||
app.addHook("onClose", async () => printerMonitor.stop());
|
app.addHook("onClose", async () => printerMonitor.stop());
|
||||||
|
|
||||||
|
// Unified device-status monitor: polls EVERY configured device (relays/readers/
|
||||||
|
// cameras via healthCheck, printers via rich readStatus) and feeds the booth's
|
||||||
|
// device-status footer over the WS. Read-only — never drives a relay.
|
||||||
|
// See wiki/concepts/device-status-monitoring.md.
|
||||||
|
const deviceMonitor = new DeviceMonitor(db, app.log);
|
||||||
|
await deviceStatusRoutes(app, deviceMonitor);
|
||||||
|
app.addHook("onReady", async () => deviceMonitor.start());
|
||||||
|
app.addHook("onClose", async () => deviceMonitor.stop());
|
||||||
|
|
||||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||||
// See wiki/decisions/event-streams-split.md.
|
// See wiki/decisions/event-streams-split.md.
|
||||||
const eventLog = new EventLog(db, buildSigner(app.log));
|
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||||
|
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||||
|
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||||
|
deviceEvents.emitLedger(row),
|
||||||
|
);
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
|
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||||
|
await wsRoutes(app, db, deviceMonitor);
|
||||||
|
|
||||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
await snapshotRoutes(app, db);
|
await snapshotRoutes(app, db);
|
||||||
|
|
||||||
@@ -103,36 +129,48 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onClose", async () => unsubscribeEntry());
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
|
||||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||||
// dispatcher to either the PERMIT flow (if it matches a permit) or the transient
|
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||||
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md.
|
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
||||||
|
// parking-session.md.
|
||||||
const exitFlow = new ExitFlow(db, eventLog, app.log);
|
const exitFlow = new ExitFlow(db, eventLog, app.log);
|
||||||
const permitFlow = new PermitFlow(db, eventLog, app.log);
|
const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log);
|
||||||
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log);
|
const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
|
||||||
const unsubscribeRead = deviceEvents.onRead((e) => {
|
const unsubscribeRead = deviceEvents.onRead((e) => {
|
||||||
void readDispatcher.dispatch(e);
|
void readDispatcher.dispatch(e);
|
||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeRead());
|
app.addHook("onClose", async () => unsubscribeRead());
|
||||||
|
|
||||||
|
// Credential capture ("enroll a card"): lets the operator present an RFID card to a
|
||||||
|
// CHOSEN reader to populate a subscription credential, without blocking the other
|
||||||
|
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
||||||
|
const credentialCapture = new CredentialCapture();
|
||||||
|
|
||||||
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||||
// verdict (host-in-the-loop, synchronous). Routes the read through the dispatcher
|
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
||||||
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
// on an armed reader for enrollment; otherwise the read routes through the
|
||||||
await qrReaderRoutes(app, db, readDispatcher);
|
// dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||||
|
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
|
||||||
|
|
||||||
|
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||||
|
// (sum payments by tender, print the Z-report). Constructed before the pay routes
|
||||||
|
// because the booth money path is GATED on an open shift. See wiki/concepts/shift.md.
|
||||||
|
const shiftService = new ShiftService(db, eventLog, app.log);
|
||||||
|
|
||||||
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||||
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
|
||||||
|
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
||||||
const payStation = new PayStation(db, eventLog, app.log);
|
const payStation = new PayStation(db, eventLog, app.log);
|
||||||
await payRoutes(app, payStation);
|
await payRoutes(app, db, payStation, exitFlow, shiftService);
|
||||||
|
|
||||||
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
await tariffRoutes(app, db);
|
await tariffRoutes(app, db);
|
||||||
|
|
||||||
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
||||||
await permitRoutes(app, db);
|
// wiki/entities/subscription.md.
|
||||||
|
await subscriptionRoutes(app, db, credentialCapture);
|
||||||
|
|
||||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||||
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
|
|
||||||
const shiftService = new ShiftService(db, eventLog, app.log);
|
|
||||||
await shiftRoutes(app, shiftService);
|
await shiftRoutes(app, shiftService);
|
||||||
|
|
||||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ import type { EventLog } from "./event-log.js";
|
|||||||
// See wiki/concepts/shift.md.
|
// See wiki/concepts/shift.md.
|
||||||
|
|
||||||
export class ShiftAlreadyOpenError extends Error {
|
export class ShiftAlreadyOpenError extends Error {
|
||||||
constructor(operator: string) {
|
/** The operator who currently holds the open shift (may be someone else). */
|
||||||
super(`operator ${operator} already has an open shift`);
|
readonly heldBy: string;
|
||||||
|
constructor(operator: string, heldBy: string) {
|
||||||
|
super(
|
||||||
|
heldBy === operator
|
||||||
|
? `operator ${operator} already has an open shift`
|
||||||
|
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
|
||||||
|
);
|
||||||
this.name = "ShiftAlreadyOpenError";
|
this.name = "ShiftAlreadyOpenError";
|
||||||
|
this.heldBy = heldBy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class NoOpenShiftError extends Error {
|
export class NoOpenShiftError extends Error {
|
||||||
@@ -22,6 +29,14 @@ export class NoOpenShiftError extends Error {
|
|||||||
this.name = "NoOpenShiftError";
|
this.name = "NoOpenShiftError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** Thrown by the booth money path when NO shift is open site-wide — an operator
|
||||||
|
* must open a shift before any payment/exit can be attributed to a shift. */
|
||||||
|
export class NoShiftOpenError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("no shift is open — open a shift before processing tickets");
|
||||||
|
this.name = "NoShiftOpenError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShiftReport {
|
export interface ShiftReport {
|
||||||
readonly operator: string;
|
readonly operator: string;
|
||||||
@@ -31,9 +46,25 @@ export interface ShiftReport {
|
|||||||
readonly cardTotalMinor: number;
|
readonly cardTotalMinor: number;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
readonly paymentCount: number;
|
readonly paymentCount: number;
|
||||||
|
// --- Drawer (physical cash till; carries across shifts) ---
|
||||||
|
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||||
|
readonly openingFloatMinor: number;
|
||||||
|
/** Admin cash LOADED into the drawer during the shift (sum of + movements). */
|
||||||
|
readonly cashAddedMinor: number;
|
||||||
|
/** Admin cash REMOVED from the drawer during the shift (sum of − movements, as +). */
|
||||||
|
readonly cashRemovedMinor: number;
|
||||||
|
/** Expected drawer at close = opening + cashTaken + added − removed. Carries forward. */
|
||||||
|
readonly expectedDrawerMinor: number;
|
||||||
readonly printed: boolean;
|
readonly printed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InvalidCashMovementError extends Error {
|
||||||
|
constructor(msg: string) {
|
||||||
|
super(msg);
|
||||||
|
this.name = "InvalidCashMovementError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ShiftService {
|
export class ShiftService {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -45,6 +76,12 @@ export class ShiftService {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current physical drawer balance (cash payments + cash_movements, by time). For
|
||||||
|
* the UI to show "inherited / in the drawer now". */
|
||||||
|
drawerBalance(): { balanceMinor: number; currency: string | null } {
|
||||||
|
return this.#drawerBalanceAt(new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
||||||
openShiftFor(operator: string) {
|
openShiftFor(operator: string) {
|
||||||
// Scan shift events for this operator; the shift is open if the most recent
|
// Scan shift events for this operator; the shift is open if the most recent
|
||||||
@@ -60,19 +97,116 @@ export class ShiftService {
|
|||||||
return last && last.type === "shift_open" ? last : null;
|
return last && last.type === "shift_open" ? last : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a shift for the operator (explicit start). */
|
/**
|
||||||
async open(operator: string): Promise<{ startedAt: string }> {
|
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
|
||||||
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator);
|
* period: at most ONE may be open at a time (so booth takings are unambiguously
|
||||||
|
* attributed to one operator). It's open iff the most recent shift event on the
|
||||||
|
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
|
||||||
|
* appended yet). Returns that row so callers can read its operator/startedAt.
|
||||||
|
*/
|
||||||
|
currentOpenShift() {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
return last && last.type === "shift_open" ? last : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require an open shift for the booth money path; returns it or throws. */
|
||||||
|
requireOpenShift() {
|
||||||
|
const open = this.currentOpenShift();
|
||||||
|
if (!open) throw new NoShiftOpenError();
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
|
||||||
|
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
|
||||||
|
* payments add to the drawer; card payments never touch it; cash_movement amounts
|
||||||
|
* (signed: + load, − removal) adjust it. This is what carries across shifts.
|
||||||
|
*/
|
||||||
|
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement"));
|
||||||
|
let balanceMinor = 0;
|
||||||
|
let currency: string | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (r.type === "payment") {
|
||||||
|
// Only CASH enters the till; card settles to the bank.
|
||||||
|
if (pl.tender !== "card") balanceMinor += amt;
|
||||||
|
} else {
|
||||||
|
// cash_movement amount is signed (+ load, − removal).
|
||||||
|
balanceMinor += amt;
|
||||||
|
}
|
||||||
|
if (pl.currency) currency = pl.currency;
|
||||||
|
}
|
||||||
|
return { balanceMinor, currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an admin cash movement (load/remove drawer float). `amountMinor` is
|
||||||
|
* signed: positive = cash loaded IN, negative = cash taken OUT. Signed +
|
||||||
|
* attributed. Admin-only is enforced at the route. Returns the new drawer balance.
|
||||||
|
*/
|
||||||
|
async recordCashMovement(
|
||||||
|
operator: string,
|
||||||
|
amountMinor: number,
|
||||||
|
reason: string,
|
||||||
|
currency?: string,
|
||||||
|
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||||||
|
if (!Number.isInteger(amountMinor) || amountMinor === 0) {
|
||||||
|
throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)");
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "cash_movement",
|
||||||
|
source: "manual",
|
||||||
|
identity: operator, // who moved the cash (admin)
|
||||||
|
payload: {
|
||||||
|
amountMinor,
|
||||||
|
...(reason ? { reason } : {}),
|
||||||
|
...(currency ? { currency } : {}),
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
occurredAt: now,
|
||||||
|
});
|
||||||
|
const { balanceMinor } = this.#drawerBalanceAt(now);
|
||||||
|
this.#logger.info(
|
||||||
|
`cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||||
|
);
|
||||||
|
return { amountMinor, balanceMinor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||||
|
* inherited from the chain = the drawer balance at the start instant. */
|
||||||
|
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||||
|
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
|
||||||
|
// operator's own (double-open) or another operator's (handover not done). Only
|
||||||
|
// one accountability period at a time.
|
||||||
|
const current = this.currentOpenShift();
|
||||||
|
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "shift_open",
|
type: "shift_open",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
identity: operator, // the shift's operator; `identity` keys the shift to them
|
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||||||
payload: { operator },
|
// Record the inherited opening float on the shift_open so it's reproducible
|
||||||
|
// and the next operator's handover figure is fixed in the chain.
|
||||||
|
payload: { operator, openingFloatMinor },
|
||||||
occurredAt: startedAt,
|
occurredAt: startedAt,
|
||||||
});
|
});
|
||||||
this.#logger.info(`shift opened for ${operator}`);
|
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||||
return { startedAt };
|
return { startedAt, openingFloatMinor };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
||||||
@@ -102,6 +236,50 @@ export class ShiftService {
|
|||||||
if (pl.currency) currency = pl.currency;
|
if (pl.currency) currency = pl.currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Drawer figures ---
|
||||||
|
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||||||
|
// fall back to a fresh fold if an older shift_open lacks it.
|
||||||
|
const openPl = (open.payload ?? {}) as LedgerPayload & { openingFloatMinor?: number };
|
||||||
|
const openingFloatMinor =
|
||||||
|
typeof openPl.openingFloatMinor === "number"
|
||||||
|
? openPl.openingFloatMinor
|
||||||
|
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
||||||
|
|
||||||
|
// Cash movements within the shift window, split into added (+) and removed (−).
|
||||||
|
const movements = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "cash_movement"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||||||
|
let cashAddedMinor = 0;
|
||||||
|
let cashRemovedMinor = 0;
|
||||||
|
for (const m of movements) {
|
||||||
|
const pl = (m.payload ?? {}) as LedgerPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (amt >= 0) cashAddedMinor += amt;
|
||||||
|
else cashRemovedMinor += -amt; // store as a positive magnitude
|
||||||
|
if (pl.currency) currency = pl.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected drawer at close = opening + cash taken + added − removed. This is the
|
||||||
|
// figure the NEXT shift inherits as its opening float.
|
||||||
|
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||||
|
|
||||||
|
const report: Omit<ShiftReport, "printed"> = {
|
||||||
|
operator,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
cashTotalMinor,
|
||||||
|
cardTotalMinor,
|
||||||
|
currency,
|
||||||
|
paymentCount: payments.length,
|
||||||
|
openingFloatMinor,
|
||||||
|
cashAddedMinor,
|
||||||
|
cashRemovedMinor,
|
||||||
|
expectedDrawerMinor,
|
||||||
|
};
|
||||||
|
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "shift_z_report",
|
type: "shift_z_report",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
@@ -114,23 +292,20 @@ export class ShiftService {
|
|||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency: currency ?? undefined,
|
currency: currency ?? undefined,
|
||||||
paymentCount: payments.length,
|
paymentCount: payments.length,
|
||||||
|
openingFloatMinor,
|
||||||
|
cashAddedMinor,
|
||||||
|
cashRemovedMinor,
|
||||||
|
expectedDrawerMinor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const printed = await this.#printZReport({
|
const printed = await this.#printZReport(report);
|
||||||
operator,
|
|
||||||
startedAt,
|
|
||||||
endedAt,
|
|
||||||
cashTotalMinor,
|
|
||||||
cardTotalMinor,
|
|
||||||
currency,
|
|
||||||
paymentCount: payments.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments)`,
|
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` +
|
||||||
|
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||||||
);
|
);
|
||||||
return { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency, paymentCount: payments.length, printed };
|
return { ...report, printed };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
||||||
@@ -151,6 +326,13 @@ export class ShiftService {
|
|||||||
`Payments: ${r.paymentCount}`,
|
`Payments: ${r.paymentCount}`,
|
||||||
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Card: ${money(r.cardTotalMinor)} ${cur}`,
|
`Card: ${money(r.cardTotalMinor)} ${cur}`,
|
||||||
|
"",
|
||||||
|
"-- Drawer --",
|
||||||
|
`Opening float: ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
|
`Cash taken: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
|
`Cash added: ${money(r.cashAddedMinor)} ${cur}`,
|
||||||
|
`Cash removed: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||||
|
`Expected drawer: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
|
await printer.printReport({ title: "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.",
|
"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 {
|
interface SnapshotJob {
|
||||||
readonly db: Db;
|
readonly db: Db;
|
||||||
readonly direction: FlowDirection;
|
readonly direction: FlowDirection;
|
||||||
/** Session/credential ref (ticket id, plate, permit car key) — links to the ledger. */
|
/** Session/credential ref (ticket id, plate, subscription car key) — links to the ledger. */
|
||||||
readonly identity: string;
|
readonly identity: string;
|
||||||
readonly logger: FastifyBaseLogger;
|
readonly logger: FastifyBaseLogger;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.17",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.15",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
|
"@tanstack/react-router": "^1.170.16",
|
||||||
|
"i18next": "^26.3.1",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.16"
|
"vite": "8.0.16"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from "react";
|
||||||
import { fetchMe, logout, type SessionUser } from "./api.js";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { fetchMe, type SessionUser } from "./api.js";
|
||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
import { PermitManager } from "./PermitManager.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { ShiftControl } from "./ShiftControl.js";
|
import { router } from "./router.js";
|
||||||
import { SiteSettings } from "./SiteSettings.js";
|
|
||||||
import { TariffComposer } from "./TariffComposer.js";
|
|
||||||
|
|
||||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
// simple enough that a framework's abstractions cost more than they save.
|
// off to TanStack Router inside the QueryClient provider. The router renders the
|
||||||
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me.
|
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
||||||
|
// the signed-in user flows into the router context for role-based route guards.
|
||||||
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -22,37 +23,26 @@ export function App() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>;
|
// Apply the signed-in user's preferred language whenever it resolves/changes
|
||||||
if (!user) return <Login onLoggedIn={setUser} />;
|
// (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 (
|
return (
|
||||||
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
<RouterProvider router={router} context={{ user, setUser }} />
|
||||||
<h1 style={{ margin: 0 }}>Parking System</h1>
|
</QueryClientProvider>
|
||||||
<span style={{ color: "#555" }}>
|
|
||||||
{user.username} ({user.role}){" "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
await logout();
|
|
||||||
setUser(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<SiteSettings canEdit={user.role === "admin"} />
|
|
||||||
{user.role !== "readonly" && <ShiftControl />}
|
|
||||||
{user.role === "admin" ? (
|
|
||||||
<>
|
|
||||||
<SetupWizard />
|
|
||||||
<TariffComposer />
|
|
||||||
<PermitManager />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { login, type SessionUser } from "./api.js";
|
import { login, type SessionUser } from "./api.js";
|
||||||
|
|
||||||
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
|
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -22,11 +24,11 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
|
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
|
||||||
<h1>Parking System</h1>
|
<h1>{t("auth.title")}</h1>
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<div style={{ margin: "0.5rem 0" }}>
|
<div style={{ margin: "0.5rem 0" }}>
|
||||||
<label>
|
<label>
|
||||||
Username
|
{t("auth.username")}
|
||||||
<br />
|
<br />
|
||||||
<input
|
<input
|
||||||
value={username}
|
value={username}
|
||||||
@@ -39,7 +41,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ margin: "0.5rem 0" }}>
|
<div style={{ margin: "0.5rem 0" }}>
|
||||||
<label>
|
<label>
|
||||||
Password
|
{t("auth.password")}
|
||||||
<br />
|
<br />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -52,7 +54,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
|||||||
</div>
|
</div>
|
||||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||||
<button type="submit" disabled={busy || !username || !password}>
|
<button type="submit" disabled={busy || !username || !password}>
|
||||||
{busy ? "Signing in…" : "Sign in"}
|
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</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 { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
assignDevice,
|
assignDevice,
|
||||||
|
editDevice,
|
||||||
discoverDevices,
|
discoverDevices,
|
||||||
fetchBackendIps,
|
fetchBackendIps,
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
@@ -127,8 +128,12 @@ function CategorySection({
|
|||||||
onChanged: () => Promise<void> | void;
|
onChanged: () => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
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.
|
// Binding categories need a controller to point at first.
|
||||||
const isBound = category !== "access";
|
const isBound = category !== "access";
|
||||||
@@ -162,15 +167,43 @@ function CategorySection({
|
|||||||
|
|
||||||
{assignments.length > 0 && (
|
{assignments.length > 0 && (
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||||
{assignments.map((a) => (
|
{assignments.map((a) =>
|
||||||
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
|
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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{blockedNoController ? (
|
{blockedNoController ? (
|
||||||
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
||||||
) : showForm ? (
|
) : editing ? null : showForm ? (
|
||||||
<DeviceForm
|
<DeviceForm
|
||||||
category={category}
|
category={category}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
@@ -197,10 +230,12 @@ function AssignmentRow({
|
|||||||
assignment,
|
assignment,
|
||||||
controllers,
|
controllers,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
assignment: Assignment;
|
assignment: Assignment;
|
||||||
controllers: Assignment[];
|
controllers: Assignment[];
|
||||||
onChanged: () => Promise<void> | void;
|
onChanged: () => Promise<void> | void;
|
||||||
|
onEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [removing, setRemoving] = useState(false);
|
const [removing, setRemoving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -237,6 +272,9 @@ function AssignmentRow({
|
|||||||
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
||||||
|
<button type="button" onClick={onEdit} disabled={removing}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<button type="button" onClick={remove} disabled={removing}>
|
<button type="button" onClick={remove} disabled={removing}>
|
||||||
{removing ? "Removing…" : "Remove"}
|
{removing ? "Removing…" : "Remove"}
|
||||||
</button>
|
</button>
|
||||||
@@ -280,6 +318,7 @@ function DeviceForm({
|
|||||||
discoverableIds,
|
discoverableIds,
|
||||||
pushCapableIds,
|
pushCapableIds,
|
||||||
controllers,
|
controllers,
|
||||||
|
editing,
|
||||||
onSaved,
|
onSaved,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
@@ -288,21 +327,42 @@ function DeviceForm({
|
|||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
pushCapableIds: string[];
|
pushCapableIds: string[];
|
||||||
controllers: Assignment[];
|
controllers: Assignment[];
|
||||||
|
/** When set, the form edits this assignment in place (driver locked, config
|
||||||
|
* pre-filled) instead of adding a new device. */
|
||||||
|
editing?: Assignment;
|
||||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedId, setSelectedId] = useState<string>("");
|
// On edit the driver is fixed (you can't change what KIND of device a slot is —
|
||||||
|
// that's a remove + re-add); pre-select it and lock the picker.
|
||||||
|
const editCfg = editing?.config as Record<string, unknown> | undefined;
|
||||||
|
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
|
||||||
const selected = entries.find((e) => e.id === selectedId);
|
const selected = entries.find((e) => e.id === selectedId);
|
||||||
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id);
|
||||||
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
||||||
const isController = category === "access";
|
const isController = category === "access";
|
||||||
|
|
||||||
const [config, setConfig] = useState<Record<string, string | number>>({});
|
// Pre-fill scalar config fields from the existing assignment when editing.
|
||||||
|
// (relays/controllerId/relay are model fields handled by their own state below.)
|
||||||
|
const [config, setConfig] = useState<Record<string, string | number>>(() => {
|
||||||
|
if (!editCfg) return {};
|
||||||
|
const out: Record<string, string | number> = {};
|
||||||
|
for (const [k, v] of Object.entries(editCfg)) {
|
||||||
|
if (typeof v === "string" || typeof v === "number") out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
||||||
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]);
|
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
||||||
|
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
||||||
|
);
|
||||||
// Bound devices: which controller + relay this device sits at.
|
// Bound devices: which controller + relay this device sits at.
|
||||||
const [controllerId, setControllerId] = useState<string>("");
|
const [controllerId, setControllerId] = useState<string>(
|
||||||
const [boundRelay, setBoundRelay] = useState<number | "">("");
|
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
||||||
|
);
|
||||||
|
const [boundRelay, setBoundRelay] = useState<number | "">(
|
||||||
|
typeof editCfg?.relay === "number" ? editCfg.relay : "",
|
||||||
|
);
|
||||||
|
|
||||||
const [tested, setTested] = useState<TestResult | null>(null);
|
const [tested, setTested] = useState<TestResult | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
@@ -420,7 +480,12 @@ function DeviceForm({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
const result = await assignDevice({
|
const result = editing
|
||||||
|
? await editDevice(editing.id, {
|
||||||
|
config: mergedConfig(),
|
||||||
|
...(backendIp ? { backendIp } : {}),
|
||||||
|
})
|
||||||
|
: await assignDevice({
|
||||||
category,
|
category,
|
||||||
driverId: selected.id,
|
driverId: selected.id,
|
||||||
config: mergedConfig(),
|
config: mergedConfig(),
|
||||||
@@ -439,7 +504,9 @@ function DeviceForm({
|
|||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<em>No drivers registered.</em>
|
<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>
|
<option value="" disabled>
|
||||||
Choose a device…
|
Choose a device…
|
||||||
</option>
|
</option>
|
||||||
@@ -538,7 +605,7 @@ function DeviceForm({
|
|||||||
{testing ? "Testing…" : "Test connection"}
|
{testing ? "Testing…" : "Test connection"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={save} disabled={saving}>
|
<button type="button" onClick={save} disabled={saving}>
|
||||||
{saving ? "Saving…" : "Save & configure"}
|
{saving ? "Saving…" : editing ? "Save changes" : "Save & configure"}
|
||||||
</button>
|
</button>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button type="button" onClick={onCancel} disabled={saving}>
|
<button type="button" onClick={onCancel} disabled={saving}>
|
||||||
|
|||||||
@@ -1,25 +1,41 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { closeShift, fetchShift, openShift, type ShiftReport } from "./api.js";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
|
||||||
|
|
||||||
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
||||||
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
||||||
// totals. Available to cashier/operator/admin (readonly has no shift).
|
// totals + the DRAWER picture (opening float carried from the prior shift, cash
|
||||||
|
// taken/added/removed, expected drawer). Admins can load/remove drawer cash.
|
||||||
|
// Available to cashier/operator/admin (readonly has no shift).
|
||||||
|
|
||||||
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
||||||
|
|
||||||
export function ShiftControl() {
|
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [startedAt, setStartedAt] = useState<string | null>(null);
|
const [startedAt, setStartedAt] = useState<string | null>(null);
|
||||||
|
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
|
||||||
|
const [currency, setCurrency] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [report, setReport] = useState<ShiftReport | null>(null);
|
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// Cash-movement form (admin only).
|
||||||
|
const [moveAmount, setMoveAmount] = useState("");
|
||||||
|
const [moveReason, setMoveReason] = useState("");
|
||||||
|
const [moveMsg, setMoveMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
fetchShift()
|
fetchShift()
|
||||||
.then((s) => setStartedAt(s.open?.startedAt ?? null))
|
.then((s) => {
|
||||||
|
setStartedAt(s.open?.startedAt ?? null);
|
||||||
|
setDrawerMinor(s.drawerMinor);
|
||||||
|
setCurrency(s.currency);
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* readonly / not permitted — hide control */
|
/* readonly / not permitted — hide control */
|
||||||
});
|
});
|
||||||
}, []);
|
}
|
||||||
|
useEffect(refresh, []);
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -28,6 +44,7 @@ export function ShiftControl() {
|
|||||||
try {
|
try {
|
||||||
const { startedAt } = await openShift();
|
const { startedAt } = await openShift();
|
||||||
setStartedAt(startedAt);
|
setStartedAt(startedAt);
|
||||||
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,6 +58,7 @@ export function ShiftControl() {
|
|||||||
const z = await closeShift();
|
const z = await closeShift();
|
||||||
setReport(z);
|
setReport(z);
|
||||||
setStartedAt(null);
|
setStartedAt(null);
|
||||||
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -48,33 +66,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 (
|
return (
|
||||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
<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 ? (
|
{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}>
|
<button type="button" onClick={end} disabled={busy}>
|
||||||
{busy ? "Ending…" : "End shift"}
|
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span style={{ color: "#777" }}>not started</span>{" "}
|
<span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
|
||||||
<button type="button" onClick={start} disabled={busy}>
|
<button type="button" onClick={start} disabled={busy}>
|
||||||
{busy ? "Starting…" : "Start shift"}
|
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||||
</button>
|
</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>}
|
{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 && (
|
{report && (
|
||||||
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||||
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
|
<div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
|
||||||
<div>Payments: {report.paymentCount}</div>
|
<div>{t("shift.payments")} {report.paymentCount}</div>
|
||||||
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
|
<div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||||
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
|
<div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
|
||||||
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}>
|
<div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
|
||||||
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
||||||
|
|
||||||
// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||||
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||||
// transient entry at capacity) is enforced server-side in the entry flow.
|
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
|
||||||
// See wiki/concepts/capacity-occupancy.md.
|
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
|
||||||
|
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
|
||||||
|
|
||||||
|
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
|
||||||
|
// (resolved at render); only `address` is multiline.
|
||||||
|
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
|
||||||
|
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
|
||||||
|
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
|
||||||
|
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
|
||||||
|
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
|
||||||
|
{ key: "phone", labelKey: "site.fieldPhone" },
|
||||||
|
{ key: "email", labelKey: "site.fieldEmail" },
|
||||||
|
];
|
||||||
|
|
||||||
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [occ, setOcc] = useState<Occupancy | null>(null);
|
const [occ, setOcc] = useState<Occupancy | null>(null);
|
||||||
const [capInput, setCapInput] = useState("");
|
const [capInput, setCapInput] = useState("");
|
||||||
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||||
|
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
@@ -17,18 +33,29 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
fetchSiteConfig()
|
fetchSiteConfig()
|
||||||
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity)))
|
.then((c) => {
|
||||||
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
|
setExitVoucherDefault(c.exitVoucherDefault);
|
||||||
|
const m: Record<string, string> = {};
|
||||||
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
||||||
|
setMeta(m);
|
||||||
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
const raw = capInput.trim();
|
const raw = capInput.trim();
|
||||||
const capacity = raw === "" ? null : Math.round(Number(raw));
|
const patch: Partial<SiteConfig> = {
|
||||||
|
capacity: raw === "" ? null : Math.round(Number(raw)),
|
||||||
|
exitVoucherDefault,
|
||||||
|
};
|
||||||
|
// Send each metadata field; "" → null is applied server-side.
|
||||||
|
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
||||||
try {
|
try {
|
||||||
await setCapacity(capacity);
|
await saveSiteConfig(patch);
|
||||||
reload();
|
reload();
|
||||||
setMsg("Capacity saved.");
|
setMsg(t("site.saved"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg((e as Error).message);
|
setMsg((e as Error).message);
|
||||||
}
|
}
|
||||||
@@ -36,29 +63,62 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
<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 ? (
|
{occ == null ? (
|
||||||
"…"
|
"…"
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span style={{ fontWeight: 600 }}>{occ.count}</span>
|
<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 && (
|
{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>
|
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div style={{ marginTop: "0.6rem" }}>
|
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
|
||||||
<label>
|
<label>
|
||||||
Capacity (blank = no limit):{" "}
|
{t("site.capacityLabel")}{" "}
|
||||||
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
|
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
|
||||||
</label>{" "}
|
</label>
|
||||||
<button type="button" onClick={save}>Save</button>
|
<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>}
|
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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 { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
fetchTariff,
|
fetchTariff,
|
||||||
@@ -14,8 +15,13 @@ import {
|
|||||||
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
|
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
|
||||||
|
|
||||||
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||||
|
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
||||||
|
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
||||||
|
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
||||||
|
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
||||||
|
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
||||||
interface BlockForm {
|
interface BlockForm {
|
||||||
uptoMin: string; // "" = open-ended (last block)
|
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||||
price: string; // major units, e.g. "2.00"
|
price: string; // major units, e.g. "2.00"
|
||||||
}
|
}
|
||||||
interface FormState {
|
interface FormState {
|
||||||
@@ -39,10 +45,25 @@ function emptyForm(): FormState {
|
|||||||
dailyCap: "",
|
dailyCap: "",
|
||||||
lostTicket: "20.00",
|
lostTicket: "20.00",
|
||||||
gracePeriodExitMin: "15",
|
gracePeriodExitMin: "15",
|
||||||
blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }],
|
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a published structure's cumulative `uptoMin` (minutes) back into the
|
||||||
|
// per-band hours the form edits. Each band's hours = (its bound − previous bound)
|
||||||
|
// / 60; the open-ended last band has no hours. Legacy versions whose last block is
|
||||||
|
// bounded (pre-2026-06-18, before open-ended was required) still load: the bounded
|
||||||
|
// tail simply shows as its own band and the operator adds/keeps an open-ended one.
|
||||||
|
function blocksToForm(blocks: TariffStructure["blocks"]): BlockForm[] {
|
||||||
|
let prev = 0;
|
||||||
|
return blocks.map((b) => {
|
||||||
|
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
||||||
|
const hours = (b.uptoMin - prev) / 60;
|
||||||
|
prev = b.uptoMin;
|
||||||
|
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formFromActive(s: TariffState): FormState {
|
function formFromActive(s: TariffState): FormState {
|
||||||
const v = s.active;
|
const v = s.active;
|
||||||
if (!v) return emptyForm();
|
if (!v) return emptyForm();
|
||||||
@@ -54,18 +75,22 @@ function formFromActive(s: TariffState): FormState {
|
|||||||
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
|
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
|
||||||
lostTicket: toMajor(st.lostTicketMinor),
|
lostTicket: toMajor(st.lostTicketMinor),
|
||||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||||
blocks: st.blocks.map((b) => ({
|
blocks: blocksToForm(st.blocks),
|
||||||
uptoMin: b.uptoMin == null ? "" : String(b.uptoMin),
|
|
||||||
price: toMajor(b.priceMinorPerIncrement),
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toStructure(f: FormState): TariffStructure {
|
function toStructure(f: FormState): TariffStructure {
|
||||||
const blocks: TariffBlock[] = f.blocks.map((b) => ({
|
// Accumulate each band's DURATION (hours) into the engine's cumulative `uptoMin`
|
||||||
uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)),
|
// (minutes). The LAST band is always open-ended (uptoMin null) — its hours are
|
||||||
priceMinorPerIncrement: toMinor(b.price),
|
// ignored — so the published structure always satisfies the "last block must be
|
||||||
}));
|
// open-ended" rule (the thereafter-rate is explicit). See wiki/concepts/tariff.md.
|
||||||
|
const last = f.blocks.length - 1;
|
||||||
|
let cumulativeMin = 0;
|
||||||
|
const blocks: TariffBlock[] = f.blocks.map((b, i) => {
|
||||||
|
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||||
|
cumulativeMin += Math.round(Number(b.hours || "0") * 60);
|
||||||
|
return { uptoMin: cumulativeMin, priceMinorPerIncrement: toMinor(b.price) };
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||||
incrementMin: Math.round(Number(f.incrementMin)),
|
incrementMin: Math.round(Number(f.incrementMin)),
|
||||||
@@ -78,6 +103,7 @@ function toStructure(f: FormState): TariffStructure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TariffComposer() {
|
export function TariffComposer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<TariffState | null>(null);
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -98,11 +124,23 @@ export function TariffComposer() {
|
|||||||
function setBlock(i: number, patch: Partial<BlockForm>) {
|
function setBlock(i: number, patch: Partial<BlockForm>) {
|
||||||
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||||
}
|
}
|
||||||
|
// Insert a new bounded band just BEFORE the open-ended "thereafter" tail, so the
|
||||||
|
// last block always stays open-ended.
|
||||||
function addBlock() {
|
function addBlock() {
|
||||||
setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] }));
|
setForm((f) => {
|
||||||
|
const tailIdx = f.blocks.length - 1;
|
||||||
|
const next = [...f.blocks];
|
||||||
|
next.splice(tailIdx, 0, { hours: "1", price: "0.00" });
|
||||||
|
return { ...f, blocks: next };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
// Remove a bounded band. The open-ended tail (last row) can't be removed (it's the
|
||||||
|
// required thereafter-rate); the guard also keeps at least the tail present.
|
||||||
function removeBlock(i: number) {
|
function removeBlock(i: number) {
|
||||||
setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) }));
|
setForm((f) => {
|
||||||
|
if (i === f.blocks.length - 1 || f.blocks.length <= 1) return f;
|
||||||
|
return { ...f, blocks: f.blocks.filter((_, j) => j !== i) };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish() {
|
async function publish() {
|
||||||
@@ -112,7 +150,7 @@ export function TariffComposer() {
|
|||||||
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
||||||
const fresh = await fetchTariff();
|
const fresh = await fetchTariff();
|
||||||
setState(fresh);
|
setState(fresh);
|
||||||
setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." });
|
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text =
|
const text =
|
||||||
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
||||||
@@ -126,77 +164,85 @@ export function TariffComposer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ marginTop: "2rem" }}>
|
<section style={{ marginTop: "2rem" }}>
|
||||||
<h2>Tariff</h2>
|
<h2>{t("tariff.title")}</h2>
|
||||||
{!state?.active ? (
|
{!state?.active ? (
|
||||||
<p style={{ color: "#b45309" }}>
|
<p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
|
||||||
No rate card published yet — the pay station can't charge until you publish one.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<p style={{ color: "#555" }}>
|
<p style={{ color: "#555" }}>
|
||||||
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "}
|
{t("tariff.activeSince", {
|
||||||
{state.versions.length} version(s) in history. Publishing creates a new version; past
|
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||||
sessions keep their original pricing.
|
count: state.versions.length,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
|
<div 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 }} />
|
<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)} />
|
<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)} />
|
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||||
<label>Daily cap (blank = none)</label>
|
<label>{t("tariff.dailyCap")}</label>
|
||||||
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
|
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||||
<label>Lost-ticket fee</label>
|
<label>{t("tariff.lostTicketFee")}</label>
|
||||||
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
<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)} />
|
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
|
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
|
||||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
|
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
|
||||||
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>
|
|
||||||
<table style={{ borderCollapse: "collapse" }}>
|
<table style={{ borderCollapse: "collapse" }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||||
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
|
<th style={{ padding: "0 0.5rem" }}>{t("tariff.bandDuration")}</th>
|
||||||
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
|
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{form.blocks.map((b, i) => (
|
{form.blocks.map((b, i) => {
|
||||||
|
const isTail = i === form.blocks.length - 1;
|
||||||
|
return (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||||
|
{isTail ? (
|
||||||
|
<span style={{ color: "#777", fontStyle: "italic" }}>{t("tariff.thereafter")}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
|
||||||
<input
|
<input
|
||||||
value={b.uptoMin}
|
value={b.hours}
|
||||||
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
|
onChange={(e) => setBlock(i, { hours: e.target.value })}
|
||||||
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"}
|
placeholder={t("tariff.egHours")}
|
||||||
style={{ width: 110 }}
|
style={{ width: 70 }}
|
||||||
/>
|
/>
|
||||||
|
<span style={{ color: "#777" }}>{t("tariff.hoursUnit")}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||||
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
|
{!isTail && (
|
||||||
Remove
|
<button type="button" onClick={() => removeBlock(i)}>
|
||||||
|
{t("tariff.remove")}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||||
+ Add block
|
{t("tariff.addBlock")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{ marginTop: "1rem" }}>
|
<div style={{ marginTop: "1rem" }}>
|
||||||
<button type="button" onClick={publish} disabled={saving}>
|
<button type="button" onClick={publish} disabled={saving}>
|
||||||
{saving ? "Publishing…" : "Publish new version"}
|
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{msg && (
|
{msg && (
|
||||||
|
|||||||
+285
-21
@@ -45,10 +45,13 @@ export class ApiError extends Error {
|
|||||||
// --- Auth -----------------------------------------------------------------
|
// --- Auth -----------------------------------------------------------------
|
||||||
|
|
||||||
export type Role = "admin" | "operator" | "cashier" | "readonly";
|
export type Role = "admin" | "operator" | "cashier" | "readonly";
|
||||||
|
export type Lang = "sq" | "en";
|
||||||
export interface SessionUser {
|
export interface SessionUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
|
/** Preferred UI language (loaded from the server on login). */
|
||||||
|
language: Lang;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(username: string, password: string): Promise<SessionUser> {
|
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" });
|
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. */
|
/** Returns the current user, or null if not authenticated. */
|
||||||
export async function fetchMe(): Promise<SessionUser | null> {
|
export async function fetchMe(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
@@ -186,6 +194,15 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
|||||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-configure an existing device in place, keeping its id (and so its push
|
||||||
|
* URL). Category/driver are fixed at create time, so only config changes. */
|
||||||
|
export function editDevice(
|
||||||
|
id: string,
|
||||||
|
body: Omit<AssignBody, "category" | "driverId">,
|
||||||
|
): Promise<AssignResult> {
|
||||||
|
return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
||||||
export interface Assignment {
|
export interface Assignment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -260,48 +277,114 @@ export function publishTariffVersion(body: {
|
|||||||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Permits --------------------------------------------------------------
|
// --- Subscriptions --------------------------------------------------------
|
||||||
|
|
||||||
export interface PermitCredential {
|
export interface SubscriptionCredential {
|
||||||
kind: "rf" | "qr";
|
kind: "rf" | "qr";
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
export interface Permit {
|
export interface Subscription {
|
||||||
id: string;
|
id: string;
|
||||||
holderName: string | null;
|
holderName: string | null;
|
||||||
contact: string | null;
|
contact: string | null;
|
||||||
|
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
|
||||||
|
priceMinor: number | null;
|
||||||
|
period: "monthly";
|
||||||
|
currency: string | null;
|
||||||
maxConcurrent: number | null;
|
maxConcurrent: number | null;
|
||||||
validFrom: string | null;
|
validFrom: string | null;
|
||||||
validTo: string | null;
|
validTo: string | null;
|
||||||
status: "active" | "suspended" | "revoked";
|
status: "active" | "suspended" | "revoked";
|
||||||
credentials: PermitCredential[];
|
credentials: SubscriptionCredential[];
|
||||||
plates: string[];
|
plates: string[];
|
||||||
}
|
}
|
||||||
export type PermitInput = Omit<Permit, "id" | "status"> & {
|
/** A credential as SENT to the server: a QR value may be omitted/blank → the server
|
||||||
status?: Permit["status"];
|
* auto-generates an unguessable code. RF must carry the card id. */
|
||||||
|
export interface SubscriptionCredentialInput {
|
||||||
|
kind: "rf" | "qr";
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
export type SubscriptionInput = {
|
||||||
|
holderName: string | null;
|
||||||
|
contact: string | null;
|
||||||
|
priceMinor: number | null;
|
||||||
|
period: "monthly";
|
||||||
|
currency: string | null;
|
||||||
|
maxConcurrent: number | null;
|
||||||
|
validFrom: string | null;
|
||||||
|
validTo: string | null;
|
||||||
|
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
|
||||||
|
months?: number | null;
|
||||||
|
status?: Subscription["status"];
|
||||||
|
credentials: SubscriptionCredentialInput[];
|
||||||
|
plates: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchPermits(): Promise<{ permits: Permit[] }> {
|
/** The create response = the saved subscription + the auto-print outcome. */
|
||||||
return apiFetch("/api/permits");
|
export type SubscriptionCreated = Subscription & {
|
||||||
|
printed: boolean;
|
||||||
|
printedBy?: string;
|
||||||
|
printError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
||||||
|
return apiFetch("/api/subscriptions");
|
||||||
}
|
}
|
||||||
export function createPermit(body: PermitInput): Promise<Permit> {
|
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
|
||||||
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
|
/** Re-print a subscription's QR card (failed auto-print / lost card). */
|
||||||
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
|
||||||
|
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
|
||||||
}
|
}
|
||||||
export function revokePermit(id: string): Promise<Permit> {
|
|
||||||
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" });
|
// --- Credential capture ("enroll a card" on a chosen reader) ---------------
|
||||||
|
|
||||||
|
export interface ReaderInfo {
|
||||||
|
id: string;
|
||||||
|
driverId: string;
|
||||||
|
direction: "entry" | "exit" | "both";
|
||||||
}
|
}
|
||||||
export function deletePermit(id: string): Promise<void> {
|
export type CaptureState =
|
||||||
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
| { status: "idle" }
|
||||||
|
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
|
||||||
|
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
|
||||||
|
| { status: "expired"; deviceId: string };
|
||||||
|
|
||||||
|
export function fetchReaders(): Promise<{ readers: ReaderInfo[] }> {
|
||||||
|
return apiFetch("/api/subscriptions/readers");
|
||||||
|
}
|
||||||
|
export function armCapture(deviceId: string): Promise<{ expiresAt: number }> {
|
||||||
|
return apiFetch("/api/subscriptions/capture/arm", { method: "POST", body: JSON.stringify({ deviceId }) });
|
||||||
|
}
|
||||||
|
export function pollCapture(): Promise<CaptureState> {
|
||||||
|
return apiFetch("/api/subscriptions/capture");
|
||||||
|
}
|
||||||
|
export function cancelCapture(): Promise<{ ok: boolean }> {
|
||||||
|
return apiFetch("/api/subscriptions/capture/cancel", { method: "POST" });
|
||||||
|
}
|
||||||
|
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
|
||||||
|
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function revokeSubscription(id: string): Promise<Subscription> {
|
||||||
|
return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" });
|
||||||
|
}
|
||||||
|
export function deleteSubscription(id: string): Promise<void> {
|
||||||
|
return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Shifts ---------------------------------------------------------------
|
// --- Shifts ---------------------------------------------------------------
|
||||||
|
|
||||||
export interface ShiftStatus {
|
export interface ShiftStatus {
|
||||||
|
/** The requesting (logged-in) operator. */
|
||||||
operator: string;
|
operator: string;
|
||||||
open: { startedAt: string } | null;
|
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
|
||||||
|
open: { startedAt: string; operator: string | null } | null;
|
||||||
|
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||||||
|
isMine: boolean;
|
||||||
|
/** Live physical drawer balance (cash payments + cash movements). */
|
||||||
|
drawerMinor: number;
|
||||||
|
currency: string | null;
|
||||||
}
|
}
|
||||||
export interface ShiftReport {
|
export interface ShiftReport {
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -311,19 +394,35 @@ export interface ShiftReport {
|
|||||||
cardTotalMinor: number;
|
cardTotalMinor: number;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
paymentCount: number;
|
paymentCount: number;
|
||||||
|
// Drawer (carries across shifts).
|
||||||
|
openingFloatMinor: number;
|
||||||
|
cashAddedMinor: number;
|
||||||
|
cashRemovedMinor: number;
|
||||||
|
expectedDrawerMinor: number;
|
||||||
printed: boolean;
|
printed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchShift(): Promise<ShiftStatus> {
|
export function fetchShift(): Promise<ShiftStatus> {
|
||||||
return apiFetch("/api/shift/current");
|
return apiFetch("/api/shift/current");
|
||||||
}
|
}
|
||||||
export function openShift(): Promise<{ startedAt: string }> {
|
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||||
return apiFetch("/api/shift/open", { method: "POST" });
|
return apiFetch("/api/shift/open", { method: "POST" });
|
||||||
}
|
}
|
||||||
export function closeShift(): Promise<ShiftReport> {
|
export function closeShift(): Promise<ShiftReport> {
|
||||||
return apiFetch("/api/shift/close", { method: "POST" });
|
return apiFetch("/api/shift/close", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
|
||||||
|
export function recordCashMovement(
|
||||||
|
amountMinor: number,
|
||||||
|
reason: string,
|
||||||
|
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||||||
|
return apiFetch("/api/cash-movement", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ amountMinor, reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Site config / occupancy ----------------------------------------------
|
// --- Site config / occupancy ----------------------------------------------
|
||||||
|
|
||||||
export interface Occupancy {
|
export interface Occupancy {
|
||||||
@@ -333,12 +432,177 @@ export interface Occupancy {
|
|||||||
full: boolean;
|
full: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
|
||||||
|
export interface SiteConfig {
|
||||||
|
capacity: number | null;
|
||||||
|
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||||||
|
exitVoucherDefault: boolean;
|
||||||
|
/** Site default monthly subscription price (minor units); pre-fills the form. */
|
||||||
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
|
parkName: string | null;
|
||||||
|
operatorName: string | null;
|
||||||
|
/** NIUS — Albanian tax/identification number. */
|
||||||
|
nius: string | null;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchOccupancy(): Promise<Occupancy> {
|
export function fetchOccupancy(): Promise<Occupancy> {
|
||||||
return apiFetch("/api/occupancy");
|
return apiFetch("/api/occupancy");
|
||||||
}
|
}
|
||||||
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
|
|
||||||
|
// --- Device status (the booth footer) -------------------------------------
|
||||||
|
|
||||||
|
/** Live status of one configured device — mirrors the server's DeviceStatusEvent.
|
||||||
|
* Every enabled device is polled (printers via rich readStatus, the rest via
|
||||||
|
* healthCheck) and flattened to one traffic-light. Pushed over the WS; the REST
|
||||||
|
* snapshot below is the initial load / fallback. */
|
||||||
|
export interface DeviceStatus {
|
||||||
|
deviceId: string;
|
||||||
|
driverId: string;
|
||||||
|
category: "access" | "reader" | "camera" | "printer";
|
||||||
|
/** Role/direction token for the footer label (NOT the vendor) — the client
|
||||||
|
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
|
||||||
|
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||||||
|
state: "ready" | "degraded" | "offline";
|
||||||
|
detail?: string;
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
|
||||||
|
return apiFetch("/api/devices/status");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ledger events (the signed audit trail; read-only) --------------------
|
||||||
|
|
||||||
|
/** A persisted ledger row. Re-exported from shared so UI code has one source of
|
||||||
|
* truth for the event shape (the same type the WS pushes). */
|
||||||
|
export type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
|
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||||||
|
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
||||||
|
* scopes to events at/after that instant — the booth passes the current shift's
|
||||||
|
* start so the feed shows ONLY this shift's activity. */
|
||||||
|
export function fetchEvents(
|
||||||
|
limit = 100,
|
||||||
|
since?: string,
|
||||||
|
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||||
|
const qs = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (since) qs.set("since", since);
|
||||||
|
return apiFetch(`/api/events?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Booth: session lookup, payment, exit ---------------------------------
|
||||||
|
|
||||||
|
/** One-read session view for the booth pay/exit modal (mirrors server SessionLookup). */
|
||||||
|
export interface SessionLookup {
|
||||||
|
identity: string;
|
||||||
|
found: boolean;
|
||||||
|
open: boolean;
|
||||||
|
enteredAt: string | null;
|
||||||
|
exitedAt: string | null;
|
||||||
|
paidAt: string | null;
|
||||||
|
amountMinor: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
withinGrace: boolean;
|
||||||
|
graceExpiresAt: string | null;
|
||||||
|
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||||||
|
subscription: boolean;
|
||||||
|
subscriptionId: string | null;
|
||||||
|
subscriptionHolder: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||||
|
export function lookupSession(identity: string): Promise<SessionLookup> {
|
||||||
|
return apiFetch(`/api/session/${encodeURIComponent(identity)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row in the booth Active Sessions list (mirrors server ActiveSession). */
|
||||||
|
export interface ActiveSession {
|
||||||
|
identity: string;
|
||||||
|
source: string | null;
|
||||||
|
enteredAt: string;
|
||||||
|
exitedAt: string | null;
|
||||||
|
open: boolean;
|
||||||
|
paidAt: string | null;
|
||||||
|
amountMinor: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
withinGrace: boolean;
|
||||||
|
graceExpiresAt: string | null;
|
||||||
|
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||||||
|
subscription: boolean;
|
||||||
|
subscriptionId: string | null;
|
||||||
|
subscriptionHolder: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
|
||||||
|
export function fetchActiveSessions(): Promise<{ sessions: ActiveSession[] }> {
|
||||||
|
return apiFetch("/api/sessions/active");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-intervention barrier re-open for a paid active session (damaged ticket /
|
||||||
|
* phantom re-close). Signs an audited anomaly; never a 2nd exit. */
|
||||||
|
export function reopenBarrier(identity: string): Promise<{ ok: true; opened: boolean; reason?: string }> {
|
||||||
|
return apiFetch("/api/barrier/reopen", { method: "POST", body: JSON.stringify({ identity }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take payment for a session → signed payment event. `overrideMinor` sets an
|
||||||
|
* operator amount (lost ticket / dispute). */
|
||||||
|
export function paySession(
|
||||||
|
identity: string,
|
||||||
|
tender: "cash" | "card",
|
||||||
|
overrideMinor?: number,
|
||||||
|
): Promise<{ amountMinor: number; currency: string }> {
|
||||||
|
return apiFetch("/api/pay", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ identity, tender, ...(overrideMinor != null ? { overrideMinor } : {}) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||||
|
* open (payment stands; operator opens manually). */
|
||||||
|
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||||||
|
|
||||||
|
/** Validate + open the barrier for a session from the booth (when near the exit). */
|
||||||
|
export function boothExit(identity: string): Promise<BoothExitResult> {
|
||||||
|
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print an exit voucher (paid ticket id reprinted) 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");
|
return apiFetch("/api/site-config");
|
||||||
}
|
}
|
||||||
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> {
|
/** PUT a partial config — only the fields supplied are changed. */
|
||||||
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) });
|
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
|
||||||
|
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
|
||||||
|
}
|
||||||
|
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||||||
|
return saveSiteConfig({ capacity });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,289 @@
|
|||||||
|
// 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: "Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.",
|
||||||
|
bandDuration: "Band duration",
|
||||||
|
hoursUnit: "hours",
|
||||||
|
egHours: "e.g. 2",
|
||||||
|
pricePerIncrement: "Price / increment",
|
||||||
|
thereafter: "thereafter (open-ended)",
|
||||||
|
remove: "Remove",
|
||||||
|
addBlock: "+ Add block",
|
||||||
|
publishNewVersion: "Publish new version",
|
||||||
|
publishing: "Publishing…",
|
||||||
|
publishedOk: "New tariff version published — it's now the active rate 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,298 @@
|
|||||||
|
// 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: "Evente",
|
||||||
|
noEventsYet: "Asnjë event ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||||
|
activeSessions: "Sesionet aktive",
|
||||||
|
insideCount: "brenda",
|
||||||
|
noActiveSessions: "Asnjë sesion aktiv.",
|
||||||
|
inAt: "në",
|
||||||
|
openPayExit: "Hap pagesën / daljen",
|
||||||
|
openBarrier: "Hap barrierën",
|
||||||
|
openBarrierTitle: "Hap barrierën manualisht",
|
||||||
|
barrierOpened: "barriera u hap",
|
||||||
|
openManually: "hape me dorë",
|
||||||
|
// session row badges
|
||||||
|
badgeExiting: "duke dalë",
|
||||||
|
badgePaid: "paguar",
|
||||||
|
badgeUnpaid: "papaguar",
|
||||||
|
badgeSubscription: "abonim",
|
||||||
|
// event types (live feed labels)
|
||||||
|
evtEntry: "HYRJE",
|
||||||
|
evtExit: "DALJE",
|
||||||
|
evtPay: "PAGESË",
|
||||||
|
evtVoid: "ANULIM",
|
||||||
|
evtOpenCmd: "HAP→",
|
||||||
|
evtOpenObserved: "HAP✓",
|
||||||
|
evtShiftOpen: "TURN+",
|
||||||
|
evtShiftZ: "TURN Z",
|
||||||
|
evtCashMovement: "ARKË",
|
||||||
|
evtAnomaly: "ANOMALI",
|
||||||
|
},
|
||||||
|
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: "Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.",
|
||||||
|
bandDuration: "Kohëzgjatja e brezit",
|
||||||
|
hoursUnit: "orë",
|
||||||
|
egHours: "p.sh. 2",
|
||||||
|
pricePerIncrement: "Çmimi / interval",
|
||||||
|
thereafter: "më pas (i hapur)",
|
||||||
|
remove: "Hiq",
|
||||||
|
addBlock: "+ Shto bllok",
|
||||||
|
publishNewVersion: "Publiko version të ri",
|
||||||
|
publishing: "Duke publikuar…",
|
||||||
|
publishedOk: "U publikua versioni i ri i tarifës — tani është 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}}? (t e kaluara ruhen.)",
|
||||||
|
statusActive: "aktiv",
|
||||||
|
statusSuspended: "pezulluar",
|
||||||
|
statusRevoked: "anuluar",
|
||||||
|
},
|
||||||
|
site: {
|
||||||
|
occupancy: "Prania:",
|
||||||
|
noCapacitySet: "(pa kapacitet të caktuar)",
|
||||||
|
free: "lirë",
|
||||||
|
full: "PLOT",
|
||||||
|
capacityLabel: "Kapaciteti (bosh = pa kufi):",
|
||||||
|
capacityPlaceholder: "p.sh. 120",
|
||||||
|
printExitDefault: "Printo biletën e daljes si parazgjedhje",
|
||||||
|
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
|
||||||
|
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
||||||
|
save: "Ruaj",
|
||||||
|
saved: "U ruajt.",
|
||||||
|
fieldParkName: "Emri i parkimit",
|
||||||
|
fieldParkNamePh: "p.sh. Acme Parking",
|
||||||
|
fieldOperator: "Operatori (emri ligjor)",
|
||||||
|
fieldOperatorPh: "kompania operuese",
|
||||||
|
fieldNius: "NIUS",
|
||||||
|
fieldNiusPh: "p.sh. L01234567A",
|
||||||
|
fieldAddress: "Adresa",
|
||||||
|
fieldPhone: "Telefoni",
|
||||||
|
fieldEmail: "Email",
|
||||||
|
},
|
||||||
|
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 { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import "./lib/i18n/index.js"; // initialize i18next before the app renders
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
|
|
||||||
const rootEl = document.getElementById("root");
|
const rootEl = document.getElementById("root");
|
||||||
|
|||||||
@@ -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 react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
// Operator SPA. Built by Vite and served by Fastify in production
|
// Operator SPA. Built by Vite and served by Fastify in production
|
||||||
// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the
|
// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the
|
||||||
// local Fastify server.
|
// local Fastify server.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
|
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
|
||||||
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
|
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
|
||||||
// notably under WSL2 mirrored networking.
|
// notably under WSL2 mirrored networking.
|
||||||
"/api": "http://127.0.0.1:3000",
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:3000",
|
||||||
|
// The live booth feed (/api/ws) is a WebSocket — without `ws: true` the
|
||||||
|
// proxy would not forward the upgrade. The backend's Origin allowlist must
|
||||||
|
// include the dev origin (http://localhost:5173) via WS_ALLOWED_ORIGINS.
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
"/health": "http://127.0.0.1:3000",
|
"/health": "http://127.0.0.1:3000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE `site_config` ADD `park_name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `operator_name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `nius` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `address` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `phone` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `email` text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `site_config` ADD `exit_voucher_default` integer DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `users` ADD `language` text DEFAULT 'sq' NOT NULL;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Rename permit → subscription (master data only). The signed ledger keeps its
|
||||||
|
-- immutable `permitId` payload — NOT touched here. Data-preserving ALTER RENAMEs
|
||||||
|
-- (SQLite 3.25+) rather than drop/recreate, so existing subscriptions survive.
|
||||||
|
-- Adds per-subscription pricing (price_minor + period + currency) and a site-wide
|
||||||
|
-- default monthly price. See wiki/entities/subscription.md.
|
||||||
|
|
||||||
|
ALTER TABLE `permits` RENAME TO `subscriptions`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `permit_credentials` RENAME TO `subscription_credentials`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscription_credentials` RENAME COLUMN `permit_id` TO `subscription_id`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `permit_plates` RENAME TO `subscription_plates`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscription_plates` RENAME COLUMN `permit_id` TO `subscription_id`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscriptions` ADD `price_minor` integer;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscriptions` ADD `period` text DEFAULT 'monthly' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscriptions` ADD `currency` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` RENAME COLUMN `permit_id` TO `subscription_id`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `subscription_monthly_price_minor` integer;
|
||||||
@@ -0,0 +1,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,
|
"when": 1781632874398,
|
||||||
"tag": "0000_baseline",
|
"tag": "0000_baseline",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781682176094,
|
||||||
|
"tag": "0001_neat_slipstream",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781713560438,
|
||||||
|
"tag": "0002_panoramic_tiger_shark",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781774228086,
|
||||||
|
"tag": "0003_early_hawkeye",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781800000000,
|
||||||
|
"tag": "0004_subscriptions_rename",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
|||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
// Re-export the query helpers consumers need, so they don't depend on
|
// Re-export the query helpers consumers need, so they don't depend on
|
||||||
// drizzle-orm directly (it's an implementation detail of this package).
|
// drizzle-orm directly (it's an implementation detail of this package).
|
||||||
export { eq, and, desc, sql } from "drizzle-orm";
|
export { eq, and, desc, gte, sql } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||||
|
|||||||
+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.
|
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
|
||||||
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
|
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
|
||||||
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
|
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
|
||||||
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a
|
// - Business master data (tariffs/subscriptions/blocklist) IS mutable, but its USE is fixed in a
|
||||||
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
|
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
|
||||||
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
|
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
|
||||||
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
|
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
|
||||||
@@ -22,6 +22,12 @@ export const users = sqliteTable("users", {
|
|||||||
role: text("role", {
|
role: text("role", {
|
||||||
enum: ["admin", "operator", "cashier", "readonly"],
|
enum: ["admin", "operator", "cashier", "readonly"],
|
||||||
}).notNull(),
|
}).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")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
@@ -87,7 +93,7 @@ export const snapshots = sqliteTable("snapshots", {
|
|||||||
direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
|
direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
|
||||||
// The camera `devices` instance that captured it (raw provenance).
|
// The camera `devices` instance that captured it (raw provenance).
|
||||||
deviceId: text("device_id"),
|
deviceId: text("device_id"),
|
||||||
// The session/credential ref (ticket id, plate, permit) — links to the ledger event.
|
// The session/credential ref (ticket id, plate, subscription) — links to the ledger event.
|
||||||
identity: text("identity"),
|
identity: text("identity"),
|
||||||
contentType: text("content_type").notNull(),
|
contentType: text("content_type").notNull(),
|
||||||
bytes: blob("bytes").notNull().$type<Buffer>(),
|
bytes: blob("bytes").notNull().$type<Buffer>(),
|
||||||
@@ -131,9 +137,40 @@ export const setupState = sqliteTable("setup_state", {
|
|||||||
// Single-row site settings (admin-configurable). The home for site-wide knobs;
|
// Single-row site settings (admin-configurable). The home for site-wide knobs;
|
||||||
// `capacity` is the nominal space count the FULL gate refuses transient entry at
|
// `capacity` is the nominal space count the FULL gate refuses transient entry at
|
||||||
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
|
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
|
||||||
|
// Park identity/metadata (all optional) lives here too — display name, the legal
|
||||||
|
// operator, the NIUS tax id, address and contact. These feed the ticket/receipt
|
||||||
|
// header (park name + NIUS are commonly required on an Albanian parking receipt)
|
||||||
|
// and admin display. All nullable: the lot runs fine with none set.
|
||||||
|
// See wiki/concepts/site-metadata.md.
|
||||||
export const siteConfig = sqliteTable("site_config", {
|
export const siteConfig = sqliteTable("site_config", {
|
||||||
id: integer("id").primaryKey(), // always 1
|
id: integer("id").primaryKey(), // always 1
|
||||||
capacity: integer("capacity"), // null = no capacity limit
|
capacity: integer("capacity"), // null = no capacity limit
|
||||||
|
/** Park display name shown on the ticket header / UI (e.g. "Acme Parking"). */
|
||||||
|
parkName: text("park_name"),
|
||||||
|
/** Legal entity operating the lot, for receipts (may differ from parkName). */
|
||||||
|
operatorName: text("operator_name"),
|
||||||
|
/** NIUS — Albanian tax/identification number, printed on the receipt when set. */
|
||||||
|
nius: text("nius"),
|
||||||
|
/** Free-text postal address (multi-line allowed). */
|
||||||
|
address: text("address"),
|
||||||
|
/** Contact phone — also used for the ticket "lost ticket? call …" footer. */
|
||||||
|
phone: text("phone"),
|
||||||
|
/** Contact email. */
|
||||||
|
email: text("email"),
|
||||||
|
/** Default for the booth pay modal's "print exit ticket" checkbox. Site-wide
|
||||||
|
* because it's booth GEOGRAPHY: when the booth is far from the exit, the
|
||||||
|
* customer pays at the booth and self-exits later by scanning a printed exit
|
||||||
|
* voucher (= the ticket id reprinted, now paid). When near the exit, the booth
|
||||||
|
* opens the barrier directly. The operator may still override per transaction.
|
||||||
|
* Stored 0/1 (SQLite has no bool). See wiki/concepts/booth-exit-flow.md. */
|
||||||
|
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
/** Default monthly subscription price in minor units (e.g. 1000000 = 10,000.00).
|
||||||
|
* A starting value the subscription form pre-fills; each subscription stores its
|
||||||
|
* own price and may differ. null = no site default set. See
|
||||||
|
* wiki/entities/subscription.md. */
|
||||||
|
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
@@ -171,16 +208,28 @@ export const tariffVersions = sqliteTable("tariff_versions", {
|
|||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Permits (subscriptions) ---------------------------------------------
|
// --- Subscriptions --------------------------------------------------------
|
||||||
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event.
|
// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
|
||||||
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null =
|
// month) instead of paying per stay. Mutable master data; every USE still produces a
|
||||||
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a
|
// signed vehicle_entry/exit ledger event. Two optional, independent bindings:
|
||||||
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md.
|
// car-count (maxConcurrent, default 1, null = unbound) and plate (plates rows,
|
||||||
export const permits = sqliteTable("permits", {
|
// default none = any car). Identity = card/QR OR a matching plate (LPR/ANPR future).
|
||||||
|
// Pricing: priceMinor + period + currency record the plan; collecting the fee into
|
||||||
|
// the ledger/shift is deferred. See wiki/entities/subscription.md.
|
||||||
|
// NB: signed ledger events still carry `permitId` in their payload — immutable
|
||||||
|
// history, intentionally NOT renamed. These tables are the mutable master data,
|
||||||
|
// renamed permit→subscription in migration 0004.
|
||||||
|
export const subscriptions = sqliteTable("subscriptions", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
holderName: text("holder_name"),
|
holderName: text("holder_name"),
|
||||||
contact: text("contact"),
|
contact: text("contact"),
|
||||||
// Car-count binding: how many of the permit's cars may be inside at once.
|
// Recurring price for the plan, in minor units (e.g. 1000000 = 10,000.00 ALL).
|
||||||
|
// null = no price set (comp/legacy). The `period` says what it recurs over.
|
||||||
|
priceMinor: integer("price_minor"),
|
||||||
|
period: text("period", { enum: ["monthly"] }).notNull().default("monthly"),
|
||||||
|
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
|
||||||
|
currency: text("currency"),
|
||||||
|
// Car-count binding: how many of the subscription's cars may be inside at once.
|
||||||
// null = unbound. Default 1.
|
// null = unbound. Default 1.
|
||||||
maxConcurrent: integer("max_concurrent").default(1),
|
maxConcurrent: integer("max_concurrent").default(1),
|
||||||
validFrom: text("valid_from"),
|
validFrom: text("valid_from"),
|
||||||
@@ -193,19 +242,19 @@ export const permits = sqliteTable("permits", {
|
|||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane.
|
// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
|
||||||
export const permitCredentials = sqliteTable("permit_credentials", {
|
export const subscriptionCredentials = sqliteTable("subscription_credentials", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
permitId: text("permit_id").notNull(),
|
subscriptionId: text("subscription_id").notNull(),
|
||||||
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
|
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
|
||||||
value: text("value").notNull(),
|
value: text("value").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Plate binding (optional). When a permit has plate rows, a matching plate read is
|
// Plate binding (optional). When a subscription has plate rows, a matching plate read
|
||||||
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
// is itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
||||||
export const permitPlates = sqliteTable("permit_plates", {
|
export const subscriptionPlates = sqliteTable("subscription_plates", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
permitId: text("permit_id").notNull(),
|
subscriptionId: text("subscription_id").notNull(),
|
||||||
plate: text("plate").notNull(),
|
plate: text("plate").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,8 +283,8 @@ export const sessions = sqliteTable("sessions", {
|
|||||||
// Identity that opened the session, and how it was read.
|
// Identity that opened the session, and how it was read.
|
||||||
identity: text("identity"),
|
identity: text("identity"),
|
||||||
source: text("source"),
|
source: text("source"),
|
||||||
// null while transient; set when matched to a permit.
|
// null while transient; set when matched to a subscription.
|
||||||
permitId: text("permit_id"),
|
subscriptionId: text("subscription_id"),
|
||||||
enteredAt: text("entered_at").notNull(),
|
enteredAt: text("entered_at").notNull(),
|
||||||
// null until exit; presence = CLOSED.
|
// null until exit; presence = CLOSED.
|
||||||
exitedAt: text("exited_at"),
|
exitedAt: text("exited_at"),
|
||||||
@@ -256,8 +305,8 @@ export type SetupStateRow = typeof setupState.$inferSelect;
|
|||||||
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
||||||
export type TariffRow = typeof tariffs.$inferSelect;
|
export type TariffRow = typeof tariffs.$inferSelect;
|
||||||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||||
export type PermitRow = typeof permits.$inferSelect;
|
export type SubscriptionRow = typeof subscriptions.$inferSelect;
|
||||||
export type PermitCredentialRow = typeof permitCredentials.$inferSelect;
|
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||||||
export type PermitPlateRow = typeof permitPlates.$inferSelect;
|
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||||
export type SessionRow = typeof sessions.$inferSelect;
|
export type SessionRow = typeof sessions.$inferSelect;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
PrinterDevice,
|
PrinterDevice,
|
||||||
PrinterStatus,
|
PrinterStatus,
|
||||||
PrintReport,
|
PrintReport,
|
||||||
|
SubscriptionCardData,
|
||||||
TicketData,
|
TicketData,
|
||||||
} from "../interfaces.js";
|
} from "../interfaces.js";
|
||||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||||
@@ -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 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
|
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 {
|
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). */
|
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||||
function renderReport(report: PrintReport): Buffer {
|
function renderReport(report: PrintReport): Buffer {
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
INIT,
|
INIT,
|
||||||
|
SELECT_CP852,
|
||||||
ALIGN_CENTER,
|
ALIGN_CENTER,
|
||||||
BOLD_ON,
|
BOLD_ON,
|
||||||
line(report.title),
|
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 {
|
function renderTicket(data: TicketData): Buffer {
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
INIT,
|
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,
|
BOLD_ON,
|
||||||
DOUBLE_ON,
|
DOUBLE_ON,
|
||||||
line("PARKING"),
|
line(data.ticketId),
|
||||||
DOUBLE_OFF,
|
DOUBLE_OFF,
|
||||||
BOLD_OFF,
|
BOLD_OFF,
|
||||||
line(),
|
line(),
|
||||||
BOLD_ON,
|
line(STR.issuedAt(data.issuedAt)),
|
||||||
line(data.ticketId),
|
// Contact footer (lost-ticket help) if a phone is set.
|
||||||
BOLD_OFF,
|
...(data.header?.phone ? [line(STR.lostTicket(data.header.phone))] : []),
|
||||||
ALIGN_LEFT,
|
|
||||||
line(),
|
|
||||||
line(`Issued: ${data.issuedAt}`),
|
|
||||||
FEED_AND_CUT,
|
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. */
|
/** Open a TCP socket, write the bytes, wait for flush, then close. */
|
||||||
function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> {
|
function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
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)`);
|
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.
|
* 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
|
* 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) -------------------------
|
// --- Printers (ticket dispenser / booth printer) -------------------------
|
||||||
|
/** Optional park identity printed at the top of a ticket/receipt. All fields
|
||||||
|
* optional — the driver prints only what's set. Sourced from site_config; an
|
||||||
|
* Albanian parking receipt commonly must show the park name + NIUS. */
|
||||||
|
export interface TicketHeader {
|
||||||
|
readonly parkName?: string | null;
|
||||||
|
readonly operatorName?: string | null;
|
||||||
|
/** NIUS — Albanian tax/identification number. */
|
||||||
|
readonly nius?: string | null;
|
||||||
|
readonly address?: string | null;
|
||||||
|
readonly phone?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TicketData {
|
export interface TicketData {
|
||||||
readonly ticketId: string;
|
readonly ticketId: string;
|
||||||
readonly issuedAt: string; // ISO-8601
|
readonly issuedAt: string; // ISO-8601
|
||||||
|
/** Park identity for the header. Absent → driver prints the generic "PARKING". */
|
||||||
|
readonly header?: TicketHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A subscription card: the customer's keepsake, printed at the booth on creation
|
||||||
|
* (and re-printable). The driver renders the `code` as a SCANNABLE QR (the
|
||||||
|
* subscriber scans it every entry/exit) plus the code as text + the holder/validity.
|
||||||
|
* See wiki/entities/subscription.md. */
|
||||||
|
export interface SubscriptionCardData {
|
||||||
|
/** The credential value to encode in the QR (e.g. "SUB-…"). */
|
||||||
|
readonly code: string;
|
||||||
|
readonly holderName?: string | null;
|
||||||
|
/** Coverage window, for the printed card (human-readable already, or ISO). */
|
||||||
|
readonly validFrom?: string | null;
|
||||||
|
readonly validTo?: string | null;
|
||||||
|
readonly header?: TicketHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrinterDevice extends Device {
|
export interface PrinterDevice extends Device {
|
||||||
@@ -201,6 +229,8 @@ export interface PrinterDevice extends Device {
|
|||||||
* printed as-is; the driver adds a header/cut. Kept generic so the business
|
* printed as-is; the driver adds a header/cut. Kept generic so the business
|
||||||
* layer composes the content. See wiki/concepts/shift.md. */
|
* layer composes the content. See wiki/concepts/shift.md. */
|
||||||
printReport(report: PrintReport): Promise<void>;
|
printReport(report: PrintReport): Promise<void>;
|
||||||
|
/** Print a subscription card: a scannable QR of the code + holder/validity. */
|
||||||
|
printSubscriptionCard(data: SubscriptionCardData): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrintReport {
|
export interface PrintReport {
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export type LedgerEventType =
|
|||||||
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
|
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
|
||||||
| "shift_open"
|
| "shift_open"
|
||||||
| "shift_z_report"
|
| "shift_z_report"
|
||||||
|
// Admin loads/removes physical drawer cash (the float). Signed payload:
|
||||||
|
// { amountMinor (signed: + load, − removal), reason, currency, operator }.
|
||||||
|
// Folds into the drawer balance carried across shifts. See wiki/concepts/shift.md.
|
||||||
|
| "cash_movement"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
/** How money was tendered (for payment events + the shift Z-report). */
|
/** How money was tendered (for payment events + the shift Z-report). */
|
||||||
@@ -196,6 +200,16 @@ export function validateTariffStructure(s: unknown): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// The LAST block must be open-ended (uptoMin null) so the "thereafter" rate is
|
||||||
|
// always explicit. A bounded final block silently inherits its own rate past
|
||||||
|
// its bound (a hidden, never-stated price) — forbidden on publish so the admin
|
||||||
|
// must state what time beyond the ladder costs. See wiki/concepts/tariff.md.
|
||||||
|
// (Read/pricing of already-published versions is unaffected — validation runs
|
||||||
|
// only on publish; rateAt() still gracefully handles legacy bounded tails.)
|
||||||
|
const lastBlock = t.blocks[t.blocks.length - 1];
|
||||||
|
if (lastBlock && lastBlock.uptoMin != null) {
|
||||||
|
errs.push("the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return errs;
|
return errs;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1267
-7
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ session projection.
|
|||||||
|
|
||||||
## The rule
|
## The rule
|
||||||
|
|
||||||
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an
|
An identity (ticket id, [[subscription]] credential, or plate) **must not enter while it already has an
|
||||||
OPEN [[parking-session|session]].** At entry:
|
OPEN [[parking-session|session]].** At entry:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -25,13 +25,13 @@ identify vehicle → is there already an OPEN session for this id?
|
|||||||
|
|
||||||
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
|
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
|
||||||
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
|
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
|
||||||
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`.
|
powers [[capacity-occupancy]] and [[subscription]] `maxConcurrent`.
|
||||||
|
|
||||||
## Interaction with the limits already designed
|
## Interaction with the limits already designed
|
||||||
|
|
||||||
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
|
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
|
||||||
id is always a violation (or a re-print/duplication attempt).
|
id is always a violation (or a re-print/duplication attempt).
|
||||||
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a
|
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[subscription]]): a
|
||||||
multi-car permit legitimately has several open sessions, but **the same car/credential** entering
|
multi-car permit legitimately has several open sessions, but **the same car/credential** entering
|
||||||
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
|
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- **`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).
|
`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.
|
permit's open sessions.
|
||||||
|
|
||||||
## Full → refuse entry + FULL sign
|
## 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]])
|
- **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.
|
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).
|
permit means the session owes nothing — the PAY step is skipped (see below).
|
||||||
|
|
||||||
## Lifecycle (pay-on-foot / pay station model)
|
## Lifecycle (pay-on-foot / pay station model)
|
||||||
@@ -71,7 +71,7 @@ States, as derived from events:
|
|||||||
| **CLOSED** | a matching `vehicle_exit` event exists |
|
| **CLOSED** | a matching `vehicle_exit` event exists |
|
||||||
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
|
| **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)
|
## 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]].
|
fails. See [[device-input-flow]].
|
||||||
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
|
- **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**.
|
**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) →
|
- **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`**
|
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
|
||||||
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
|
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ updated: 2026-06-14
|
|||||||
|
|
||||||
# Printer status monitoring
|
# 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
|
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
|
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
|
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:
|
- **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]]).
|
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
|
||||||
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
|
- **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`.
|
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
|
||||||
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
|
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
|
||||||
the `anomaly` events + reconciliation findings ([[reconciliation]]).
|
the `anomaly` events + reconciliation findings ([[reconciliation]]).
|
||||||
|
|||||||
+82
-4
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, shifts, anti-fraud]
|
tags: [parking, domain, business, shifts, anti-fraud]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-15
|
updated: 2026-06-18
|
||||||
status: open
|
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;
|
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
|
||||||
don't force one model across both.
|
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
|
## A shift is NOT time-based
|
||||||
|
|
||||||
It is delimited by **explicit operator action**, never by a clock:
|
It is delimited by **explicit operator action**, never by a clock:
|
||||||
@@ -40,7 +63,9 @@ login ————————————————————————
|
|||||||
## What End Shift does
|
## What End Shift does
|
||||||
|
|
||||||
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
|
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**
|
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).
|
(the card line is omitted when there's no terminal).
|
||||||
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
|
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
|
- 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
|
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
|
event `identity`. `ShiftService` (`apps/server/src/shift-service.ts`).
|
||||||
`shift_open`. `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
|
- **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
|
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
|
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;
|
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
|
||||||
verifyChain ok.
|
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
|
## Where the fraud control actually lives
|
||||||
|
|
||||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
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
|
## 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
|
- **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
|
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).
|
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).
|
||||||
+73
-13
@@ -10,7 +10,7 @@ status: open
|
|||||||
|
|
||||||
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
|
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
|
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
|
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
|
||||||
beyond the host).
|
beyond the host).
|
||||||
|
|
||||||
@@ -45,11 +45,12 @@ code. All amounts are **integer minor units** in the tariff's currency.
|
|||||||
"currency": "EUR", // ISO 4217; selectable per tariff version
|
"currency": "EUR", // ISO 4217; selectable per tariff version
|
||||||
"gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround)
|
"gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround)
|
||||||
"incrementMin": 60, // billing granularity; partial increments round UP
|
"incrementMin": 60, // billing granularity; partial increments round UP
|
||||||
"blocks": [ // consumed in order as duration accrues
|
"blocks": [ // consumed in order as duration accrues; uptoMin is
|
||||||
|
// the CUMULATIVE upper bound in minutes
|
||||||
{ "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour
|
{ "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour
|
||||||
{ "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min
|
{ "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min
|
||||||
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // null = open-ended, thereafter
|
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // REQUIRED open-ended last
|
||||||
],
|
], // block — the explicit "thereafter" rate
|
||||||
"dailyCapMinor": 1200, // cap per rolling 24h (null = no cap)
|
"dailyCapMinor": 1200, // cap per rolling 24h (null = no cap)
|
||||||
"lostTicketMinor": 2000, // flat charge when there's no entry id
|
"lostTicketMinor": 2000, // flat charge when there's no entry id
|
||||||
"gracePeriodExitMin": 15, // pay-on-foot walk-back window
|
"gracePeriodExitMin": 15, // pay-on-foot walk-back window
|
||||||
@@ -92,6 +93,12 @@ because the chain + reconciliation depend on the result being reproducible.
|
|||||||
increment would round it up (else rounding defeats the grace window).
|
increment would round it up (else rounding defeats the grace window).
|
||||||
- **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h
|
- **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h
|
||||||
stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily.
|
stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily.
|
||||||
|
- **The LAST block MUST be open-ended (`uptoMin: null`)** — enforced on publish (2026-06-18). A
|
||||||
|
bounded final block silently inherited its own rate past its bound (a hidden, never-stated price);
|
||||||
|
forcing an open-ended tail makes the "thereafter" rate explicit. `rateAt()` still gracefully prices
|
||||||
|
legacy bounded-tail versions (validation runs only on publish, never on read), so already-published
|
||||||
|
immutable versions keep pricing unchanged. This is the "first N hrs × X, next N hrs × Y, …, 24h
|
||||||
|
cap" model made complete — the same engine, no new axis; the only gap was the unstated tail.
|
||||||
|
|
||||||
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
||||||
across grace, block steps, daily cap, and multi-day reset.
|
across grace, block steps, daily cap, and multi-day reset.
|
||||||
@@ -103,12 +110,17 @@ The admin authors the rate card at runtime — no hand-seeding:
|
|||||||
- **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any
|
- **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any
|
||||||
signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**).
|
signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**).
|
||||||
Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers,
|
Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers,
|
||||||
ordered/ascending block bounds, only the last block open-ended — so a malformed card can never be
|
ordered/ascending block bounds, **the last block open-ended (enforced)**, and **`effectiveFrom`
|
||||||
published. The single site `tariffs` row is created lazily on first read/publish.
|
not in the past** (no backdating) — so a malformed or retroactive card can never be published. The
|
||||||
|
single site `tariffs` row is created lazily on first read/publish.
|
||||||
- **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment,
|
- **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment,
|
||||||
daily cap, lost-ticket fee, and add/remove rate blocks; amounts entered in major units, converted
|
daily cap, lost-ticket fee, and add/remove rate bands; amounts entered in major units, converted to
|
||||||
to integer minor units on submit. Shows the active version + history; "Publish" creates a new
|
integer minor units on submit. **Bands are edited as a DURATION in hours** ("this band lasts N
|
||||||
version (past sessions keep their pricing).
|
hours") — the owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes; the
|
||||||
|
composer accumulates per-band hours into the engine's cumulative `uptoMin` (minutes) on submit. The
|
||||||
|
**last band is always the open-ended "thereafter"** row (not removable, no hours field), so a
|
||||||
|
published card always satisfies the open-ended-last rule. Shows the active version + history;
|
||||||
|
"Publish" creates a new version (past sessions keep their pricing).
|
||||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||||
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||||
|
|
||||||
@@ -124,11 +136,29 @@ time references**, not one:
|
|||||||
`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people
|
`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.
|
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
|
## 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
|
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
|
## Versioning — edits publish immutable, effective-dated versions
|
||||||
|
|
||||||
@@ -145,6 +175,24 @@ that was in force when it was incurred — never today's. So a tariff is **never
|
|||||||
- An **in-progress** session that crosses a version boundary uses the version in force at **entry**
|
- An **in-progress** session that crosses a version boundary uses the version in force at **entry**
|
||||||
(consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter.
|
(consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter.
|
||||||
|
|
||||||
|
### No backdating — versioning would otherwise be retroactive (fixed 2026-06-18)
|
||||||
|
|
||||||
|
The two bullets above only hold if a new version's `effectiveFrom` **cannot be in the past**. The
|
||||||
|
selector is "latest `effectiveFrom ≤ entry time`", so publishing a version with a **backdated**
|
||||||
|
`effectiveFrom` would silently re-select it for sessions that **already entered** — retroactively
|
||||||
|
repricing in-progress (and re-quotable) stays. That is exactly the rewrite the versioning exists to
|
||||||
|
prevent, and it was **publishable** until this fix (the publish handler accepted any `effectiveFrom`,
|
||||||
|
defaulting to now).
|
||||||
|
|
||||||
|
**Rule (enforced server-side in `routes/tariffs.ts`):** on publish, `effectiveFrom` must be **≥ now**
|
||||||
|
(a 60 s skew tolerance absorbs clock drift + round-trip). A **future** `effectiveFrom` is allowed —
|
||||||
|
scheduling a forthcoming price change is legitimate and forward-only. A past one is rejected `400`.
|
||||||
|
Combined with entry-time selection, this makes the guarantee structural: **once a car has entered, no
|
||||||
|
later publish can change its price**, because no new version can carry an `effectiveFrom` that
|
||||||
|
predates the entry. We deliberately did **not** also pin `tariffVersionId` onto the `vehicle_entry`
|
||||||
|
event (entry-time selection + no-backdating already freezes the price); revisit only if multi-tariff
|
||||||
|
`scope` makes entry-time resolution ambiguous.
|
||||||
|
|
||||||
## Data model (first cut — with [[session-model]])
|
## Data model (first cut — with [[session-model]])
|
||||||
|
|
||||||
| Table / field | Notes |
|
| Table / field | Notes |
|
||||||
@@ -169,12 +217,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
|
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
|
||||||
[[open-questions]].
|
[[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
|
## Open
|
||||||
|
|
||||||
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
|
- 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.
|
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).
|
- **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).
|
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
|
||||||
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
|
- **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
|
- **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
|
someone else's cheaper session). Sequential **physical** stock numbering is a separate
|
||||||
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
|
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
|
- **Single logical session** — scanning it at the pay station finds the open session; after payment
|
||||||
it's the proof-of-paid the exit checks.
|
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
|
- The [[rongta-printer]] prints the ticket id as a **1D Code128 barcode** (the all-numeric code),
|
||||||
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap
|
with the **same code in large human-readable digits below it**, then the entry time. Code128 over
|
||||||
camera/imager to read.
|
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]]):
|
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
|
||||||
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
|
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
|
||||||
payment ([[tariff]], pay-on-foot) → appends `payment`.
|
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
|
## 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?).
|
- Scanner hardware (imager model; same unit at pay station and exit?).
|
||||||
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
|
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
|
||||||
amount).
|
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
|
merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is
|
||||||
an anomaly), rather than invisible free parking.
|
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
|
## 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.
|
- Whether merchants self-serve (portal/terminal) or the operator applies it.
|
||||||
- Caps (max discount, max per merchant/day).
|
- 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
|
events. A cache table is allowed for query speed but is always rebuildable and never
|
||||||
authoritative.
|
authoritative.
|
||||||
2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first;
|
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]]).
|
([[entry-exit-readers]]).
|
||||||
3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a
|
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
|
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]]).
|
- Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]).
|
||||||
- Tariff versioning (effective-dated) for historical repricing.
|
- 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
|
- 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.
|
(payment = an independent signed event referencing a session) until procurement settles.
|
||||||
- Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4
|
- 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 /
|
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]]
|
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
|
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/`.)
|
(See [[gee-qr-er80|datasheet summary]] / `raw/`.)
|
||||||
|
|
||||||
## What it is (and isn't)
|
## 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
|
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
|
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`
|
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.)
|
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
|
> **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
|
1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing
|
||||||
`IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way
|
`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
|
2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum
|
||||||
`{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding).
|
`{ 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
|
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
|
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
|
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]].
|
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
|
## Anti-fraud / threat-model fit
|
||||||
|
|
||||||
- **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or
|
- **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.
|
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]]
|
- 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
|
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
|
type: entity
|
||||||
tags: [parking, stack, frontend]
|
tags: [parking, stack, frontend]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-14
|
updated: 2026-06-18
|
||||||
---
|
---
|
||||||
|
|
||||||
# React + Vite SPA
|
# React + Vite SPA
|
||||||
|
|
||||||
The frontend: a React single-page app built with Vite, **served by [[fastify]]** (MIT). Plain
|
The frontend: a React single-page app built with Vite, **served by [[fastify]]** (MIT).
|
||||||
React was chosen over an admin framework — see [[refine]], which was dropped because the
|
(See [[parking-system-architecture]] §2.) Part of the [[technology-stack]].
|
||||||
operator UI is simple enough that a framework's abstractions cost more than they save.
|
|
||||||
(See [[parking-system-architecture]] §2.)
|
|
||||||
|
|
||||||
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
|
## Ticket rendering
|
||||||
|
|
||||||
`printTicket(TicketData)` builds ESC/POS: `ESC @` init, centered/bold/double-size header,
|
`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
|
## 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
|
type: overview
|
||||||
tags: [parking, index]
|
tags: [parking, index]
|
||||||
updated: 2026-06-14
|
updated: 2026-06-18
|
||||||
---
|
---
|
||||||
|
|
||||||
# Index
|
# Index
|
||||||
|
|
||||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
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 & navigation
|
||||||
- [[overview]] — the top-level synthesis and entry point.
|
- [[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.
|
- [[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).
|
- [[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).
|
- [[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
|
## Entities — technology stack
|
||||||
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
- [[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.
|
- [[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-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.
|
- [[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.
|
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
- [[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
|
## Concepts — business domain
|
||||||
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
- [[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.
|
- [[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.
|
- [[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.
|
- [[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-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.
|
- [[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.
|
- [[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.
|
- [[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.
|
- [[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.
|
- [[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).
|
- [[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.
|
- [[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)
|
## Dev environment (reference)
|
||||||
- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin.
|
- [[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.
|
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
|
||||||
|
|||||||
+110
@@ -744,3 +744,113 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
|||||||
builds; no test suite in-repo.
|
builds; no test suite in-repo.
|
||||||
- Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages
|
- 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.
|
(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.
|
||||||
|
|
||||||
|
## [2026-06-18] fix | Tariff versioning was retroactive — forbid backdated effectiveFrom
|
||||||
|
|
||||||
|
The version selector picks "latest tariff_version with effectiveFrom ≤ session entry time" (correct intent: a past session reprices against the rate in force when it was incurred). But the publish handler (`routes/tariffs.ts`) accepted ANY `effectiveFrom` (defaulting to now). So an admin could publish a version with a **backdated** effectiveFrom and silently reprice sessions that had already entered — exactly the retroactive rewrite the versioning exists to prevent. Pricing itself was already sound: `quote()` resolves by `entry.occurredAt` and the `payment` event records `tariffVersionId`, so a COMPLETED session is frozen; the leak was entirely the publish side. Fix: reject `effectiveFrom` earlier than now (60 s skew tolerance); future-dated (scheduling a price change) stays allowed; bad ISO → 400. Decision (with user): forbid backdating + keep entry-time pinning; did NOT add tariffVersionId to vehicle_entry (entry-time selection + no-backdating already freezes the price). Together these make it structural: once a car has entered, no later publish can reprice it. Verified 5/5 via inject against a copy of the live DB (now→201, -1h→400, +1h→201, garbage→400, -10s skew→201). Updated [[tariff]]. No migration.
|
||||||
|
|
||||||
|
## [2026-06-18] feat | Tariff: complete the progressive ladder — open-ended last block required + hours-based composer
|
||||||
|
|
||||||
|
The owner asked for "first N hours × X, next N hours × Y, …, 24h cap" — which the stepped-block engine ALREADY does (ordered blocks, per-block rate, rolling-24h cap; computeFee tested). So no new axis: the work was making the model complete + footgun-free. Two changes. (1) **Forbid a bounded last block** — `validateTariffStructure` (shared) now rejects a final block with a non-null `uptoMin`, so the "thereafter" rate is always explicit; previously a bounded tail silently inherited its own rate past its bound (a hidden, never-stated price — e.g. the live ALL tariff's 180-min last block billed hour 4+ at the 3rd-hour rate). `rateAt()` still prices legacy bounded-tail versions gracefully and validation is publish-only, so immutable published versions are unaffected (no migration). (2) **Composer edits bands as a DURATION in hours**, not cumulative minutes — `BlockForm` carries `hours`; `toStructure` accumulates into cumulative `uptoMin` minutes; the last row is a pinned, non-removable, hours-less "thereafter (open-ended)" band; `blocksToForm` round-trips stored minutes back to band hours (legacy bounded tails still load). i18n: replaced `upToMin`/`egExample` with `bandDuration`/`hoursUnit`/`egHours` in sq+en (catalog parity green). Verified: validator rejects bounded-last / accepts open-ended; computeFee correct at 1/2/3/5/6/24h for a 0-2h@200,2-5h@100,5h+@50 + 1000 cap card. Full build green (shared/server/web). Updated [[tariff]]. No migration.
|
||||||
|
|
||||||
|
NB considered-and-rejected: time-of-day / weekday wall-clock tiers ("timeframe") were offered but the owner explicitly chose the elapsed-duration ladder only — see [[tariff-time-tiers]] for the deferred wall-clock axis.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user