Compare commits
36 Commits
main
...
14c83e182a
| Author | SHA1 | Date | |
|---|---|---|---|
| 14c83e182a | |||
| 445bca0bf6 | |||
| 062feeae2f | |||
| 50a3095ef3 | |||
| eb3dc18e67 | |||
| 06dab1e790 | |||
| 9956488fd5 | |||
| 49df2015c8 | |||
| c2f06a5d2a | |||
| 58d8f06ba0 | |||
| 71aaad03b9 | |||
| 727c62da90 | |||
| 1efa77bf56 | |||
| 15d3e1ba08 | |||
| ff3b011fe0 | |||
| 5705098054 | |||
| 68d61f2d99 | |||
| 04135b27cf | |||
| 392d44d842 | |||
| f67c1ead87 | |||
| bf37106c5c | |||
| e579fe5b6e | |||
| 644bfa1462 | |||
| 3429642edb | |||
| c24d99b0f4 | |||
| b4d0dfadd6 | |||
| f18e28eeca | |||
| a8c6d6e714 | |||
| 2a36830880 | |||
| 2696d281ce | |||
| 648d3254d6 | |||
| 8c2cf93067 | |||
| 9a4c7ee27b | |||
| 8a8e74561d | |||
| 2ab5a39a57 | |||
| fa65b2df86 |
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,3 +20,7 @@ dist/
|
|||||||
/*.png
|
/*.png
|
||||||
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
||||||
/dingtian/
|
/dingtian/
|
||||||
|
/QRCode_sdk*/
|
||||||
|
|
||||||
|
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||||
|
graphify-out/
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
+11
-5
@@ -18,9 +18,15 @@ export const TOKEN_COOKIE = "parking_token";
|
|||||||
export const CSRF_COOKIE = "parking_csrf";
|
export const CSRF_COOKIE = "parking_csrf";
|
||||||
export const CSRF_HEADER = "x-csrf-token";
|
export const CSRF_HEADER = "x-csrf-token";
|
||||||
|
|
||||||
/** Token lifetime, also used as the cookie maxAge. */
|
// Session lifetime: the JWT has NO expiry — a login is valid until explicit
|
||||||
export const TOKEN_TTL = "8h";
|
// logout. Booth reality breaks any fixed clock (relief late/absent, forced double
|
||||||
export const TOKEN_TTL_SECONDS = 8 * 60 * 60;
|
// shifts), and a shift is a separate explicit boundary, not the token's lifetime.
|
||||||
|
// See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md.
|
||||||
|
//
|
||||||
|
// The cookie still needs a maxAge so it survives a browser restart (a session
|
||||||
|
// cookie would log out an active operator on browser close — the opposite of
|
||||||
|
// "until logout"). Use a long fixed window; the server clears it on logout.
|
||||||
|
export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the JWT signing secret, refusing to start without a strong one.
|
* Resolve the JWT signing secret, refusing to start without a strong one.
|
||||||
@@ -55,7 +61,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
|||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
secure,
|
secure,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: TOKEN_TTL_SECONDS,
|
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||||
});
|
});
|
||||||
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
||||||
reply.setCookie(CSRF_COOKIE, csrf, {
|
reply.setCookie(CSRF_COOKIE, csrf, {
|
||||||
@@ -63,7 +69,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
|||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
secure,
|
secure,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: TOKEN_TTL_SECONDS,
|
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -8,17 +9,41 @@ import type { PrinterStatus } from "@parking/devices";
|
|||||||
|
|
||||||
export interface DeviceInputEvent {
|
export interface DeviceInputEvent {
|
||||||
readonly driverId: string; // e.g. "dingtian"
|
readonly driverId: string; // e.g. "dingtian"
|
||||||
readonly deviceId: string; // which configured device (lane_devices id)
|
readonly deviceId: string; // which configured device (devices id)
|
||||||
readonly input: number; // 1-based input/channel
|
readonly input: number; // 1-based input/channel
|
||||||
readonly edge: "on" | "off"; // active / inactive
|
readonly edge: "on" | "off"; // active / inactive
|
||||||
readonly at: string; // ISO-8601 (server receive time)
|
readonly at: string; // ISO-8601 (server receive time)
|
||||||
readonly source: "push" | "poll";
|
readonly source: "push" | "poll";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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`
|
||||||
|
// mirrors IdentitySource. See parking-session.md.
|
||||||
|
export interface DeviceReadEvent {
|
||||||
|
readonly driverId: string;
|
||||||
|
readonly deviceId: string; // devices id of the reader/scanner/camera
|
||||||
|
readonly value: string; // the ticket id / plate / card number
|
||||||
|
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||||
|
readonly at: string; // ISO-8601
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
||||||
|
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
||||||
|
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md.
|
||||||
|
*/
|
||||||
|
export interface ReadOutcome {
|
||||||
|
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||||
|
readonly accepted: boolean;
|
||||||
|
/** Which way it went, when known (permit/exit infer this). */
|
||||||
|
readonly direction?: "entry" | "exit";
|
||||||
|
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
||||||
|
readonly reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A printer's status as tracked by the live monitor (status + identity). */
|
/** A printer's status as tracked by the live monitor (status + identity). */
|
||||||
export interface PrinterStatusEvent {
|
export interface PrinterStatusEvent {
|
||||||
readonly deviceId: string; // lane_devices id
|
readonly deviceId: string; // devices id
|
||||||
readonly lane: number;
|
|
||||||
readonly driverId: string;
|
readonly driverId: string;
|
||||||
readonly role?: string; // entry-dispenser | booth-receipt
|
readonly role?: string; // entry-dispenser | booth-receipt
|
||||||
readonly status: PrinterStatus;
|
readonly status: PrinterStatus;
|
||||||
@@ -33,6 +58,15 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
return () => this.off("input", cb);
|
return () => this.off("input", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A credential read (ticket scan, plate, card). */
|
||||||
|
emitRead(event: DeviceReadEvent): void {
|
||||||
|
this.emit("read", event);
|
||||||
|
}
|
||||||
|
onRead(cb: (event: DeviceReadEvent) => void): () => void {
|
||||||
|
this.on("read", cb);
|
||||||
|
return () => this.off("read", cb);
|
||||||
|
}
|
||||||
|
|
||||||
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
||||||
emitPrinterStatus(event: PrinterStatusEvent): void {
|
emitPrinterStatus(event: PrinterStatusEvent): void {
|
||||||
this.emit("printer-status", event);
|
this.emit("printer-status", event);
|
||||||
@@ -41,6 +75,21 @@ 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 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,155 @@
|
|||||||
|
import { and, eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
|
||||||
|
// Device resolution for the pool-of-spaces model — NO lane. A parking lot is one
|
||||||
|
// pool with a flexible set of entry/exit points. Direction lives on each RELAY
|
||||||
|
// inside an access controller, and readers/cameras BIND to a (controller, relay).
|
||||||
|
// See wiki/concepts/entry-exit-points.md.
|
||||||
|
|
||||||
|
/** A flow direction. "both" = one relay/barrier serving entry AND exit. */
|
||||||
|
export type Direction = "entry" | "exit" | "both";
|
||||||
|
/** A concrete flow a credential/button drives (never "both"). */
|
||||||
|
export type FlowDirection = "entry" | "exit";
|
||||||
|
|
||||||
|
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||||
|
* and (optionally) the input terminal its entry button is wired to. */
|
||||||
|
export interface RelaySpec {
|
||||||
|
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||||
|
readonly relay: number;
|
||||||
|
readonly direction: Direction;
|
||||||
|
/** 1-based input terminal of the entry button that fires this relay (transient
|
||||||
|
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
||||||
|
readonly button?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access controller config (the `relays[]` map + connection fields). */
|
||||||
|
interface AccessConfig {
|
||||||
|
readonly relays?: RelaySpec[];
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reader/camera config: optional binding to a controller relay. */
|
||||||
|
interface BoundConfig {
|
||||||
|
/** The access `devices.id` this reader/camera sits at. */
|
||||||
|
readonly controllerId?: string;
|
||||||
|
/** The relay on that controller it opens. */
|
||||||
|
readonly relay?: number;
|
||||||
|
/** Fallback direction when not bound to a relay. */
|
||||||
|
readonly direction?: Direction;
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resolved barrier: the controller row + the specific relay to pulse. */
|
||||||
|
export interface ResolvedRelay {
|
||||||
|
readonly controller: DeviceRow;
|
||||||
|
readonly relay: number;
|
||||||
|
readonly direction: Direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All enabled access controller rows. */
|
||||||
|
function accessRows(db: Db): DeviceRow[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.category, "access"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The relay specs declared on an access controller (defaults to none). */
|
||||||
|
export function relaysOf(row: DeviceRow): RelaySpec[] {
|
||||||
|
const cfg = row.config as AccessConfig;
|
||||||
|
return Array.isArray(cfg.relays) ? cfg.relays : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a button press to the relay it fires: the access controller with this
|
||||||
|
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
||||||
|
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
||||||
|
*/
|
||||||
|
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const spec = relaysOf(row).find((r) => r.button === terminal);
|
||||||
|
if (!spec) return null;
|
||||||
|
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||||
|
return { controller: row, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
|
||||||
|
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
|
||||||
|
* spec. Fallback (unbound): the device's config.direction + the first relay site-
|
||||||
|
* wide matching that direction — keeps the single-barrier case trivial. Null if
|
||||||
|
* nothing resolves (no barrier to open).
|
||||||
|
*/
|
||||||
|
export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | null {
|
||||||
|
const cfg = deviceRow.config as BoundConfig;
|
||||||
|
|
||||||
|
// Bound: follow controllerId + relay to the exact barrier.
|
||||||
|
if (cfg.controllerId && typeof cfg.relay === "number") {
|
||||||
|
const controller = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, cfg.controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (controller && controller.enabled) {
|
||||||
|
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||||
|
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbound: fall back to the device's declared direction + first matching relay.
|
||||||
|
const want = cfg.direction;
|
||||||
|
if (want === "entry" || want === "exit" || want === "both") {
|
||||||
|
return firstRelayByDirection(db, want === "both" ? "entry" : want);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first relay site-wide serving a direction ("both" relays match either).
|
||||||
|
* Used as the unbound fallback and where a flow only needs "an exit barrier".
|
||||||
|
*/
|
||||||
|
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||||
|
for (const controller of accessRows(db)) {
|
||||||
|
const spec = relaysOf(controller).find(
|
||||||
|
(r) => r.direction === direction || r.direction === "both",
|
||||||
|
);
|
||||||
|
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enabled devices of a category whose direction matches `want` (or is "both").
|
||||||
|
* Direction is inherited from each device's bound relay, else its config fallback.
|
||||||
|
* Used for snapshots: every entry/exit camera fires on an entry/exit. */
|
||||||
|
export function devicesByDirection(
|
||||||
|
db: Db,
|
||||||
|
category: DeviceRow["category"],
|
||||||
|
want: FlowDirection,
|
||||||
|
): DeviceRow[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.category, category))
|
||||||
|
.all()
|
||||||
|
.filter((r) => {
|
||||||
|
if (!r.enabled) return false;
|
||||||
|
const d = directionOf(db, r);
|
||||||
|
return d === want || d === "both";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The direction a reader/camera operates in (inherited from its bound relay, or
|
||||||
|
* its config fallback). "both" when undetermined → the flow infers. */
|
||||||
|
export function directionOf(db: Db, deviceRow: DeviceRow): Direction {
|
||||||
|
const resolved = relayForDevice(db, deviceRow);
|
||||||
|
if (resolved) return resolved.direction;
|
||||||
|
const cfg = deviceRow.config as BoundConfig;
|
||||||
|
return cfg.direction === "entry" || cfg.direction === "exit" ? cfg.direction : "both";
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { randomInt } from "node:crypto";
|
||||||
|
import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import {
|
||||||
|
NoPrinterAvailableError,
|
||||||
|
printWithFailover,
|
||||||
|
registry,
|
||||||
|
type AccessControlDevice,
|
||||||
|
type PrinterDevice,
|
||||||
|
type PrinterInstance,
|
||||||
|
type TicketData,
|
||||||
|
type TicketHeader,
|
||||||
|
} from "@parking/devices";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { DeviceInputEvent } from "./device-events.js";
|
||||||
|
import { getOccupancy } from "./occupancy.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { devicesByDirection, relayForButton, type ResolvedRelay } from "./device-resolve.js";
|
||||||
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
|
|
||||||
|
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
|
||||||
|
// → open the barrier. The button is wired into an access controller's input; the
|
||||||
|
// admin maps that input terminal to a relay (config.relays[].button), so a press
|
||||||
|
// resolves to exactly the entry relay it should open. See entry-exit-points.md.
|
||||||
|
//
|
||||||
|
// Two invariants from the threat model + safety analysis:
|
||||||
|
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
|
||||||
|
// BEFORE pulseOpen fires; an open with no matching signed event is the fraud
|
||||||
|
// signal (wiki/concepts/append-only-event-chain.md).
|
||||||
|
// 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if
|
||||||
|
// all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket
|
||||||
|
// unprinted) and leave the barrier closed; the operator handles the held car.
|
||||||
|
// Crucially, NO vehicle_entry is written in that case — we never record an
|
||||||
|
// "entered" event for a car that didn't get in (decision 2026-06-15).
|
||||||
|
//
|
||||||
|
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
|
||||||
|
// (fail) sign anomaly, stop.
|
||||||
|
|
||||||
|
export class EntryFlow {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
/** Guard against double-fire from the same physical press (on edge only). */
|
||||||
|
readonly #inFlight = new Set<string>();
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a device input edge. Acts only on the rising ("on") edge of an entry
|
||||||
|
* button — an input terminal mapped to an entry relay on its controller. */
|
||||||
|
async onInput(e: DeviceInputEvent): Promise<void> {
|
||||||
|
if (e.edge !== "on") return; // release edge is just telemetry
|
||||||
|
|
||||||
|
// The firing device must be an access controller, and the pressed input terminal
|
||||||
|
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
|
||||||
|
// (reader/printer edge, exit-only relay's input) is not a transient-entry trigger.
|
||||||
|
const resolved = relayForButton(this.#db, e.deviceId, e.input);
|
||||||
|
if (!resolved) return;
|
||||||
|
|
||||||
|
const key = `${e.deviceId}:${e.input}`;
|
||||||
|
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
await this.#runEntry(resolved);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`entry-flow failed: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
||||||
|
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||||
|
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT
|
||||||
|
// 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-
|
||||||
|
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
const occ = getOccupancy(this.#db);
|
||||||
|
if (occ.full) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = newTicketId();
|
||||||
|
const issuedAt = new Date().toISOString();
|
||||||
|
const printers = this.#loadPrinters();
|
||||||
|
|
||||||
|
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||||
|
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||||
|
try {
|
||||||
|
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||||
|
d.printTicket(ticket),
|
||||||
|
);
|
||||||
|
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
|
||||||
|
} catch (err) {
|
||||||
|
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
|
||||||
|
// failed attempt is in the tamper-evident record for the operator.
|
||||||
|
const reason =
|
||||||
|
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: ticketId,
|
||||||
|
payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||||
|
await this.#log.append({
|
||||||
|
type: "vehicle_entry",
|
||||||
|
direction: "entry",
|
||||||
|
source: "ticket",
|
||||||
|
identity: ticketId,
|
||||||
|
payload: { sessionRef: ticketId, ticketPrinted: true },
|
||||||
|
occurredAt: issuedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||||
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (access) await access.pulseOpen(resolved.relay);
|
||||||
|
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||||
|
|
||||||
|
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
|
||||||
|
// a camera failure must not delay or block the already-open barrier).
|
||||||
|
void snapshotAsync({
|
||||||
|
db: this.#db,
|
||||||
|
direction: "entry",
|
||||||
|
identity: ticketId,
|
||||||
|
logger: this.#logger,
|
||||||
|
}).catch((err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`));
|
||||||
|
|
||||||
|
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
||||||
|
// just a fast read-model, never the source of truth).
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.insert(sessions)
|
||||||
|
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
||||||
|
// can be rebuilt. Log it; don't fail the (already-open) entry.
|
||||||
|
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build live ENTRY printer instances (for failover selection). */
|
||||||
|
#loadPrinters(): PrinterInstance[] {
|
||||||
|
const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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).
|
||||||
|
*
|
||||||
|
* 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 {
|
||||||
|
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];
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { desc, events, type Db, type EventRow } from "@parking/db";
|
import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
|
||||||
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared";
|
import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
|
||||||
|
|
||||||
// The append-only, hash-chained, signed event log — the system's core anti-fraud
|
// The append-only, hash-chained, signed event log — the system's core anti-fraud
|
||||||
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
|
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
|
||||||
@@ -16,11 +16,12 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
|
|||||||
// so we guard it with an in-process async lock as well.
|
// so we guard it with an in-process async lock as well.
|
||||||
|
|
||||||
export interface AppendInput {
|
export interface AppendInput {
|
||||||
readonly type: ParkingEventType;
|
readonly type: LedgerEventType;
|
||||||
readonly lane: number;
|
|
||||||
readonly direction?: Direction | null;
|
readonly direction?: Direction | null;
|
||||||
readonly source?: IdentitySource | null;
|
readonly source?: IdentitySource | null;
|
||||||
readonly identity?: string | null;
|
readonly identity?: string | null;
|
||||||
|
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
|
||||||
|
readonly payload?: LedgerPayload | null;
|
||||||
/** Event time (ISO-8601). Defaults to now. */
|
/** Event time (ISO-8601). Defaults to now. */
|
||||||
readonly occurredAt?: string;
|
readonly occurredAt?: string;
|
||||||
}
|
}
|
||||||
@@ -36,9 +37,9 @@ export function canonicalize(e: {
|
|||||||
index: number;
|
index: number;
|
||||||
type: string;
|
type: string;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
lane: number;
|
|
||||||
source: string | null;
|
source: string | null;
|
||||||
identity: string | null;
|
identity: string | null;
|
||||||
|
payload: Record<string, unknown> | null;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
prevHash: string | null;
|
prevHash: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
@@ -46,57 +47,107 @@ export function canonicalize(e: {
|
|||||||
e.index,
|
e.index,
|
||||||
e.type,
|
e.type,
|
||||||
e.direction ?? null,
|
e.direction ?? null,
|
||||||
e.lane,
|
|
||||||
e.source ?? null,
|
e.source ?? null,
|
||||||
e.identity ?? null,
|
e.identity ?? null,
|
||||||
|
// Payload is part of the signed form so business data is tamper-evident.
|
||||||
|
// Serialize with sorted keys for byte-stability (object key order must not
|
||||||
|
// change a signature). null when the event type carries no payload.
|
||||||
|
canonicalPayload(e.payload),
|
||||||
e.occurredAt,
|
e.occurredAt,
|
||||||
e.prevHash ?? null,
|
e.prevHash ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
|
||||||
|
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
|
||||||
|
if (p == null) return null;
|
||||||
|
const sort = (v: unknown): unknown => {
|
||||||
|
if (Array.isArray(v)) return v.map(sort);
|
||||||
|
if (v && typeof v === "object") {
|
||||||
|
return Object.keys(v as Record<string, unknown>)
|
||||||
|
.sort()
|
||||||
|
.reduce<Record<string, unknown>>((o, k) => {
|
||||||
|
o[k] = sort((v as Record<string, unknown>)[k]);
|
||||||
|
return o;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
return sort(p);
|
||||||
|
}
|
||||||
|
|
||||||
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
|
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
|
||||||
export function hashEvent(canonical: string): string {
|
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. */
|
||||||
append(input: AppendInput): Promise<EventRow> {
|
append(input: AppendInput): Promise<LedgerEventRow> {
|
||||||
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): EventRow {
|
#appendNow(input: AppendInput): LedgerEventRow {
|
||||||
const prev = this.#db
|
const prev = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(ledgerEvents)
|
||||||
.orderBy(desc(events.index))
|
.orderBy(desc(ledgerEvents.index))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
const index = (prev?.index ?? 0) + 1;
|
const index = (prev?.index ?? 0) + 1;
|
||||||
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
|
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
|
||||||
const occurredAt = input.occurredAt ?? new Date().toISOString();
|
const occurredAt = input.occurredAt ?? new Date().toISOString();
|
||||||
|
const payload = input.payload ?? null;
|
||||||
|
|
||||||
const canonical = canonicalize({
|
const canonical = canonicalize({
|
||||||
index,
|
index,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
direction: input.direction ?? null,
|
direction: input.direction ?? null,
|
||||||
lane: input.lane,
|
|
||||||
source: input.source ?? null,
|
source: input.source ?? null,
|
||||||
identity: input.identity ?? null,
|
identity: input.identity ?? null,
|
||||||
|
payload,
|
||||||
occurredAt,
|
occurredAt,
|
||||||
prevHash,
|
prevHash,
|
||||||
});
|
});
|
||||||
@@ -106,26 +157,33 @@ export class EventLog {
|
|||||||
index,
|
index,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
direction: input.direction ?? null,
|
direction: input.direction ?? null,
|
||||||
lane: input.lane,
|
|
||||||
source: input.source ?? null,
|
source: input.source ?? null,
|
||||||
identity: input.identity ?? null,
|
identity: input.identity ?? null,
|
||||||
|
payload,
|
||||||
occurredAt,
|
occurredAt,
|
||||||
prevHash,
|
prevHash,
|
||||||
signature: this.#signer.sign(canonical),
|
signature: this.#signer.sign(canonical),
|
||||||
|
keyId: this.#signer.keyId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.#db.insert(events).values(row).run();
|
this.#db.insert(ledgerEvents).values(row).run();
|
||||||
return row as EventRow;
|
return row as LedgerEventRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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(events).orderBy(events.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
let expectedIndex = 1;
|
let expectedIndex = 1;
|
||||||
let prevHash: string | null = null;
|
let prevHash: string | null = null;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -135,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);
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
|
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
|
import { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
|
||||||
|
// the session → validate it is PAID and within the walk-back grace → sign a
|
||||||
|
// vehicle_exit → open. Payment is decoupled from exit (it happens earlier at the
|
||||||
|
// pay station); the exit lane only VALIDATES. See wiki/concepts/parking-session.md.
|
||||||
|
//
|
||||||
|
// Validation is a fold over the SIGNED ledger (the authoritative record), not the
|
||||||
|
// projection cache: find the open vehicle_entry for this identity, then a covering
|
||||||
|
// payment within grace. The cache is updated after, for fast reads.
|
||||||
|
//
|
||||||
|
// REJECT (barrier stays closed) when unpaid / over grace — this is correct business
|
||||||
|
// logic, NOT a fail-state. "Exit fails OPEN" (fail-state-safety) is about the SYSTEM
|
||||||
|
// being unable to decide (power/host loss), not about an unpaid car; an unpaid driver
|
||||||
|
// is sent back to the pay station, the rejection is logged.
|
||||||
|
//
|
||||||
|
// NOTE: payments / the pay station don't exist yet, so no session is ever PAID — every
|
||||||
|
// transient exit currently REJECTS (logged). That's the correct end-state; it becomes
|
||||||
|
// passable once the pay-station + `payment` events land.
|
||||||
|
|
||||||
|
interface SessionView {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly enteredAt: string;
|
||||||
|
readonly open: boolean; // no vehicle_exit yet
|
||||||
|
readonly paidAt: string | null; // latest payment time, if any
|
||||||
|
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
||||||
|
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
|
||||||
|
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
|
||||||
|
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
|
||||||
|
// active tariff resolves (then we fall back to the normal paid check).
|
||||||
|
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
|
||||||
|
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
|
||||||
|
* the barrier didn't open (payment stands; operator opens manually). */
|
||||||
|
export type BoothExitResult =
|
||||||
|
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||||
|
| { ok: true; opened: true }
|
||||||
|
| { ok: true; opened: false; reason: string };
|
||||||
|
|
||||||
|
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
|
||||||
|
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
|
||||||
|
* intervention was recorded (signed anomaly) but the relay did not fire. */
|
||||||
|
export type BoothReopenResult =
|
||||||
|
| { ok: false; reason: string }
|
||||||
|
| { ok: true; opened: boolean; reason?: string };
|
||||||
|
|
||||||
|
export class ExitFlow {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #inFlight = new Set<string>();
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
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). Unlike exitForBooth this does NOT sign a
|
||||||
|
* `vehicle_exit` (the session may already be exited; a second exit would
|
||||||
|
* double-count occupancy). It re-pulses the exit relay and signs an `anomaly`
|
||||||
|
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
|
||||||
|
* 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" };
|
||||||
|
// No payment → no re-open. The barrier-open action is only for sessions that
|
||||||
|
// have been paid (or paid-then-exited within grace). An unpaid car takes the
|
||||||
|
// pay/exit flow instead — enforced here, not just in the UI.
|
||||||
|
if (view.paidAt == null) {
|
||||||
|
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) — never a second vehicle_exit.
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
reason: "manual barrier open (human intervention)",
|
||||||
|
source: "booth",
|
||||||
|
barrierReopen: true,
|
||||||
|
...(operator ? { operator } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
|
||||||
|
return { ok: true, opened: false, reason: "no exit barrier configured — open manually" };
|
||||||
|
}
|
||||||
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (!access) {
|
||||||
|
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
|
||||||
|
return { ok: true, opened: false, reason: "barrier unavailable — open manually" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await access.pulseOpen(resolved.relay);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
|
||||||
|
return { ok: true, opened: false, reason: "barrier did not open — open manually" };
|
||||||
|
}
|
||||||
|
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
|
||||||
|
return { ok: true, opened: true };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
|
||||||
|
* read dispatcher from the reader's binding, which has ruled out a permit match). */
|
||||||
|
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
const key = `${e.deviceId}:${e.value}`;
|
||||||
|
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
return await this.#runExit(resolved, e);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`exit-flow failed: ${(err as Error).message}`);
|
||||||
|
return { accepted: false, reason: (err as Error).message };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
const view = this.#sessionFor(e.value);
|
||||||
|
|
||||||
|
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||||
|
if (!view || !view.open) {
|
||||||
|
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for credential";
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: e.value,
|
||||||
|
payload: { reason, exitRefused: true },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`exit refused: no open session for ${e.value}`);
|
||||||
|
return { accepted: false, direction: "exit", reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate
|
||||||
|
// with no pay-station visit. Mint a signed $0 `payment` first so the ledger keeps
|
||||||
|
// its "an exit is covered by a payment" invariant, then fall through to open.
|
||||||
|
// Only when NOT already paid (a real payment, walk-back grace, takes precedence).
|
||||||
|
if (view.paidAt == null && view.freeGrace) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "payment",
|
||||||
|
// No `source` (not operator-keyed nor a read) — the payload reason marks it.
|
||||||
|
identity: e.value,
|
||||||
|
payload: {
|
||||||
|
sessionRef: e.value,
|
||||||
|
amountMinor: 0,
|
||||||
|
currency: view.freeGrace.currency,
|
||||||
|
tariffVersionId: view.freeGrace.tariffVersionId,
|
||||||
|
graceExitMin: view.freeGrace.graceExitMin,
|
||||||
|
reason: "free entry-grace (no charge)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.info(`exit free within entry-grace (${e.value})`);
|
||||||
|
return this.#signExitAndOpen(resolved, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAID + within walk-back grace?
|
||||||
|
const paid = view.paidAt != null;
|
||||||
|
const withinGrace =
|
||||||
|
paid &&
|
||||||
|
view.graceExitMin != null &&
|
||||||
|
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
|
||||||
|
if (!paid || !withinGrace) {
|
||||||
|
const reason = !paid
|
||||||
|
? "exit refused — not paid (pay at the station)"
|
||||||
|
: "exit refused — walk-back grace expired (top-up required)";
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: e.value,
|
||||||
|
payload: { reason, exitRefused: true, sessionRef: e.value },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`exit refused (${e.value}): ${reason}`);
|
||||||
|
return { accepted: false, direction: "exit", reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid (a real payment within walk-back grace): sign + open.
|
||||||
|
return this.#signExitAndOpen(resolved, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
|
||||||
|
* Shared by the paid-exit and free-entry-grace paths. The caller has already
|
||||||
|
* established the session is allowed out (and, for grace, minted the $0 payment). */
|
||||||
|
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
|
||||||
|
|
||||||
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (access) await access.pulseOpen(resolved.relay);
|
||||||
|
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
||||||
|
|
||||||
|
this.#fireExitSnapshot(e.value);
|
||||||
|
this.#closeSessionCache(e.value);
|
||||||
|
return { accepted: true, direction: "exit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append the signed vehicle_exit. `source` defaults to "ticket" (booth/manual). */
|
||||||
|
async #signExit(identity: string, source: "ticket" | "lpr" = "ticket"): Promise<void> {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "vehicle_exit",
|
||||||
|
direction: "exit",
|
||||||
|
source,
|
||||||
|
identity,
|
||||||
|
payload: { sessionRef: identity },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
|
||||||
|
#fireExitSnapshot(identity: string): void {
|
||||||
|
void snapshotAsync({
|
||||||
|
db: this.#db,
|
||||||
|
direction: "exit",
|
||||||
|
identity,
|
||||||
|
logger: this.#logger,
|
||||||
|
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the (rebuildable) session projection cache to closed. */
|
||||||
|
#closeSessionCache(identity: string): void {
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.update(sessions)
|
||||||
|
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||||
|
.where(eq(sessions.id, identity))
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
||||||
|
#sessionFor(identity: string): SessionView | null {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return null;
|
||||||
|
const exited = rows.some((r) => r.type === "vehicle_exit");
|
||||||
|
|
||||||
|
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 LedgerPayload & { graceExitMin?: number };
|
||||||
|
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
open: !exited,
|
||||||
|
paidAt,
|
||||||
|
graceExitMin,
|
||||||
|
freeGrace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||||
|
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
|
||||||
|
#tariffVersionFor(at: string) {
|
||||||
|
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (!tariff) return null;
|
||||||
|
const versions = this.#db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all();
|
||||||
|
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a live access adapter from a resolved controller row, or null. */
|
||||||
|
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(row.config as never) as AccessControlDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { laneDevices, type Db } from "@parking/db";
|
|
||||||
|
|
||||||
// Resolves a device instance id (lane_devices.id) to its lane number.
|
|
||||||
//
|
|
||||||
// Device pushes/events carry the `lane_devices` id (which device fired), not a
|
|
||||||
// lane. The event log wants the lane, so we keep a small in-memory id->lane map
|
|
||||||
// rebuilt from the DB at startup and refreshed whenever assignments change
|
|
||||||
// (assign/unassign). It's tiny (one row per device) and read on the hot path of
|
|
||||||
// every input event, so a cached map beats a per-event DB lookup.
|
|
||||||
export class LaneMap {
|
|
||||||
readonly #db: Db;
|
|
||||||
#byDeviceId = new Map<string, number>();
|
|
||||||
|
|
||||||
constructor(db: Db) {
|
|
||||||
this.#db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** (Re)load the id->lane map from the lane_devices table. */
|
|
||||||
refresh(): void {
|
|
||||||
const rows = this.#db.select().from(laneDevices).all();
|
|
||||||
const next = new Map<string, number>();
|
|
||||||
for (const r of rows) next.set(r.id, r.lane);
|
|
||||||
this.#byDeviceId = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lane for a device instance id, or null if the device isn't known. */
|
|
||||||
laneFor(deviceId: string): number | null {
|
|
||||||
return this.#byDeviceId.get(deviceId) ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||||
|
|
||||||
|
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
|
||||||
|
// with no matching vehicle_exit. Never a hand-maintained counter (which is
|
||||||
|
// editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|
||||||
|
export interface Occupancy {
|
||||||
|
/** Cars currently inside (open sessions). */
|
||||||
|
readonly count: number;
|
||||||
|
/** Admin-set nominal capacity, or null = no limit. */
|
||||||
|
readonly capacity: number | null;
|
||||||
|
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
||||||
|
readonly free: number | null;
|
||||||
|
/** True when count ≥ capacity (always false when uncapped). */
|
||||||
|
readonly full: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count cars inside: entries minus exits, per identity, over the ledger. */
|
||||||
|
export function occupancyCount(db: Db): number {
|
||||||
|
const rows = db
|
||||||
|
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.all();
|
||||||
|
const balance = new Map<string, number>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
||||||
|
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
||||||
|
}
|
||||||
|
let open = 0;
|
||||||
|
for (const v of balance.values()) if (v > 0) open += 1;
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin-set capacity (null = uncapped). */
|
||||||
|
export function siteCapacity(db: Db): number | null {
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return row?.capacity ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOccupancy(db: Db): Occupancy {
|
||||||
|
const count = occupancyCount(db);
|
||||||
|
const capacity = siteCapacity(db);
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
capacity,
|
||||||
|
free: capacity == null ? null : capacity - count,
|
||||||
|
full: capacity != null && count >= capacity,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
|
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||||
|
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||||
|
// 1. quote(identity) → look up the open session, price it against the tariff in
|
||||||
|
// force at entry, return the amount due (no side effect).
|
||||||
|
// 2. pay(identity, tender) → re-price, append a SIGNED `payment` event carrying
|
||||||
|
// the amount, currency, tender, tariffVersionId, and graceExitMin (so the exit
|
||||||
|
// flow can validate paid + within walk-back grace). Payment is a signed ledger
|
||||||
|
// event, never a mutable "paid" flag — an operator can't forge or delete it.
|
||||||
|
// See wiki/concepts/tariff.md, parking-session.md.
|
||||||
|
|
||||||
|
export class NoOpenSessionError extends Error {
|
||||||
|
constructor(identity: string) {
|
||||||
|
super(`no open session for ${identity}`);
|
||||||
|
this.name = "NoOpenSessionError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class NoTariffError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("no active tariff configured");
|
||||||
|
this.name = "NoTariffError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Quote {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly enteredAt: string;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
readonly currency: string;
|
||||||
|
readonly tariffVersionId: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PayStation {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price an open session against the tariff in force at its entry. No side effect. */
|
||||||
|
quote(identity: string): Quote {
|
||||||
|
const entry = this.#openEntry(identity);
|
||||||
|
if (!entry) throw new NoOpenSessionError(identity);
|
||||||
|
|
||||||
|
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||||
|
if (!tv) throw new NoTariffError();
|
||||||
|
const structure = tv.structure as unknown as TariffStructure;
|
||||||
|
|
||||||
|
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure);
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
amountMinor,
|
||||||
|
currency: tv.currency,
|
||||||
|
tariffVersionId: tv.id,
|
||||||
|
graceExitMin: structure.gracePeriodExitMin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take payment for a session and append the signed `payment` event. Re-quotes at
|
||||||
|
* the moment of payment (the customer pays for time parked SO FAR). For an
|
||||||
|
* overstay top-up the same call re-prices entry→now and the exit flow's
|
||||||
|
* grace-window restarts from this payment. `overrideMinor` lets the operator set
|
||||||
|
* an arbitrary amount (lost ticket / dispute) — recorded as the charged amount.
|
||||||
|
*/
|
||||||
|
async pay(
|
||||||
|
identity: string,
|
||||||
|
tender: Tender,
|
||||||
|
overrideMinor?: number,
|
||||||
|
): Promise<{ amountMinor: number; currency: string }> {
|
||||||
|
const q = this.quote(identity);
|
||||||
|
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "payment",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
amountMinor,
|
||||||
|
currency: q.currency,
|
||||||
|
tender,
|
||||||
|
tariffVersionId: q.tariffVersionId,
|
||||||
|
// The exit flow reads graceExitMin off the payment to validate the
|
||||||
|
// walk-back window without re-resolving the tariff.
|
||||||
|
graceExitMin: q.graceExitMin,
|
||||||
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the projection cache (rebuildable; not the source of truth).
|
||||||
|
try {
|
||||||
|
this.#db.update(sessions).set({ state: "paid" }).where(eq(sessions.id, identity)).run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
let amountMinor: number | null = null;
|
||||||
|
let currency: string | null = null;
|
||||||
|
if (open) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
|
||||||
|
// ACTIVE = still inside, OR exited but still within the (unconfirmed) grace window.
|
||||||
|
// An exited session past grace is presumed truly gone → omitted.
|
||||||
|
if (!open && !withinGrace) continue;
|
||||||
|
|
||||||
|
// Amount owed now: only meaningful for an open + unpaid session.
|
||||||
|
let amountMinor: number | null = null;
|
||||||
|
let currency: string | null = null;
|
||||||
|
if (open && a.paidAt == null) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest entry first.
|
||||||
|
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
||||||
|
#openEntry(identity: string) {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return null;
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already closed
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||||
|
* (single, for now) active site tariff. */
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { eq, laneDevices, type Db } from "@parking/db";
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
isMonitorable,
|
isMonitorable,
|
||||||
registry,
|
registry,
|
||||||
@@ -66,8 +66,8 @@ export class PrinterMonitor {
|
|||||||
async refreshDevices(): Promise<void> {
|
async refreshDevices(): Promise<void> {
|
||||||
const rows = await this.#db
|
const rows = await this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(laneDevices)
|
.from(devices)
|
||||||
.where(eq(laneDevices.category, "printer"))
|
.where(eq(devices.category, "printer"))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -89,7 +89,6 @@ export class PrinterMonitor {
|
|||||||
build: () => driver.create(cfg as never),
|
build: () => driver.create(cfg as never),
|
||||||
meta: {
|
meta: {
|
||||||
deviceId: row.id,
|
deviceId: row.id,
|
||||||
lane: row.lane,
|
|
||||||
driverId: row.driverId,
|
driverId: row.driverId,
|
||||||
role: typeof cfg.role === "string" ? cfg.role : undefined,
|
role: typeof cfg.role === "string" ? cfg.role : undefined,
|
||||||
},
|
},
|
||||||
@@ -139,7 +138,7 @@ export class PrinterMonitor {
|
|||||||
|
|
||||||
if (!prev || statusChanged(prev.status, status)) {
|
if (!prev || statusChanged(prev.status, status)) {
|
||||||
this.#log.info(
|
this.#log.info(
|
||||||
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} (lane ${entry.meta.lane}) -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
|
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
|
||||||
);
|
);
|
||||||
deviceEvents.emitPrinterStatus(event);
|
deviceEvents.emitPrinterStatus(event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { devices, eq, type Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
|
import type { ExitFlow } from "./exit-flow.js";
|
||||||
|
import type { PermitFlow } from "./permit-flow.js";
|
||||||
|
import { relayForDevice } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||||||
|
// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||||
|
// credential is (decision 2026-06-15):
|
||||||
|
// - matches a permit (card/QR/bound plate) → PERMIT flow,
|
||||||
|
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||||
|
//
|
||||||
|
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
|
||||||
|
// resolves to exactly the barrier it sits at, and the direction is inherited from
|
||||||
|
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
|
||||||
|
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||||
|
// reader the exit side; "both" defers to the flow's own inference (permit: session
|
||||||
|
// state; transient: exit).
|
||||||
|
|
||||||
|
export class ReadDispatcher {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #exit: ExitFlow;
|
||||||
|
readonly #permit: PermitFlow;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#exit = exit;
|
||||||
|
this.#permit = permit;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
const reader = this.#db.select().from(devices).where(eq(devices.id, e.deviceId)).get();
|
||||||
|
if (!reader || !reader.enabled) {
|
||||||
|
return { accepted: false, reason: "read from unknown/disabled device" };
|
||||||
|
}
|
||||||
|
const resolved = relayForDevice(this.#db, reader);
|
||||||
|
if (!resolved) {
|
||||||
|
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const permit = this.#permit.match(e);
|
||||||
|
if (permit) {
|
||||||
|
return this.#permit.run(resolved, e, permit);
|
||||||
|
}
|
||||||
|
// Not a permit → transient ticket exit. An ENTRY reader can't produce a transient
|
||||||
|
// exit (transient entry is the button flow, not a reader), so reject+log rather
|
||||||
|
// than treat an entry scan as an exit.
|
||||||
|
if (resolved.direction === "entry") {
|
||||||
|
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
||||||
|
}
|
||||||
|
return this.#exit.handleAt(resolved, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import bcrypt from "bcrypt";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, users, type Db } from "@parking/db";
|
import { eq, users, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
TOKEN_TTL,
|
|
||||||
clearAuthCookies,
|
clearAuthCookies,
|
||||||
newCsrfToken,
|
newCsrfToken,
|
||||||
requireRole,
|
requireRole,
|
||||||
@@ -17,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 ?? {};
|
||||||
@@ -34,12 +39,17 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const csrf = newCsrfToken();
|
const csrf = newCsrfToken();
|
||||||
const token = await reply.jwtSign(
|
// No expiresIn: the token is valid until explicit logout (see auth.ts).
|
||||||
{ sub: user.id, username: user.username, role: user.role, csrf },
|
const token = await reply.jwtSign({
|
||||||
{ expiresIn: TOKEN_TTL },
|
sub: user.id,
|
||||||
);
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
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) => {
|
||||||
@@ -47,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 };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { eq, laneDevices, type Db } from "@parking/db";
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
import { deviceEvents } from "../device-events.js";
|
import { deviceEvents } from "../device-events.js";
|
||||||
import { verifyDigest } from "../digest-auth.js";
|
import { verifyDigest } from "../digest-auth.js";
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
||||||
const { deviceId, n, edge } = req.params;
|
const { deviceId, n, edge } = req.params;
|
||||||
|
|
||||||
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
||||||
|
|
||||||
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { desc, events, type Db } from "@parking/db";
|
import { desc, 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";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export async function eventRoutes(
|
|||||||
{ 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(events).orderBy(desc(events.index)).limit(limit).all();
|
const rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all();
|
||||||
return { events: rows };
|
return { events: rows };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
import {
|
||||||
|
NoOpenSessionError,
|
||||||
|
NoTariffError,
|
||||||
|
type PayStation,
|
||||||
|
} from "../pay-station.js";
|
||||||
|
import type { ExitFlow } from "../exit-flow.js";
|
||||||
|
import { printExitVoucher } from "../booth-print.js";
|
||||||
|
|
||||||
|
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
|
||||||
|
// when the booth is at/near the exit — open the barrier. The payment becomes a
|
||||||
|
// signed ledger event; PCI scope stays OUT of the app (card capture is a standalone
|
||||||
|
// P2PE terminal; `tender` just records cash vs. card). The booth exit reuses the
|
||||||
|
// SAME validation as the reader path — no booth-only bypass admits an unpaid car.
|
||||||
|
// See wiki/concepts/tariff.md, parking-session.md, booth-exit-flow.md, bom.md.
|
||||||
|
|
||||||
|
interface QuoteQuery {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
interface PayBody {
|
||||||
|
identity: string;
|
||||||
|
tender: "cash" | "card";
|
||||||
|
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
|
||||||
|
overrideMinor?: number;
|
||||||
|
}
|
||||||
|
interface ExitBody {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
interface VoucherBody {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function payRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
payStation: PayStation,
|
||||||
|
exitFlow: ExitFlow,
|
||||||
|
): Promise<void> {
|
||||||
|
// Cashier/operator/admin operate the booth; readonly may not.
|
||||||
|
const guard = requireRole("admin", "operator", "cashier");
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
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 },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const operator = req.user?.username;
|
||||||
|
const res = await exitFlow.reopenBarrier(identity, operator);
|
||||||
|
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||||
|
return reply.code(200).send(res);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Quote: what does this session owe right now? (No side effect.)
|
||||||
|
app.get<{ Querystring: QuoteQuery }>(
|
||||||
|
"/api/pay/quote",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.query.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
try {
|
||||||
|
return payStation.quote(identity);
|
||||||
|
} catch (err) {
|
||||||
|
return mapError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pay: take payment and append the signed `payment` event.
|
||||||
|
app.post<{ Body: PayBody }>(
|
||||||
|
"/api/pay",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { identity, tender, overrideMinor } = req.body ?? {};
|
||||||
|
if (!identity || (tender !== "cash" && tender !== "card")) {
|
||||||
|
return reply.code(400).send({ error: "identity and tender (cash|card) required" });
|
||||||
|
}
|
||||||
|
if (overrideMinor != null && (!Number.isInteger(overrideMinor) || overrideMinor < 0)) {
|
||||||
|
return reply.code(400).send({ error: "overrideMinor must be a non-negative integer (minor units)" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await payStation.pay(identity, tender, overrideMinor);
|
||||||
|
return reply.code(201).send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return mapError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
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) {
|
||||||
|
if (err instanceof NoOpenSessionError) return reply.code(404).send({ error: err.message });
|
||||||
|
if (err instanceof NoTariffError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import type { DeviceReadEvent } from "../device-events.js";
|
||||||
|
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
|
||||||
|
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md.
|
||||||
|
//
|
||||||
|
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
|
||||||
|
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
||||||
|
// reply status: 1 = valid (beep 2×) / 0 = invalid (beep 1×)
|
||||||
|
// reply output: 0 = Access, 1 = WG26, 2 = WG34 (line driven on a valid read)
|
||||||
|
// reply time: UTC — syncs the device clock
|
||||||
|
//
|
||||||
|
// The "server language" set on the device only selects this URL path; we accept the
|
||||||
|
// SDK default path. No auth on the device side (it can't); the reader sits on the
|
||||||
|
// device subnet (network-isolation) and the signed ledger is the real guarantee.
|
||||||
|
|
||||||
|
interface ReaderQuery {
|
||||||
|
cardid?: string;
|
||||||
|
mjihao?: string; // device id
|
||||||
|
cjihao?: string; // device serial
|
||||||
|
status?: string; // 2 chars: high valid/invalid, low 1=in/0=out
|
||||||
|
time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function qrReaderRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
dispatcher: ReadDispatcher,
|
||||||
|
): Promise<void> {
|
||||||
|
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||||
|
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||||
|
// enters when assigning the gee-qr-reader. Returns the row id, or null if no
|
||||||
|
// reader is assigned for that serial. (Small device set → scan in JS.)
|
||||||
|
const readerRowIdForSerial = (serial: string): string | null => {
|
||||||
|
if (!serial) return null;
|
||||||
|
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
|
||||||
|
const match = rows.find((r) => r.enabled && (r.config as { serial?: string }).serial === serial);
|
||||||
|
return match?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// No auth: the reader is a machine on the isolated device subnet and offers no
|
||||||
|
// auth on its side. Public route, like the Dingtian input push.
|
||||||
|
const handler = async (req: { query: ReaderQuery }, reply: import("fastify").FastifyReply) => {
|
||||||
|
const q = req.query;
|
||||||
|
// The reader sends `Connection: keep-alive` but only ACTS on our verdict (beep,
|
||||||
|
// drive output) once the socket CLOSES — every vendor demo replies
|
||||||
|
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
||||||
|
// ~10 s keep-alive timeout before beeping. So force-close the connection.
|
||||||
|
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md.
|
||||||
|
reply.header("connection", "close");
|
||||||
|
const cardid = (q.cardid ?? "").trim();
|
||||||
|
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
||||||
|
const serial = (q.cjihao ?? "").trim();
|
||||||
|
|
||||||
|
// Map the reader's serial → its assigned lane_devices row id (the dispatcher
|
||||||
|
// resolves the lane from that row). If unassigned, deviceId stays the serial so
|
||||||
|
// the dispatcher simply finds no lane and rejects (status:0) — never crashes.
|
||||||
|
const deviceId = readerRowIdForSerial(serial) ?? serial;
|
||||||
|
|
||||||
|
let accepted = false;
|
||||||
|
if (cardid) {
|
||||||
|
const read: DeviceReadEvent = {
|
||||||
|
driverId: "gee-qr-reader",
|
||||||
|
deviceId,
|
||||||
|
value: cardid,
|
||||||
|
kind: "qr",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const outcome = await dispatcher.dispatch(read);
|
||||||
|
accepted = outcome.accepted;
|
||||||
|
if (!accepted) app.log.info(`QR ${cardid} rejected: ${outcome.reason ?? "?"}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
|
||||||
|
// output 0 = Access (drive the reader's access line on a valid read).
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
cardid,
|
||||||
|
cjihao: q.cjihao ?? 0,
|
||||||
|
mjihao,
|
||||||
|
status: accepted ? 1 : 0,
|
||||||
|
time: String(Math.floor(Date.now() / 1000)),
|
||||||
|
output: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
code: 0,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reader's "server language" setting (JSP/PHP/C#/ASP/CGI) selects the URL
|
||||||
|
// EXTENSION it GETs — verified on hardware: a JSP-configured unit posts
|
||||||
|
// /qa/mcardsea.jsp. Register every extension so the endpoint works whatever the
|
||||||
|
// device is set to; accept POST too in case a variant differs.
|
||||||
|
for (const ext of ["php", "jsp", "asp", "aspx", "cgi"]) {
|
||||||
|
const path = `/qa/mcardsea.${ext}`;
|
||||||
|
app.get<{ Querystring: ReaderQuery }>(path, handler);
|
||||||
|
app.post<{ Querystring: ReaderQuery }>(path, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
+193
-107
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
import { eq, devices, setupState, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
hasPreconditions,
|
hasPreconditions,
|
||||||
hasPushConfig,
|
hasPushConfig,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
|
type DeviceConfig,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||||
@@ -18,10 +19,12 @@ import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"
|
|||||||
// per lane. See wiki/concepts/first-run-setup.md.
|
// per lane. See wiki/concepts/first-run-setup.md.
|
||||||
|
|
||||||
interface AssignBody {
|
interface AssignBody {
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
// Driver config (opaque JSON, validated by the driver). Carries the model's
|
||||||
|
// direction/binding: access → config.relays=[{relay,direction,button?}];
|
||||||
|
// reader/camera → config.controllerId + config.relay. See entry-exit-points.md.
|
||||||
|
config: DeviceConfig;
|
||||||
/** Optional: the backend IP the device should push to (overrides auto-pick;
|
/** Optional: the backend IP the device should push to (overrides auto-pick;
|
||||||
* matters on multi-NIC hosts). */
|
* matters on multi-NIC hosts). */
|
||||||
backendIp?: string;
|
backendIp?: string;
|
||||||
@@ -48,13 +51,128 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupRoutes(
|
/** 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,
|
app: FastifyInstance,
|
||||||
db: Db,
|
args: {
|
||||||
// Called after the set of assignments changes (assign/unassign) so the caller
|
id: string;
|
||||||
// can refresh anything derived from it — e.g. the device id->lane map.
|
driverId: string;
|
||||||
onAssignmentsChanged: () => void = () => {},
|
config: DeviceConfig;
|
||||||
): Promise<void> {
|
backendIp?: string;
|
||||||
|
existingConfig?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<ConfigureOutcome> {
|
||||||
|
const { id, driverId, config, backendIp, existingConfig } = args;
|
||||||
|
|
||||||
|
// Start from any machine-only secrets already on the row (push/relay passwords
|
||||||
|
// are redacted out of the client's copy, so an edit would otherwise drop them),
|
||||||
|
// then layer the submitted config on top.
|
||||||
|
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
|
||||||
|
// The web password the admin typed is a DESIRED value, not a stored fact:
|
||||||
|
// it's passed to the driver (via create(config) below) as the rotation
|
||||||
|
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
|
||||||
|
// secrets.webPassword gets saved — otherwise a failed rotation would leave
|
||||||
|
// the DB claiming a password the device never accepted (login stays old).
|
||||||
|
delete fullConfig.webPassword;
|
||||||
|
// webPasswordCurrent is an input-only credential (the OLD password used to
|
||||||
|
// authorize the change) — never persist it as typed.
|
||||||
|
delete fullConfig.webPasswordCurrent;
|
||||||
|
// Residual-risk warnings from device hardening (shown to the admin; the
|
||||||
|
// save still succeeds — these are "configured, but note X" advisories).
|
||||||
|
const hardenWarnings: string[] = [];
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, config); // validates required fields
|
||||||
|
} catch (err) {
|
||||||
|
return { error: { code: 400, message: (err as Error).message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure the device on save (before persisting, so we don't store a row
|
||||||
|
// for a device we couldn't configure):
|
||||||
|
// 1. fix preconditions (e.g. disable input_link_relay so a button press
|
||||||
|
// doesn't auto-fire its relay — host must decide first),
|
||||||
|
// 2. harden (relay password + disable unused protocol channels), and
|
||||||
|
// 3. set up input push (Digest creds + push URLs).
|
||||||
|
// Each step is a device config write (the device reboots on apply).
|
||||||
|
try {
|
||||||
|
if (hasPreconditions(device)) {
|
||||||
|
const fixed = await device.fixPreconditions();
|
||||||
|
if (!fixed.ok) {
|
||||||
|
const unfixable = fixed.issues.find((i) => !i.fixable);
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: 502,
|
||||||
|
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHardenable(device)) {
|
||||||
|
const { secrets, warnings } = await device.harden();
|
||||||
|
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
||||||
|
// Surface residual-risk warnings (e.g. firmware that won't disable the
|
||||||
|
// password-less string protocol) so the admin can act (web-UI step).
|
||||||
|
for (const w of warnings ?? []) {
|
||||||
|
app.log.warn(`harden(${driverId} ${id}): ${w}`);
|
||||||
|
hardenWarnings.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPushConfig(device)) {
|
||||||
|
const host = String(config.host ?? "");
|
||||||
|
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
|
||||||
|
const pushHost = backendIp ?? backendIpForDevice(host);
|
||||||
|
if (!pushHost) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: 400,
|
||||||
|
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pushUser = "dingtian";
|
||||||
|
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
||||||
|
// (longer is silently truncated → auth mismatch), so keep it short.
|
||||||
|
const pushPassword = randomBytes(12).toString("hex");
|
||||||
|
await device.configureInputPush({
|
||||||
|
host: pushHost,
|
||||||
|
port: backendPort(),
|
||||||
|
pathBase: `/api/devices/${driverId}/${id}/input`,
|
||||||
|
auth: { user: pushUser, password: pushPassword },
|
||||||
|
});
|
||||||
|
fullConfig.pushUser = pushUser;
|
||||||
|
fullConfig.pushPassword = pushPassword;
|
||||||
|
// Record the backend IP the device was told to push to — lets us detect
|
||||||
|
// a later mismatch if the host's IP changes.
|
||||||
|
fullConfig.backendIp = pushHost;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: fullConfig, warnings: hardenWarnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
|
|
||||||
@@ -62,11 +180,13 @@ export async function setupRoutes(
|
|||||||
const adminGuard = requireRole("admin");
|
const adminGuard = requireRole("admin");
|
||||||
|
|
||||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||||
// `discoverable` flags drivers that can scan the LAN.
|
// `discoverable` flags drivers that can scan the LAN; `pushCapable` flags
|
||||||
|
// drivers that push to the backend (and thus need a backend IP at assign time).
|
||||||
app.get("/api/setup/catalog", async () => {
|
app.get("/api/setup/catalog", async () => {
|
||||||
const catalog = registry.catalog();
|
const catalog = registry.catalog();
|
||||||
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
||||||
return { ...catalog, discoverable };
|
const pushCapable = registry.pushCapable();
|
||||||
|
return { ...catalog, discoverable, pushCapable };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scan the LAN for devices a driver can discover (UDP broadcast, etc).
|
// Scan the LAN for devices a driver can discover (UDP broadcast, etc).
|
||||||
@@ -108,7 +228,7 @@ export async function setupRoutes(
|
|||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async () => {
|
async () => {
|
||||||
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
||||||
const rows = await db.select().from(laneDevices).all();
|
const rows = await db.select().from(devices).all();
|
||||||
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
|
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
|
||||||
return { completedAt: state?.completedAt ?? null, assignments };
|
return { completedAt: state?.completedAt ?? null, assignments };
|
||||||
},
|
},
|
||||||
@@ -152,117 +272,84 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assign a device to a lane. Validates the chosen driver + config, configures
|
// Assign a device. Validates the chosen driver + config, configures the device
|
||||||
// the device (fix preconditions + set up Digest-authenticated input push — no
|
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
||||||
// manual device-web-UI step by the admin), then persists. Fails the save if
|
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||||
// the device can't be configured. See wiki/concepts/device-input-flow.md.
|
// configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md.
|
||||||
app.post<{ Body: AssignBody }>(
|
app.post<{ Body: AssignBody }>(
|
||||||
"/api/setup/assign",
|
"/api/setup/assign",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { lane, category, driverId, config, backendIp } = req.body;
|
const { category, driverId, config, backendIp } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
if (!driver || driver.category !== category) {
|
if (!driver || driver.category !== category) {
|
||||||
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
lane,
|
|
||||||
category,
|
category,
|
||||||
driverId,
|
driverId,
|
||||||
config: fullConfig,
|
config: outcome.config,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
await db.insert(laneDevices).values(row);
|
await db.insert(devices).values(row);
|
||||||
onAssignmentsChanged(); // refresh derived state (device->lane map)
|
|
||||||
// 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 } : {}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -283,13 +370,12 @@ export async function setupRoutes(
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(laneDevices)
|
.from(devices)
|
||||||
.where(eq(laneDevices.id, req.params.id))
|
.where(eq(devices.id, req.params.id))
|
||||||
.get();
|
.get();
|
||||||
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
||||||
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id));
|
await db.delete(devices).where(eq(devices.id, req.params.id));
|
||||||
onAssignmentsChanged(); // refresh derived state (device->lane map)
|
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`);
|
||||||
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
|
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
import {
|
||||||
|
InvalidCashMovementError,
|
||||||
|
NoOpenShiftError,
|
||||||
|
ShiftAlreadyOpenError,
|
||||||
|
type ShiftService,
|
||||||
|
} from "../shift-service.js";
|
||||||
|
|
||||||
|
interface CashMovementBody {
|
||||||
|
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
|
||||||
|
amountMinor: number;
|
||||||
|
reason?: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||||
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
|
|
||||||
|
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||||
|
// Cashier/operator/admin run shifts; readonly can't.
|
||||||
|
const guard = requireRole("admin", "operator", "cashier");
|
||||||
|
|
||||||
|
// Is the current operator's shift open? (For the UI to show Start vs. End.)
|
||||||
|
// Also returns the live drawer balance so the UI can show what's in the till.
|
||||||
|
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
|
||||||
|
const operator = req.user.username;
|
||||||
|
const open = shift.openShiftFor(operator);
|
||||||
|
const drawer = shift.drawerBalance();
|
||||||
|
return {
|
||||||
|
operator,
|
||||||
|
open: open ? { startedAt: open.occurredAt } : null,
|
||||||
|
drawerMinor: drawer.balanceMinor,
|
||||||
|
currency: drawer.currency,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
|
||||||
|
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
|
||||||
|
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
|
||||||
|
app.post<{ Body: CashMovementBody }>(
|
||||||
|
"/api/cash-movement",
|
||||||
|
{ preHandler: requireRole("admin") },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody);
|
||||||
|
try {
|
||||||
|
return await shift.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await shift.open(req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await shift.close(req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
|
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
||||||
|
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
|
||||||
|
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|
||||||
|
// Optional park-metadata text fields (all nullable). Trimmed; "" → null.
|
||||||
|
const TEXT_FIELDS = [
|
||||||
|
"parkName",
|
||||||
|
"operatorName",
|
||||||
|
"nius",
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
] as const;
|
||||||
|
type TextField = (typeof TEXT_FIELDS)[number];
|
||||||
|
|
||||||
|
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||||
|
/** Nominal capacity; null = no limit. */
|
||||||
|
capacity?: number | null;
|
||||||
|
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||||||
|
exitVoucherDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape returned by GET/PUT: capacity + the booth flag + every metadata field. */
|
||||||
|
type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean } & Record<
|
||||||
|
TextField,
|
||||||
|
string | null
|
||||||
|
>;
|
||||||
|
|
||||||
|
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||||
|
const out = {
|
||||||
|
capacity: row?.capacity ?? null,
|
||||||
|
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||||
|
} 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> {
|
||||||
|
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
const writeGuard = requireRole("admin");
|
||||||
|
|
||||||
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
|
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||||
|
|
||||||
|
// Read site config (capacity + park metadata).
|
||||||
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return toSiteConfig(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
|
||||||
|
// (only the fields PRESENT in the body are updated; absent fields are untouched).
|
||||||
|
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
|
||||||
|
const body = req.body ?? ({} as SiteConfigBody);
|
||||||
|
|
||||||
|
const patch: Partial<typeof siteConfig.$inferInsert> = {};
|
||||||
|
if ("capacity" in body) {
|
||||||
|
const c = body.capacity;
|
||||||
|
if (c != null && (!Number.isInteger(c) || c < 0)) {
|
||||||
|
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
|
||||||
|
}
|
||||||
|
patch.capacity = c ?? null;
|
||||||
|
}
|
||||||
|
if ("exitVoucherDefault" in body) {
|
||||||
|
if (typeof body.exitVoucherDefault !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: "exitVoucherDefault must be a boolean" });
|
||||||
|
}
|
||||||
|
patch.exitVoucherDefault = body.exitVoucherDefault;
|
||||||
|
}
|
||||||
|
for (const f of TEXT_FIELDS) {
|
||||||
|
if (f in body) patch[f] = normText(body[f]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||||
|
}
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return toSiteConfig(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { desc, eq, snapshots, type Db } from "@parking/db";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
|
||||||
|
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
||||||
|
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
|
||||||
|
// tied to a signed vehicle_entry/exit by `identity`; the operator reviews them
|
||||||
|
// next to the event. Read-only — images are written only by the flows (snapshot.ts),
|
||||||
|
// never via the API.
|
||||||
|
|
||||||
|
export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
|
||||||
|
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
|
||||||
|
// first — lets the UI show "entry/exit image" links beside an event.
|
||||||
|
app.get<{ Params: { identity: string } }>(
|
||||||
|
"/api/snapshots/by-identity/:identity",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req) => {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
id: snapshots.id,
|
||||||
|
direction: snapshots.direction,
|
||||||
|
deviceId: snapshots.deviceId,
|
||||||
|
identity: snapshots.identity,
|
||||||
|
contentType: snapshots.contentType,
|
||||||
|
capturedAt: snapshots.capturedAt,
|
||||||
|
})
|
||||||
|
.from(snapshots)
|
||||||
|
.where(eq(snapshots.identity, req.params.identity))
|
||||||
|
.orderBy(desc(snapshots.capturedAt))
|
||||||
|
.all();
|
||||||
|
return { snapshots: rows };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stream one snapshot's image bytes by id. Returns the stored content type.
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
"/api/snapshots/:id",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
||||||
|
if (!row) return reply.code(404).send({ error: "no such snapshot" });
|
||||||
|
reply.header("content-type", row.contentType);
|
||||||
|
reply.header("cache-control", "private, max-age=31536000, immutable");
|
||||||
|
return reply.send(row.bytes);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { desc, eq, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
|
import { validateTariffStructure, type TariffStructure } from "@parking/shared";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
|
||||||
|
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
|
||||||
|
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
|
||||||
|
// mutates one; a session reprices against the version in force at its entry, and
|
||||||
|
// the `payment` event records the tariffVersionId. "One active tariff per site" for
|
||||||
|
// now (a single `tariffs` row, lazily created). See wiki/concepts/tariff.md.
|
||||||
|
|
||||||
|
interface PublishBody {
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||||
|
effectiveFrom?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITE_TARIFF_NAME = "Site tariff";
|
||||||
|
|
||||||
|
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
// Any signed-in role may READ the tariff (the pay station / operator UI needs it).
|
||||||
|
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
// Only an admin may PUBLISH a new version (it changes what customers are charged).
|
||||||
|
const writeGuard = requireRole("admin");
|
||||||
|
|
||||||
|
// The single site tariff row, created on first read/publish.
|
||||||
|
function ensureSiteTariff(): string {
|
||||||
|
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (existing) return existing.id;
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current state: the active (latest-effective, ≤ now) version + the full history.
|
||||||
|
app.get("/api/tariff", { preHandler: readGuard }, async () => {
|
||||||
|
const tariffId = ensureSiteTariff();
|
||||||
|
const versions = db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariffId))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const active = versions.find((v) => v.effectiveFrom <= now) ?? null;
|
||||||
|
return { tariffId, active, versions };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish a new immutable version. Validates the structure first — a malformed
|
||||||
|
// rate card can never be published (the fee calc + the chain depend on it).
|
||||||
|
app.post<{ Body: PublishBody }>(
|
||||||
|
"/api/tariff/versions",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
|
||||||
|
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||||
|
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||||
|
}
|
||||||
|
const problems = validateTariffStructure(structure);
|
||||||
|
if (problems.length) {
|
||||||
|
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||||
|
}
|
||||||
|
const tariffId = ensureSiteTariff();
|
||||||
|
const id = randomUUID();
|
||||||
|
const row = {
|
||||||
|
id,
|
||||||
|
tariffId,
|
||||||
|
effectiveFrom: effectiveFrom ?? new Date().toISOString(),
|
||||||
|
currency,
|
||||||
|
structure: structure as unknown as Record<string, unknown>,
|
||||||
|
createdBy: req.user?.username ?? null,
|
||||||
|
};
|
||||||
|
db.insert(tariffVersions).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { Role } from "@parking/shared";
|
||||||
|
import { deviceEvents } from "../device-events.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> }
|
||||||
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||||
|
| { kind: "printer-status"; event: unknown };
|
||||||
|
|
||||||
|
export async function wsRoutes(app: FastifyInstance, db: Db): 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.
|
||||||
|
send({ kind: "hello", occupancy: getOccupancy(db) });
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("close", () => {
|
||||||
|
offLedger();
|
||||||
|
offPrinter();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+110
-40
@@ -1,18 +1,33 @@
|
|||||||
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 { createDb, type Db } from "@parking/db";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { LaneMap } from "./lane-map.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { PayStation } from "./pay-station.js";
|
||||||
|
import { PermitFlow } from "./permit-flow.js";
|
||||||
|
import { ShiftService } from "./shift-service.js";
|
||||||
|
import { ReadDispatcher } from "./read-dispatch.js";
|
||||||
import { PrinterMonitor } from "./printer-monitor.js";
|
import { PrinterMonitor } from "./printer-monitor.js";
|
||||||
import { buildSigner } from "./signer.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 { permitRoutes } from "./routes/permits.js";
|
||||||
|
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||||
|
import { shiftRoutes } from "./routes/shift.js";
|
||||||
|
import { siteRoutes } from "./routes/site.js";
|
||||||
|
import { snapshotRoutes } from "./routes/snapshots.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 { 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
|
||||||
@@ -31,6 +46,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
|
||||||
@@ -38,7 +57,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
secret: requireJwtSecret(),
|
secret: requireJwtSecret(),
|
||||||
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire
|
// No expiry: a login is valid until explicit logout — a shift is a separate
|
||||||
|
// boundary, not the token lifetime (see auth.ts + wiki/concepts/shift.md).
|
||||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,15 +67,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||||
await authRoutes(app, db);
|
await authRoutes(app, db);
|
||||||
|
|
||||||
// device id -> lane resolver. Built from lane_devices at startup and refreshed
|
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||||
// by setupRoutes on assign/unassign, so device events can be stamped with the
|
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||||
// lane the device belongs to (events carry the device id, not a lane).
|
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||||
const laneMap = new LaneMap(db);
|
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||||
laneMap.refresh();
|
await setupRoutes(app, db);
|
||||||
|
|
||||||
// Device-agnostic setup: the admin selects devices per lane from the driver
|
|
||||||
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
|
||||||
await setupRoutes(app, db, () => laneMap.refresh());
|
|
||||||
|
|
||||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
@@ -70,39 +86,93 @@ 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());
|
||||||
|
|
||||||
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button
|
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||||
// presses) into the hash-chained, signed `events` table — the anti-fraud audit
|
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||||
// trail. The device is NOT trusted; the host record is the source of truth, and
|
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||||
// a relay open with no matching signed event is itself the anomaly. We record
|
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||||
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that
|
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||||
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.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);
|
||||||
|
|
||||||
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
|
await snapshotRoutes(app, db);
|
||||||
|
|
||||||
|
// Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen.
|
||||||
|
// Subscribes to the SAME input bus as the telemetry writer below; the two are
|
||||||
|
// independent (telemetry always records; the entry flow acts only on an access
|
||||||
|
// device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md.
|
||||||
|
const entryFlow = new EntryFlow(db, eventLog, app.log);
|
||||||
|
const unsubscribeEntry = deviceEvents.onInput((e) => {
|
||||||
|
void entryFlow.onInput(e);
|
||||||
|
});
|
||||||
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
|
||||||
|
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||||
|
// dispatcher to either the PERMIT flow (if it matches a permit) or the transient
|
||||||
|
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md.
|
||||||
|
const exitFlow = new ExitFlow(db, eventLog, app.log);
|
||||||
|
const permitFlow = new PermitFlow(db, eventLog, app.log);
|
||||||
|
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log);
|
||||||
|
const unsubscribeRead = deviceEvents.onRead((e) => {
|
||||||
|
void readDispatcher.dispatch(e);
|
||||||
|
});
|
||||||
|
app.addHook("onClose", async () => unsubscribeRead());
|
||||||
|
|
||||||
|
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||||
|
// verdict (host-in-the-loop, synchronous). Routes the read through the dispatcher
|
||||||
|
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||||
|
await qrReaderRoutes(app, db, readDispatcher);
|
||||||
|
|
||||||
|
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||||
|
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
||||||
|
const payStation = new PayStation(db, eventLog, app.log);
|
||||||
|
await payRoutes(app, db, payStation, exitFlow);
|
||||||
|
|
||||||
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||||
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
|
await tariffRoutes(app, db);
|
||||||
|
|
||||||
|
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||||
|
await permitRoutes(app, db);
|
||||||
|
|
||||||
|
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||||
|
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
|
||||||
|
const shiftService = new ShiftService(db, eventLog, app.log);
|
||||||
|
await shiftRoutes(app, shiftService);
|
||||||
|
|
||||||
|
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||||
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
await siteRoutes(app, db);
|
||||||
|
|
||||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
// Record every input edge as unsigned telemetry, keyed to the device that fired
|
||||||
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
|
||||||
// faithfully (the chain is append-only) rather than silently dropped or
|
// (above) independently decides whether this edge is an entry button.
|
||||||
// mis-stamped as lane 0, which is a real lane.
|
try {
|
||||||
const lane = laneMap.laneFor(e.deviceId) ?? -1;
|
db.insert(deviceEventsTable)
|
||||||
if (lane === -1) {
|
.values({
|
||||||
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
|
id: randomUUID(),
|
||||||
}
|
deviceId: e.deviceId,
|
||||||
eventLog
|
category: "access",
|
||||||
.append({
|
kind: "input",
|
||||||
type: "input_received",
|
detail: { driverId: e.driverId, input: e.input, edge: e.edge },
|
||||||
lane,
|
|
||||||
// `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a
|
|
||||||
// VEHICLE was identified. A raw input has none, so it stays null. The
|
|
||||||
// device provenance lives in `identity` instead.
|
|
||||||
source: null,
|
|
||||||
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`,
|
|
||||||
occurredAt: e.at,
|
occurredAt: e.at,
|
||||||
})
|
})
|
||||||
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`));
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(`device-event insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeInput());
|
app.addHook("onClose", async () => unsubscribeInput());
|
||||||
|
|
||||||
// TODO: entry flow (input event → signed event → print → relay).
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { registry, type PrinterDevice } from "@parking/devices";
|
||||||
|
import type { LedgerPayload } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Shift service (manned mode only). A shift is an operator's accountability period,
|
||||||
|
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
|
||||||
|
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
|
||||||
|
// `payment` events taken during the shift by tender and print a Z-report.
|
||||||
|
// See wiki/concepts/shift.md.
|
||||||
|
|
||||||
|
export class ShiftAlreadyOpenError extends Error {
|
||||||
|
constructor(operator: string) {
|
||||||
|
super(`operator ${operator} already has an open shift`);
|
||||||
|
this.name = "ShiftAlreadyOpenError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class NoOpenShiftError extends Error {
|
||||||
|
constructor(operator: string) {
|
||||||
|
super(`operator ${operator} has no open shift`);
|
||||||
|
this.name = "NoOpenShiftError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftReport {
|
||||||
|
readonly operator: string;
|
||||||
|
readonly startedAt: string;
|
||||||
|
readonly endedAt: string;
|
||||||
|
readonly cashTotalMinor: number;
|
||||||
|
readonly cardTotalMinor: number;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly paymentCount: number;
|
||||||
|
// --- Drawer (physical cash till; carries across shifts) ---
|
||||||
|
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||||
|
readonly openingFloatMinor: number;
|
||||||
|
/** Admin cash LOADED into the drawer during the shift (sum of + movements). */
|
||||||
|
readonly cashAddedMinor: number;
|
||||||
|
/** Admin cash REMOVED from the drawer during the shift (sum of − movements, as +). */
|
||||||
|
readonly cashRemovedMinor: number;
|
||||||
|
/** Expected drawer at close = opening + cashTaken + added − removed. Carries forward. */
|
||||||
|
readonly expectedDrawerMinor: number;
|
||||||
|
readonly printed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidCashMovementError extends Error {
|
||||||
|
constructor(msg: string) {
|
||||||
|
super(msg);
|
||||||
|
this.name = "InvalidCashMovementError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShiftService {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current physical drawer balance (cash payments + cash_movements, by time). For
|
||||||
|
* the UI to show "inherited / in the drawer now". */
|
||||||
|
drawerBalance(): { balanceMinor: number; currency: string | null } {
|
||||||
|
return this.#drawerBalanceAt(new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
||||||
|
openShiftFor(operator: string) {
|
||||||
|
// Scan shift events for this operator; the shift is open if the most recent
|
||||||
|
// shift event for them is a `shift_open` (not yet closed by a z_report).
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, operator))
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }> {
|
||||||
|
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator);
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||||||
|
await this.#log.append({
|
||||||
|
type: "shift_open",
|
||||||
|
source: "manual",
|
||||||
|
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||||||
|
// Record the inherited opening float on the shift_open so it's reproducible
|
||||||
|
// and the next operator's handover figure is fixed in the chain.
|
||||||
|
payload: { operator, openingFloatMinor },
|
||||||
|
occurredAt: startedAt,
|
||||||
|
});
|
||||||
|
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||||
|
return { startedAt, openingFloatMinor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
||||||
|
async close(operator: string): Promise<ShiftReport> {
|
||||||
|
const open = this.openShiftFor(operator);
|
||||||
|
if (!open) throw new NoOpenShiftError(operator);
|
||||||
|
const startedAt = open.occurredAt;
|
||||||
|
const endedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// All payments taken in [startedAt, endedAt], summed by tender. Payment time =
|
||||||
|
// the operator who handled the money (decision: sum by payment time).
|
||||||
|
const payments = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "payment"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||||||
|
|
||||||
|
let cashTotalMinor = 0;
|
||||||
|
let cardTotalMinor = 0;
|
||||||
|
let currency: string | null = null;
|
||||||
|
for (const p of payments) {
|
||||||
|
const pl = (p.payload ?? {}) as LedgerPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (pl.tender === "card") cardTotalMinor += amt;
|
||||||
|
else cashTotalMinor += amt;
|
||||||
|
if (pl.currency) currency = pl.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drawer figures ---
|
||||||
|
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||||||
|
// fall back to a fresh fold if an older shift_open lacks it.
|
||||||
|
const openPl = (open.payload ?? {}) as LedgerPayload & { openingFloatMinor?: number };
|
||||||
|
const openingFloatMinor =
|
||||||
|
typeof openPl.openingFloatMinor === "number"
|
||||||
|
? openPl.openingFloatMinor
|
||||||
|
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
||||||
|
|
||||||
|
// Cash movements within the shift window, split into added (+) and removed (−).
|
||||||
|
const movements = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "cash_movement"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||||||
|
let cashAddedMinor = 0;
|
||||||
|
let cashRemovedMinor = 0;
|
||||||
|
for (const m of movements) {
|
||||||
|
const pl = (m.payload ?? {}) as LedgerPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (amt >= 0) cashAddedMinor += amt;
|
||||||
|
else cashRemovedMinor += -amt; // store as a positive magnitude
|
||||||
|
if (pl.currency) currency = pl.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected drawer at close = opening + cash taken + added − removed. This is the
|
||||||
|
// figure the NEXT shift inherits as its opening float.
|
||||||
|
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||||
|
|
||||||
|
const report: Omit<ShiftReport, "printed"> = {
|
||||||
|
operator,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
cashTotalMinor,
|
||||||
|
cardTotalMinor,
|
||||||
|
currency,
|
||||||
|
paymentCount: payments.length,
|
||||||
|
openingFloatMinor,
|
||||||
|
cashAddedMinor,
|
||||||
|
cashRemovedMinor,
|
||||||
|
expectedDrawerMinor,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "shift_z_report",
|
||||||
|
source: "manual",
|
||||||
|
identity: operator,
|
||||||
|
payload: {
|
||||||
|
operator,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
cashTotalMinor,
|
||||||
|
cardTotalMinor,
|
||||||
|
currency: currency ?? undefined,
|
||||||
|
paymentCount: payments.length,
|
||||||
|
openingFloatMinor,
|
||||||
|
cashAddedMinor,
|
||||||
|
cashRemovedMinor,
|
||||||
|
expectedDrawerMinor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const printed = await this.#printZReport(report);
|
||||||
|
|
||||||
|
this.#logger.info(
|
||||||
|
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` +
|
||||||
|
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||||||
|
);
|
||||||
|
return { ...report, printed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
||||||
|
* is the record — a failed print doesn't undo the close). */
|
||||||
|
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
|
||||||
|
const printer = await this.#boothPrinter();
|
||||||
|
if (!printer) {
|
||||||
|
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cur = r.currency ?? "";
|
||||||
|
const money = (m: number) => (m / 100).toFixed(2);
|
||||||
|
const lines = [
|
||||||
|
`Operator: ${r.operator}`,
|
||||||
|
`From: ${r.startedAt}`,
|
||||||
|
`To: ${r.endedAt}`,
|
||||||
|
"",
|
||||||
|
`Payments: ${r.paymentCount}`,
|
||||||
|
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
|
`Card: ${money(r.cardTotalMinor)} ${cur}`,
|
||||||
|
"",
|
||||||
|
"-- Drawer --",
|
||||||
|
`Opening float: ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
|
`Cash taken: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
|
`Cash added: ${money(r.cashAddedMinor)} ${cur}`,
|
||||||
|
`Cash removed: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||||
|
`Expected drawer: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First enabled booth-receipt printer, or any enabled printer. */
|
||||||
|
async #boothPrinter(): Promise<PrinterDevice | null> {
|
||||||
|
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||||
|
const enabled = rows.filter((r) => r.enabled);
|
||||||
|
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
|
||||||
|
if (!booth) return null;
|
||||||
|
const driver = registry.get(booth.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(booth.config as never) as PrinterDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ export class SoftwareSigner implements Signer {
|
|||||||
readonly keyId: string;
|
readonly keyId: string;
|
||||||
readonly #key: Buffer;
|
readonly #key: Buffer;
|
||||||
|
|
||||||
constructor(secret: string, keyId = "sw-hmac-v1") {
|
// v2 canonical form: `lane` dropped from the signed array (pool-of-spaces model,
|
||||||
|
// 2026-06-16). v1 events used a different field order and won't verify under v2 —
|
||||||
|
// that's intentional and gated by the per-event keyId. See event-log canonicalize().
|
||||||
|
constructor(secret: string, keyId = "sw-hmac-v2") {
|
||||||
this.#key = Buffer.from(secret, "utf8");
|
this.#key = Buffer.from(secret, "utf8");
|
||||||
this.keyId = keyId;
|
this.keyId = keyId;
|
||||||
}
|
}
|
||||||
@@ -55,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||||
|
import { registry, type CameraDevice } from "@parking/devices";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
||||||
|
// open path (decision 2026-06-16): a snapshot is EVIDENCE, not a gate. A camera
|
||||||
|
// failure must never delay or prevent an open — the signed ledger is the decision,
|
||||||
|
// the image is an independent, prunable record stored as a BLOB in `snapshots`.
|
||||||
|
// See wiki/concepts/entry-exit-points.md and append-only-event-chain.md.
|
||||||
|
//
|
||||||
|
// Every camera serving the firing direction (entry/exit, or both) snapshots. Each
|
||||||
|
// capture is independent — one camera down doesn't stop the others. A captured image
|
||||||
|
// → a `snapshots` row + a `kind:"snapshot"` telemetry device_event; a failure → a
|
||||||
|
// telemetry device_event only. The caller passes the session `identity` so the image
|
||||||
|
// links to the signed vehicle_entry/exit.
|
||||||
|
|
||||||
|
interface SnapshotJob {
|
||||||
|
readonly db: Db;
|
||||||
|
readonly direction: FlowDirection;
|
||||||
|
/** Session/credential ref (ticket id, plate, permit car key) — links to the ledger. */
|
||||||
|
readonly identity: string;
|
||||||
|
readonly logger: FastifyBaseLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire snapshots for the directional camera set. Returns immediately with a promise
|
||||||
|
* the caller MAY ignore (fire-and-forget) — it resolves to the captured snapshot ids.
|
||||||
|
* The caller must NOT block its open path on this.
|
||||||
|
*/
|
||||||
|
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||||
|
const { db, direction, identity, logger } = job;
|
||||||
|
const rows = devicesByDirection(db, "camera", direction);
|
||||||
|
if (rows.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
rows.map(async (row): Promise<string | null> => {
|
||||||
|
const camera = buildCamera(row);
|
||||||
|
if (!camera) {
|
||||||
|
recordFailure(db, direction, row.id, identity, "camera config won't build", logger);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
|
const id: string = randomUUID();
|
||||||
|
db.insert(snapshots)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
direction,
|
||||||
|
deviceId: row.id,
|
||||||
|
identity,
|
||||||
|
contentType: shot.contentType,
|
||||||
|
bytes: shot.bytes,
|
||||||
|
capturedAt: shot.capturedAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
// Telemetry breadcrumb pointing at the stored image (NOT the bytes).
|
||||||
|
recordEvent(db, direction, row.id, identity, { snapshotId: id, ok: true }, logger);
|
||||||
|
return id;
|
||||||
|
} catch (err) {
|
||||||
|
recordFailure(db, direction, row.id, identity, (err as Error).message, logger);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((ids) => ids.filter((id): id is string => id != null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a live camera adapter from a resolved devices row, or null. */
|
||||||
|
function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(row.config as never) as CameraDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailure(
|
||||||
|
db: Db,
|
||||||
|
direction: FlowDirection,
|
||||||
|
deviceId: string,
|
||||||
|
identity: string,
|
||||||
|
error: string,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): void {
|
||||||
|
logger.warn(`snapshot failed (${direction}, ${identity}): ${error}`);
|
||||||
|
recordEvent(db, direction, deviceId, identity, { ok: false, error }, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordEvent(
|
||||||
|
db: Db,
|
||||||
|
direction: FlowDirection,
|
||||||
|
deviceId: string,
|
||||||
|
identity: string,
|
||||||
|
detail: Record<string, unknown>,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
db.insert(deviceEventsTable)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId,
|
||||||
|
category: "camera",
|
||||||
|
kind: "snapshot",
|
||||||
|
detail: { ...detail, direction, identity },
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
// Telemetry is best-effort; never let it surface on the (already-open) path.
|
||||||
|
logger.error(`snapshot device-event insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,123 @@
|
|||||||
|
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 { 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.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();
|
||||||
|
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.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 sessions only (no payment, no button). */}
|
||||||
|
{s.paidAt ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={reopen.isPending}
|
||||||
|
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={t("booth.openBarrierTitle")}
|
||||||
|
>
|
||||||
|
{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
-29
@@ -1,11 +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 { SetupWizard } from "./SetupWizard.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
|
import { router } from "./router.js";
|
||||||
|
|
||||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
// 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() {
|
||||||
@@ -18,31 +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>
|
|
||||||
{user.role === "admin" ? (
|
|
||||||
<SetupWizard />
|
|
||||||
) : (
|
|
||||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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,
|
||||||
|
paySession,
|
||||||
|
printVoucher,
|
||||||
|
type SessionLookup,
|
||||||
|
} from "./api.js";
|
||||||
|
import { qk } from "./lib/query.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 });
|
||||||
|
|
||||||
|
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 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 canPay = s?.found && s.open && !alreadyPaid;
|
||||||
|
|
||||||
|
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">
|
||||||
|
{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">
|
||||||
|
{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={alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
||||||
|
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<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">{t("pay.total")}</span>
|
||||||
|
<span className="text-3xl font-bold text-term-cyan">
|
||||||
|
{s.amountMinor != null && s.currency
|
||||||
|
? formatMoney(s.amountMinor, s.currency)
|
||||||
|
: alreadyPaid
|
||||||
|
? t("booth.badgePaid")
|
||||||
|
: t("pay.noTariff")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Snapshots */}
|
||||||
|
<SnapshotStrip identity={identity} />
|
||||||
|
|
||||||
|
{phase !== "done" && (
|
||||||
|
<>
|
||||||
|
{/* 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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePayAndExit}
|
||||||
|
disabled={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,190 @@
|
|||||||
|
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 { 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();
|
||||||
|
// Initial load via Query (also the fallback if the WS is briefly down).
|
||||||
|
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
||||||
|
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) });
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const seen = new Set(liveFeed.map((e) => e.id));
|
||||||
|
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||||
|
const events = [...liveFeed, ...history].slice(0, 200);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{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>
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
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(", "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const STATUS_KEY: Record<Permit["status"], string> = {
|
||||||
|
active: "permits.statusActive",
|
||||||
|
suspended: "permits.statusSuspended",
|
||||||
|
revoked: "permits.statusRevoked",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 { t } = useTranslation();
|
||||||
|
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: t("permits.permitSaved") });
|
||||||
|
} 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(t("permits.confirmRevoke", { name: p.holderName ?? p.id }))) return;
|
||||||
|
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
async function doDelete(p: Permit) {
|
||||||
|
if (!confirm(t("permits.confirmDelete", { name: p.holderName ?? p.id }))) 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>{t("permits.title")}</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 ?? t("permits.unnamed")}</strong>
|
||||||
|
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[p.status])}</span>
|
||||||
|
<span style={{ color: "#666" }}>
|
||||||
|
{p.maxConcurrent == null ? t("permits.unbound") : t("permits.car", { count: p.maxConcurrent })} ·{" "}
|
||||||
|
{p.credentials.length} {t("permits.cred")} · {t("permits.plates", { count: p.plates.length })}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button type="button" onClick={() => startEdit(p)}>{t("permits.edit")}</button>
|
||||||
|
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>{t("permits.revoke")}</button>}
|
||||||
|
<button type="button" onClick={() => doDelete(p)}>{t("permits.delete")}</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{permits.length === 0 && <li style={{ color: "#777" }}>{t("permits.noPermitsYet")}</li>}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{editing == null ? (
|
||||||
|
<button type="button" onClick={startNew}>{t("permits.addPermit")}</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("permits.newPermit") : t("permits.editPermit")}</h3>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
||||||
|
<label>{t("permits.holderName")}</label>
|
||||||
|
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||||
|
<label>{t("permits.contact")}</label>
|
||||||
|
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||||
|
<label>{t("permits.carLimit")}</label>
|
||||||
|
<span>
|
||||||
|
<label style={{ marginRight: "0.5rem" }}>
|
||||||
|
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("permits.limitCarsInAtOnce")}
|
||||||
|
</label>
|
||||||
|
{form.carBound && (
|
||||||
|
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<label>{t("permits.validFrom")}</label>
|
||||||
|
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
|
||||||
|
<label>{t("permits.validTo")}</label>
|
||||||
|
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
|
||||||
|
<label>{t("permits.boundPlates")}</label>
|
||||||
|
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("permits.commaSeparatedOptional")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style={{ marginBottom: "0.25rem" }}>{t("permits.credentialsCardQr")}</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">{t("permits.rfCardTag")}</option>
|
||||||
|
<option value="qr">{t("permits.qr")}</option>
|
||||||
|
</select>
|
||||||
|
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("permits.credentialValue")} 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: "" }] }))}>{t("permits.addCredential")}</button>
|
||||||
|
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||||
|
{t("permits.needCredentialOrPlate")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={save}>{t("permits.save")}</button>
|
||||||
|
<button type="button" onClick={() => setEditing(null)}>{t("permits.cancel")}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+389
-78
@@ -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,
|
||||||
@@ -12,28 +13,41 @@ import {
|
|||||||
type Catalog,
|
type Catalog,
|
||||||
type CatalogEntry,
|
type CatalogEntry,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
|
type DeviceConfig,
|
||||||
|
type Direction,
|
||||||
type DiscoveredDevice,
|
type DiscoveredDevice,
|
||||||
|
type RelaySpec,
|
||||||
type TestResult,
|
type TestResult,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
// First-run setup wizard (scaffold). The admin assigns devices per lane from the
|
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
|
||||||
// driver catalog. The data model is multi-instance — one lane_devices row per
|
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
|
||||||
// instance — so EVERY category supports more than one device: each section lists
|
// declares its relays = entry/exit/both + which input terminal the entry button is
|
||||||
// the already-assigned instances (with Remove) and an "Add" form. Drivers that
|
// on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at).
|
||||||
// support LAN discovery get a "Scan" button. Auth is via the admin's session
|
// Direction is a property of the relay, inherited by bound devices. The data model
|
||||||
// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md.
|
// is multi-instance — one `devices` row per instance. See entry-exit-points.md.
|
||||||
|
|
||||||
const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [
|
const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = {
|
||||||
{ key: "access", title: "Access controllers", noun: "access controller" },
|
key: "access",
|
||||||
{ key: "reader", title: "Readers", noun: "reader" },
|
title: "Controllers (barriers + entry button)",
|
||||||
{ key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" },
|
noun: "controller",
|
||||||
{ key: "printer", title: "Printers", noun: "printer" },
|
};
|
||||||
|
// Categories that BIND to a controller relay (direction inherited from the relay).
|
||||||
|
const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [
|
||||||
|
{ key: "reader", title: "Readers (QR / RFID)", noun: "reader" },
|
||||||
|
{ key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" },
|
||||||
|
{ key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DIRECTION_LABELS: Record<Direction, string> = {
|
||||||
|
entry: "Entry",
|
||||||
|
exit: "Exit",
|
||||||
|
both: "Both (entry + exit)",
|
||||||
|
};
|
||||||
|
|
||||||
export function SetupWizard() {
|
export function SetupWizard() {
|
||||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||||
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
||||||
const [lane, setLane] = useState(1);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const reloadState = useCallback(() => {
|
const reloadState = useCallback(() => {
|
||||||
@@ -50,35 +64,41 @@ export function SetupWizard() {
|
|||||||
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
||||||
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
||||||
|
|
||||||
|
// Controllers are needed before binding readers/cameras (they pick a controller relay).
|
||||||
|
const controllers = assignments.filter((a) => a.category === "access");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2>First-run setup</h2>
|
<h2>First-run setup</h2>
|
||||||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
<p style={{ color: "#666", fontSize: "0.9em" }}>
|
||||||
<label>
|
Add your barrier controllers first — set which relay is entry/exit and which
|
||||||
Lane{" "}
|
terminal the entry button is wired to. Then add readers, cameras and printers
|
||||||
<input
|
and point each at the barrier it serves.
|
||||||
type="number"
|
</p>
|
||||||
min={1}
|
|
||||||
value={lane}
|
|
||||||
onChange={(e) => setLane(Number(e.target.value))}
|
|
||||||
style={{ width: "4rem" }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<span style={{ color: "#666", fontSize: "0.85em" }}>
|
|
||||||
Devices are added per lane. Switch lanes to configure another.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{CATEGORIES.map(({ key, title, noun }) => (
|
<CategorySection
|
||||||
|
category={CONTROLLER.key}
|
||||||
|
title={CONTROLLER.title}
|
||||||
|
noun={CONTROLLER.noun}
|
||||||
|
entries={catalog[CONTROLLER.key]}
|
||||||
|
discoverableIds={catalog.discoverable}
|
||||||
|
pushCapableIds={catalog.pushCapable}
|
||||||
|
controllers={controllers}
|
||||||
|
assignments={controllers}
|
||||||
|
onChanged={reloadState}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{BOUND.map(({ key, title, noun }) => (
|
||||||
<CategorySection
|
<CategorySection
|
||||||
key={key}
|
key={key}
|
||||||
lane={lane}
|
|
||||||
category={key}
|
category={key}
|
||||||
title={title}
|
title={title}
|
||||||
noun={noun}
|
noun={noun}
|
||||||
entries={catalog[key]}
|
entries={catalog[key]}
|
||||||
discoverableIds={catalog.discoverable}
|
discoverableIds={catalog.discoverable}
|
||||||
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
|
pushCapableIds={catalog.pushCapable}
|
||||||
|
controllers={controllers}
|
||||||
|
assignments={assignments.filter((a) => a.category === key)}
|
||||||
onChanged={reloadState}
|
onChanged={reloadState}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -87,37 +107,41 @@ export function SetupWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CategorySection({
|
function CategorySection({
|
||||||
lane,
|
|
||||||
category,
|
category,
|
||||||
title,
|
title,
|
||||||
noun,
|
noun,
|
||||||
entries,
|
entries,
|
||||||
discoverableIds,
|
discoverableIds,
|
||||||
|
pushCapableIds,
|
||||||
|
controllers,
|
||||||
assignments,
|
assignments,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
title: string;
|
title: string;
|
||||||
noun: string;
|
noun: string;
|
||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
|
pushCapableIds: string[];
|
||||||
|
controllers: Assignment[];
|
||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
onChanged: () => Promise<void> | void;
|
onChanged: () => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
// Show the add-form automatically when nothing is assigned yet; otherwise it's
|
|
||||||
// collapsed behind "Add another" so the list stays the focus.
|
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
// Warnings from the most recent save (e.g. "string protocol could not be
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
// disabled — finish in the device web UI"). Persist after the form closes.
|
|
||||||
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.
|
||||||
|
const isBound = category !== "access";
|
||||||
|
const blockedNoController = isBound && controllers.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset style={{ marginTop: "1rem" }}>
|
<fieldset style={{ marginTop: "1rem" }}>
|
||||||
<legend>
|
<legend>{title}</legend>
|
||||||
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
|
|
||||||
</legend>
|
|
||||||
|
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -143,18 +167,49 @@ 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} onChanged={onChanged} />
|
editingId === a.id ? (
|
||||||
))}
|
<li key={a.id} style={{ listStyle: "none", padding: 0 }}>
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showForm ? (
|
|
||||||
<DeviceForm
|
<DeviceForm
|
||||||
lane={lane}
|
|
||||||
category={category}
|
category={category}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
discoverableIds={discoverableIds}
|
discoverableIds={discoverableIds}
|
||||||
|
pushCapableIds={pushCapableIds}
|
||||||
|
controllers={controllers}
|
||||||
|
editing={a}
|
||||||
|
onSaved={async (w) => {
|
||||||
|
setWarnings(w);
|
||||||
|
await onChanged();
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<AssignmentRow
|
||||||
|
key={a.id}
|
||||||
|
assignment={a}
|
||||||
|
controllers={controllers}
|
||||||
|
onChanged={onChanged}
|
||||||
|
onEdit={() => {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingId(a.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blockedNoController ? (
|
||||||
|
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
||||||
|
) : editing ? null : showForm ? (
|
||||||
|
<DeviceForm
|
||||||
|
category={category}
|
||||||
|
entries={entries}
|
||||||
|
discoverableIds={discoverableIds}
|
||||||
|
pushCapableIds={pushCapableIds}
|
||||||
|
controllers={controllers}
|
||||||
onSaved={async (w) => {
|
onSaved={async (w) => {
|
||||||
setWarnings(w);
|
setWarnings(w);
|
||||||
await onChanged();
|
await onChanged();
|
||||||
@@ -173,17 +228,19 @@ function CategorySection({
|
|||||||
|
|
||||||
function AssignmentRow({
|
function AssignmentRow({
|
||||||
assignment,
|
assignment,
|
||||||
|
controllers,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
assignment: Assignment;
|
assignment: 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);
|
||||||
|
|
||||||
// A short, human summary of the instance: role (if any) + host.
|
const cfg = assignment.config as Record<string, unknown>;
|
||||||
const cfg = assignment.config;
|
|
||||||
const role = typeof cfg.role === "string" ? cfg.role : null;
|
|
||||||
const host = typeof cfg.host === "string" ? cfg.host : null;
|
const host = typeof cfg.host === "string" ? cfg.host : null;
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
@@ -210,11 +267,14 @@ function AssignmentRow({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>{assignment.driverId}</strong>
|
<strong>{assignment.driverId}</strong>
|
||||||
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
|
|
||||||
{host && <span style={{ color: "#666" }}>{host}</span>}
|
{host && <span style={{ color: "#666" }}>{host}</span>}
|
||||||
|
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||||
{!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>
|
||||||
@@ -222,27 +282,88 @@ function AssignmentRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inline summary of an assignment's direction/binding for the list. */
|
||||||
|
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
||||||
|
const cfg = assignment.config as Record<string, unknown>;
|
||||||
|
if (assignment.category === "access") {
|
||||||
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||||
|
if (relays.length === 0) return <em style={{ color: "#b45309" }}>no relays set</em>;
|
||||||
|
return (
|
||||||
|
<span style={{ display: "flex", gap: "0.35rem" }}>
|
||||||
|
{relays.map((r) => (
|
||||||
|
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Bound device: show controller + relay it points at, with inherited direction.
|
||||||
|
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
||||||
|
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
||||||
|
if (!controllerId || relay == null) return <em style={{ color: "#b45309" }}>unbound</em>;
|
||||||
|
const controller = controllers.find((c) => c.id === controllerId);
|
||||||
|
const spec = controller
|
||||||
|
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<DirectionBadge
|
||||||
|
direction={spec?.direction ?? "both"}
|
||||||
|
label={`${controller ? controller.driverId : "?"} · R${relay}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DeviceForm({
|
function DeviceForm({
|
||||||
lane,
|
|
||||||
category,
|
category,
|
||||||
entries,
|
entries,
|
||||||
discoverableIds,
|
discoverableIds,
|
||||||
|
pushCapableIds,
|
||||||
|
controllers,
|
||||||
|
editing,
|
||||||
onSaved,
|
onSaved,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
|
pushCapableIds: string[];
|
||||||
|
controllers: Assignment[];
|
||||||
|
/** When set, the form edits this assignment in place (driver locked, config
|
||||||
|
* pre-filled) instead of adding a new device. */
|
||||||
|
editing?: Assignment;
|
||||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
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 isController = category === "access";
|
||||||
|
|
||||||
|
// Pre-fill scalar config fields from the existing assignment when editing.
|
||||||
|
// (relays/controllerId/relay are model fields handled by their own state below.)
|
||||||
|
const [config, setConfig] = useState<Record<string, string | number>>(() => {
|
||||||
|
if (!editCfg) return {};
|
||||||
|
const out: Record<string, string | number> = {};
|
||||||
|
for (const [k, v] of Object.entries(editCfg)) {
|
||||||
|
if (typeof v === "string" || typeof v === "number") out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
||||||
|
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
||||||
|
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
||||||
|
);
|
||||||
|
// Bound devices: which controller + relay this device sits at.
|
||||||
|
const [controllerId, setControllerId] = useState<string>(
|
||||||
|
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
||||||
|
);
|
||||||
|
const [boundRelay, setBoundRelay] = useState<number | "">(
|
||||||
|
typeof editCfg?.relay === "number" ? editCfg.relay : "",
|
||||||
|
);
|
||||||
|
|
||||||
// Config values (auto-filled by discovery, editable by hand).
|
|
||||||
const [config, setConfig] = useState<Record<string, string | number>>({});
|
|
||||||
const [tested, setTested] = useState<TestResult | null>(null);
|
const [tested, setTested] = useState<TestResult | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
@@ -252,18 +373,12 @@ function DeviceForm({
|
|||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [scanError, setScanError] = useState<string | null>(null);
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Backend push IP: which of OUR addresses the device should call back on. We
|
|
||||||
// auto-pick the NIC on the device's subnet, but surface it editable here so a
|
|
||||||
// multi-NIC host can be corrected (the chosen IP is baked into the device on
|
|
||||||
// save). Only relevant for drivers that push (the field hides if no candidates).
|
|
||||||
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||||
const [backendIp, setBackendIp] = useState<string>("");
|
const [backendIp, setBackendIp] = useState<string>("");
|
||||||
|
|
||||||
// (Re)load backend-IP candidates whenever the device host changes after a
|
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
||||||
// successful test (the test confirms the host is real + reachable).
|
|
||||||
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!testedHost) {
|
if (!testedHost || !pushesToBackend) {
|
||||||
setBackendIps(null);
|
setBackendIps(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -281,7 +396,7 @@ function DeviceForm({
|
|||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [testedHost]);
|
}, [testedHost, pushesToBackend]);
|
||||||
|
|
||||||
function selectDriver(id: string) {
|
function selectDriver(id: string) {
|
||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
@@ -308,8 +423,8 @@ function DeviceForm({
|
|||||||
resetStatus();
|
resetStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config the user actually entered, merged over driver defaults.
|
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
|
||||||
function mergedConfig(): Record<string, string | number> {
|
function mergedScalarConfig(): Record<string, string | number> {
|
||||||
const out: Record<string, string | number> = {};
|
const out: Record<string, string | number> = {};
|
||||||
for (const f of selected?.configFields ?? []) {
|
for (const f of selected?.configFields ?? []) {
|
||||||
const v = config[f.key] ?? (f.default as string | number | undefined);
|
const v = config[f.key] ?? (f.default as string | number | undefined);
|
||||||
@@ -318,7 +433,22 @@ function DeviceForm({
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editing config invalidates a prior test.
|
/** Full config to persist: scalars + the model's direction/binding fields. */
|
||||||
|
function mergedConfig(): DeviceConfig {
|
||||||
|
const out: DeviceConfig = { ...mergedScalarConfig() };
|
||||||
|
if (isController) {
|
||||||
|
out.relays = relays.map((r) => ({
|
||||||
|
relay: r.relay,
|
||||||
|
direction: r.direction,
|
||||||
|
...(r.button ? { button: r.button } : {}),
|
||||||
|
}));
|
||||||
|
} else if (controllerId && boundRelay !== "") {
|
||||||
|
out.controllerId = controllerId;
|
||||||
|
out.relay = boundRelay;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function resetStatus() {
|
function resetStatus() {
|
||||||
setTested(null);
|
setTested(null);
|
||||||
setTestError(null);
|
setTestError(null);
|
||||||
@@ -331,7 +461,7 @@ function DeviceForm({
|
|||||||
setTestError(null);
|
setTestError(null);
|
||||||
setTested(null);
|
setTested(null);
|
||||||
try {
|
try {
|
||||||
setTested(await testDevice(selected.id, mergedConfig()));
|
setTested(await testDevice(selected.id, mergedScalarConfig()));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setTestError((e as Error).message);
|
setTestError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -341,17 +471,26 @@ function DeviceForm({
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
// Bound devices must point at a controller relay (binding is optional in the
|
||||||
|
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
||||||
|
if (!isController && (!controllerId || boundRelay === "")) {
|
||||||
|
setSaveError("Pick the controller and relay this device sits at.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
const result = await assignDevice({
|
const result = editing
|
||||||
lane,
|
? await editDevice(editing.id, {
|
||||||
|
config: mergedConfig(),
|
||||||
|
...(backendIp ? { backendIp } : {}),
|
||||||
|
})
|
||||||
|
: await assignDevice({
|
||||||
category,
|
category,
|
||||||
driverId: selected.id,
|
driverId: selected.id,
|
||||||
config: mergedConfig(),
|
config: mergedConfig(),
|
||||||
...(backendIp ? { backendIp } : {}),
|
...(backendIp ? { backendIp } : {}),
|
||||||
});
|
});
|
||||||
// Hand warnings to the parent so they persist after this form unmounts.
|
|
||||||
await onSaved(result.warnings ?? []);
|
await onSaved(result.warnings ?? []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSaveError((e as Error).message);
|
setSaveError((e as Error).message);
|
||||||
@@ -365,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>
|
||||||
@@ -441,13 +582,30 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
||||||
|
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
||||||
|
|
||||||
|
{/* BOUND device: which controller + relay it sits at. */}
|
||||||
|
{!isController && (
|
||||||
|
<BindingPicker
|
||||||
|
controllers={controllers}
|
||||||
|
controllerId={controllerId}
|
||||||
|
relay={boundRelay}
|
||||||
|
onControllerChange={(id) => {
|
||||||
|
setControllerId(id);
|
||||||
|
setBoundRelay("");
|
||||||
|
}}
|
||||||
|
onRelayChange={setBoundRelay}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Test (no save/no device change) then Save (configures + persists). */}
|
{/* Test (no save/no device change) then Save (configures + persists). */}
|
||||||
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||||
<button type="button" onClick={test} disabled={testing}>
|
<button type="button" onClick={test} disabled={testing}>
|
||||||
{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}>
|
||||||
@@ -476,8 +634,6 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Backend push IP — only for push-capable devices (candidates present).
|
|
||||||
Pre-filled with the auto-pick; editable for multi-NIC hosts. */}
|
|
||||||
{backendIps && backendIps.length > 0 && (
|
{backendIps && backendIps.length > 0 && (
|
||||||
<div style={{ margin: "0.5rem 0 0" }}>
|
<div style={{ margin: "0.5rem 0 0" }}>
|
||||||
<label>
|
<label>
|
||||||
@@ -512,6 +668,161 @@ function DeviceForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
||||||
|
* the input terminal its entry button is wired to. */
|
||||||
|
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
||||||
|
function update(i: number, patch: Partial<RelaySpec>) {
|
||||||
|
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||||
|
}
|
||||||
|
function add() {
|
||||||
|
const nextRelay = (relays.reduce((m, r) => Math.max(m, r.relay), 0) || 0) + 1;
|
||||||
|
onChange([...relays, { relay: nextRelay, direction: "both" }]);
|
||||||
|
}
|
||||||
|
function remove(i: number) {
|
||||||
|
onChange(relays.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||||
|
<strong style={{ fontSize: "0.9em" }}>Relays on this controller</strong>
|
||||||
|
<p style={{ margin: "0.15rem 0 0.5rem", color: "#666", fontSize: "0.8em" }}>
|
||||||
|
Each relay opens one barrier. Set its direction; for transient entry, set which input
|
||||||
|
terminal the entry button is wired to.
|
||||||
|
</p>
|
||||||
|
{relays.map((r, i) => (
|
||||||
|
<div key={i} style={{ display: "flex", gap: "0.5rem", alignItems: "center", margin: "0.25rem 0" }}>
|
||||||
|
<label>
|
||||||
|
Relay{" "}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={r.relay}
|
||||||
|
style={{ width: "3.5rem" }}
|
||||||
|
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<select value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
||||||
|
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{DIRECTION_LABELS[d]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(r.direction === "entry" || r.direction === "both") && (
|
||||||
|
<label>
|
||||||
|
Entry button on terminal{" "}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={r.button ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
style={{ width: "3.5rem" }}
|
||||||
|
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{relays.length > 1 && (
|
||||||
|
<button type="button" onClick={() => remove(i)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={add} style={{ marginTop: "0.25rem" }}>
|
||||||
|
+ Add relay
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binding picker for readers/cameras/printers: choose the controller + relay this
|
||||||
|
* device sits at. Direction is inherited from the chosen relay (shown). */
|
||||||
|
function BindingPicker({
|
||||||
|
controllers,
|
||||||
|
controllerId,
|
||||||
|
relay,
|
||||||
|
onControllerChange,
|
||||||
|
onRelayChange,
|
||||||
|
}: {
|
||||||
|
controllers: Assignment[];
|
||||||
|
controllerId: string;
|
||||||
|
relay: number | "";
|
||||||
|
onControllerChange: (id: string) => void;
|
||||||
|
onRelayChange: (relay: number) => void;
|
||||||
|
}) {
|
||||||
|
const controller = controllers.find((c) => c.id === controllerId);
|
||||||
|
const relays: RelaySpec[] = controller
|
||||||
|
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
|
||||||
|
: [];
|
||||||
|
const chosen = relays.find((r) => r.relay === relay);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||||
|
<strong style={{ fontSize: "0.9em" }}>Which barrier does this device serve?</strong>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.35rem", flexWrap: "wrap" }}>
|
||||||
|
<label>
|
||||||
|
Controller{" "}
|
||||||
|
<select value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||||
|
<option value="" disabled>
|
||||||
|
Choose…
|
||||||
|
</option>
|
||||||
|
{controllers.map((c) => {
|
||||||
|
const host = (c.config as Record<string, unknown>).host;
|
||||||
|
return (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.driverId}
|
||||||
|
{typeof host === "string" ? ` (${host})` : ""}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Relay{" "}
|
||||||
|
<select
|
||||||
|
value={relay === "" ? "" : String(relay)}
|
||||||
|
disabled={!controller}
|
||||||
|
onChange={(e) => onRelayChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Choose…
|
||||||
|
</option>
|
||||||
|
{relays.map((r) => (
|
||||||
|
<option key={r.relay} value={r.relay}>
|
||||||
|
Relay {r.relay} ({DIRECTION_LABELS[r.direction]})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{chosen && <DirectionBadge direction={chosen.direction} label={`inherits ${chosen.direction}`} />}
|
||||||
|
</div>
|
||||||
|
{controller && relays.length === 0 && (
|
||||||
|
<p style={{ margin: "0.35rem 0 0", color: "#b45309", fontSize: "0.85em" }}>
|
||||||
|
This controller has no relays configured.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
||||||
|
const color = direction === "entry" ? "#15803d" : direction === "exit" ? "#b45309" : "#6b7280";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "0 0.35rem",
|
||||||
|
fontSize: "0.75em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label ?? direction}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HealthBadge({ status }: { status: string }) {
|
function HealthBadge({ status }: { status: string }) {
|
||||||
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
||||||
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
|
||||||
|
|
||||||
|
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
||||||
|
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
||||||
|
// totals + the DRAWER picture (opening float carried from the prior shift, cash
|
||||||
|
// taken/added/removed, expected drawer). Admins can load/remove drawer cash.
|
||||||
|
// Available to cashier/operator/admin (readonly has no shift).
|
||||||
|
|
||||||
|
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
||||||
|
|
||||||
|
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [startedAt, setStartedAt] = useState<string | null>(null);
|
||||||
|
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
|
||||||
|
const [currency, setCurrency] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Cash-movement form (admin only).
|
||||||
|
const [moveAmount, setMoveAmount] = useState("");
|
||||||
|
const [moveReason, setMoveReason] = useState("");
|
||||||
|
const [moveMsg, setMoveMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
fetchShift()
|
||||||
|
.then((s) => {
|
||||||
|
setStartedAt(s.open?.startedAt ?? null);
|
||||||
|
setDrawerMinor(s.drawerMinor);
|
||||||
|
setCurrency(s.currency);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* readonly / not permitted — hide control */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
useEffect(refresh, []);
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setReport(null);
|
||||||
|
try {
|
||||||
|
const { startedAt } = await openShift();
|
||||||
|
setStartedAt(startedAt);
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function end() {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const z = await closeShift();
|
||||||
|
setReport(z);
|
||||||
|
setStartedAt(null);
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(sign: 1 | -1) {
|
||||||
|
setMoveMsg(null);
|
||||||
|
const major = Number(moveAmount);
|
||||||
|
if (!Number.isFinite(major) || major <= 0) {
|
||||||
|
setMoveMsg(t("shift.enterPositive"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
|
||||||
|
setMoveAmount("");
|
||||||
|
setMoveReason("");
|
||||||
|
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setMoveMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||||
|
<strong>{t("shift.label")}</strong>{" "}
|
||||||
|
{startedAt ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "#16a34a" }}>{t("shift.open")}</span> {t("shift.since")}{" "}
|
||||||
|
{new Date(startedAt).toLocaleString()}{" "}
|
||||||
|
<button type="button" onClick={end} disabled={busy}>
|
||||||
|
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
|
||||||
|
<button type="button" onClick={start} disabled={busy}>
|
||||||
|
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Live drawer balance (what's in the till right now / inherited). */}
|
||||||
|
{drawerMinor != null && (
|
||||||
|
<div style={{ marginTop: "0.5rem", color: "#555" }}>
|
||||||
|
{t("shift.drawer")} <strong>{money(drawerMinor, currency)}</strong>
|
||||||
|
{startedAt && <span style={{ color: "#888" }}> {t("shift.openingFloatInherited")}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
|
||||||
|
|
||||||
|
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
|
||||||
|
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
|
||||||
|
{t("shift.drawerCashAdmin")}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<input
|
||||||
|
value={moveAmount}
|
||||||
|
onChange={(e) => setMoveAmount(e.target.value)}
|
||||||
|
placeholder={t("shift.amount")}
|
||||||
|
inputMode="decimal"
|
||||||
|
style={{ width: 90 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={moveReason}
|
||||||
|
onChange={(e) => setMoveReason(e.target.value)}
|
||||||
|
placeholder={t("shift.reasonPlaceholder")}
|
||||||
|
style={{ flex: 1, minWidth: 140 }}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => move(1)}>{t("shift.load")}</button>
|
||||||
|
<button type="button" onClick={() => move(-1)}>{t("shift.remove")}</button>
|
||||||
|
</div>
|
||||||
|
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
|
||||||
|
<div>{t("shift.payments")} {report.paymentCount}</div>
|
||||||
|
<div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||||
|
<div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
|
||||||
|
<div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
|
||||||
|
<div>{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
|
||||||
|
<div>{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||||
|
<div>{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
|
||||||
|
<div>{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>
|
||||||
|
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
|
||||||
|
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
||||||
|
|
||||||
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||||
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||||
|
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
|
||||||
|
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
|
||||||
|
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
|
||||||
|
|
||||||
|
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
|
||||||
|
// (resolved at render); only `address` is multiline.
|
||||||
|
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
|
||||||
|
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
|
||||||
|
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
|
||||||
|
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
|
||||||
|
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
|
||||||
|
{ key: "phone", labelKey: "site.fieldPhone" },
|
||||||
|
{ key: "email", labelKey: "site.fieldEmail" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [occ, setOcc] = useState<Occupancy | null>(null);
|
||||||
|
const [capInput, setCapInput] = useState("");
|
||||||
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||||
|
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
fetchOccupancy().then(setOcc).catch(() => {});
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
fetchSiteConfig()
|
||||||
|
.then((c) => {
|
||||||
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
|
setExitVoucherDefault(c.exitVoucherDefault);
|
||||||
|
const m: Record<string, string> = {};
|
||||||
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
||||||
|
setMeta(m);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setMsg(null);
|
||||||
|
const raw = capInput.trim();
|
||||||
|
const patch: Partial<SiteConfig> = {
|
||||||
|
capacity: raw === "" ? null : Math.round(Number(raw)),
|
||||||
|
exitVoucherDefault,
|
||||||
|
};
|
||||||
|
// Send each metadata field; "" → null is applied server-side.
|
||||||
|
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
||||||
|
try {
|
||||||
|
await saveSiteConfig(patch);
|
||||||
|
reload();
|
||||||
|
setMsg(t("site.saved"));
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||||
|
<strong>{t("site.occupancy")}</strong>{" "}
|
||||||
|
{occ == null ? (
|
||||||
|
"…"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ fontWeight: 600 }}>{occ.count}</span>
|
||||||
|
{occ.capacity != null ? ` / ${occ.capacity}` : ` ${t("site.noCapacitySet")}`}
|
||||||
|
{occ.capacity != null && (
|
||||||
|
<span style={{ color: "#666" }}> · {occ.free} {t("site.free")}</span>
|
||||||
|
)}
|
||||||
|
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>{t("site.full")}</span>}{" "}
|
||||||
|
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
|
||||||
|
<label>
|
||||||
|
{t("site.capacityLabel")}{" "}
|
||||||
|
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exitVoucherDefault}
|
||||||
|
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("site.printExitDefault")}
|
||||||
|
<span style={{ color: "#888", fontSize: "0.8rem" }}>{t("site.printExitHint")}</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
|
||||||
|
{t("site.parkDetails")}
|
||||||
|
</div>
|
||||||
|
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||||
|
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
|
||||||
|
{t(labelKey)}
|
||||||
|
{multiline ? (
|
||||||
|
<textarea
|
||||||
|
value={meta[key] ?? ""}
|
||||||
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||||
|
rows={2}
|
||||||
|
placeholder={phKey ? t(phKey) : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
value={meta[key] ?? ""}
|
||||||
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||||
|
placeholder={phKey ? t(phKey) : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={save}>{t("site.save")}</button>
|
||||||
|
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchTariff,
|
||||||
|
publishTariffVersion,
|
||||||
|
type TariffBlock,
|
||||||
|
type TariffStructure,
|
||||||
|
type TariffState,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
|
||||||
|
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||||
|
// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
|
||||||
|
// 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.
|
||||||
|
interface BlockForm {
|
||||||
|
uptoMin: string; // "" = open-ended (last block)
|
||||||
|
price: string; // major units, e.g. "2.00"
|
||||||
|
}
|
||||||
|
interface FormState {
|
||||||
|
currency: string;
|
||||||
|
gracePeriodEntryMin: string;
|
||||||
|
incrementMin: string;
|
||||||
|
dailyCap: string; // "" = no cap
|
||||||
|
lostTicket: string;
|
||||||
|
gracePeriodExitMin: string;
|
||||||
|
blocks: BlockForm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||||
|
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||||
|
|
||||||
|
function emptyForm(): FormState {
|
||||||
|
return {
|
||||||
|
currency: "EUR",
|
||||||
|
gracePeriodEntryMin: "15",
|
||||||
|
incrementMin: "60",
|
||||||
|
dailyCap: "",
|
||||||
|
lostTicket: "20.00",
|
||||||
|
gracePeriodExitMin: "15",
|
||||||
|
blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromActive(s: TariffState): FormState {
|
||||||
|
const v = s.active;
|
||||||
|
if (!v) return emptyForm();
|
||||||
|
const st = v.structure;
|
||||||
|
return {
|
||||||
|
currency: v.currency,
|
||||||
|
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||||
|
incrementMin: String(st.incrementMin),
|
||||||
|
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
|
||||||
|
lostTicket: toMajor(st.lostTicketMinor),
|
||||||
|
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||||
|
blocks: st.blocks.map((b) => ({
|
||||||
|
uptoMin: b.uptoMin == null ? "" : String(b.uptoMin),
|
||||||
|
price: toMajor(b.priceMinorPerIncrement),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStructure(f: FormState): TariffStructure {
|
||||||
|
const blocks: TariffBlock[] = f.blocks.map((b) => ({
|
||||||
|
uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)),
|
||||||
|
priceMinorPerIncrement: toMinor(b.price),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||||
|
incrementMin: Math.round(Number(f.incrementMin)),
|
||||||
|
blocks,
|
||||||
|
dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap),
|
||||||
|
lostTicketMinor: toMinor(f.lostTicket),
|
||||||
|
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||||
|
overstay: "reprice",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TariffComposer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTariff()
|
||||||
|
.then((s) => {
|
||||||
|
setState(s);
|
||||||
|
setForm(formFromActive(s));
|
||||||
|
})
|
||||||
|
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||||
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
}
|
||||||
|
function setBlock(i: number, patch: Partial<BlockForm>) {
|
||||||
|
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||||
|
}
|
||||||
|
function addBlock() {
|
||||||
|
setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] }));
|
||||||
|
}
|
||||||
|
function removeBlock(i: number) {
|
||||||
|
setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish() {
|
||||||
|
setSaving(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
||||||
|
const fresh = await fetchTariff();
|
||||||
|
setState(fresh);
|
||||||
|
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||||
|
} catch (e) {
|
||||||
|
const text =
|
||||||
|
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
||||||
|
? `${e.message}: ${((e as ApiError & { problems?: string[] }).problems ?? []).join("; ")}`
|
||||||
|
: (e as Error).message;
|
||||||
|
setMsg({ kind: "err", text });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: "2rem" }}>
|
||||||
|
<h2>{t("tariff.title")}</h2>
|
||||||
|
{!state?.active ? (
|
||||||
|
<p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: "#555" }}>
|
||||||
|
{t("tariff.activeSince", {
|
||||||
|
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||||
|
count: state.versions.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
|
||||||
|
<label>{t("tariff.currency")}</label>
|
||||||
|
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
|
||||||
|
<label>{t("tariff.freeEntryGrace")}</label>
|
||||||
|
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||||
|
<label>{t("tariff.billingIncrement")}</label>
|
||||||
|
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||||
|
<label>{t("tariff.dailyCap")}</label>
|
||||||
|
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||||
|
<label>{t("tariff.lostTicketFee")}</label>
|
||||||
|
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||||
|
<label>{t("tariff.exitGrace")}</label>
|
||||||
|
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
|
||||||
|
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
|
||||||
|
<table style={{ borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||||
|
<th style={{ padding: "0 0.5rem" }}>{t("tariff.upToMin")}</th>
|
||||||
|
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{form.blocks.map((b, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||||
|
<input
|
||||||
|
value={b.uptoMin}
|
||||||
|
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
|
||||||
|
placeholder={i === form.blocks.length - 1 ? t("tariff.thereafter") : t("tariff.egExample")}
|
||||||
|
style={{ width: 110 }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||||
|
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
|
||||||
|
{t("tariff.remove")}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||||
|
{t("tariff.addBlock")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<button type="button" onClick={publish} disabled={saving}>
|
||||||
|
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{msg && (
|
||||||
|
<p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson", marginTop: "0.5rem" }}>{msg.text}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+312
-3
@@ -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 {
|
||||||
@@ -96,6 +104,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
|||||||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||||||
/** Driver ids that support LAN discovery. */
|
/** Driver ids that support LAN discovery. */
|
||||||
discoverable: string[];
|
discoverable: string[];
|
||||||
|
/** Driver ids that push to the backend (need a backend IP at assign time). */
|
||||||
|
pushCapable: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchCatalog(): Promise<Catalog> {
|
export function fetchCatalog(): Promise<Catalog> {
|
||||||
@@ -118,7 +128,26 @@ export async function discoverDevices(driverId: string): Promise<DiscoveredDevic
|
|||||||
return body.devices;
|
return body.devices;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeviceConfig = Record<string, string | number | boolean>;
|
export type ConfigValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| ConfigValue[]
|
||||||
|
| { [k: string]: ConfigValue };
|
||||||
|
export type DeviceConfig = Record<string, ConfigValue>;
|
||||||
|
|
||||||
|
/** Direction a barrier/relay (or a device bound to it) serves. */
|
||||||
|
export type Direction = "entry" | "exit" | "both";
|
||||||
|
|
||||||
|
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||||
|
* and (optionally) the input terminal its entry button is wired to. */
|
||||||
|
export interface RelaySpec {
|
||||||
|
relay: number;
|
||||||
|
direction: Direction;
|
||||||
|
/** Input terminal of the entry button that fires this relay (transient entry). */
|
||||||
|
button?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TestResult {
|
export interface TestResult {
|
||||||
health: { status: string; detail?: string };
|
health: { status: string; detail?: string };
|
||||||
@@ -151,9 +180,10 @@ export function fetchBackendIps(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignBody {
|
export interface AssignBody {
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
|
// Direction/binding lives in config: access → config.relays=[{relay,direction,button?}];
|
||||||
|
// reader/camera → config.controllerId + config.relay.
|
||||||
config: DeviceConfig;
|
config: DeviceConfig;
|
||||||
/** Backend IP the device should push to (overrides auto-pick). */
|
/** Backend IP the device should push to (overrides auto-pick). */
|
||||||
backendIp?: string;
|
backendIp?: string;
|
||||||
@@ -164,10 +194,18 @@ 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;
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: DeviceConfig;
|
config: DeviceConfig;
|
||||||
@@ -195,3 +233,274 @@ export function fetchState(): Promise<SetupState> {
|
|||||||
export function unassignDevice(id: string): Promise<void> {
|
export function unassignDevice(id: string): Promise<void> {
|
||||||
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tariff composer ------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TariffBlock {
|
||||||
|
uptoMin: number | null;
|
||||||
|
priceMinorPerIncrement: number;
|
||||||
|
}
|
||||||
|
export interface TariffStructure {
|
||||||
|
gracePeriodEntryMin: number;
|
||||||
|
incrementMin: number;
|
||||||
|
blocks: TariffBlock[];
|
||||||
|
dailyCapMinor: number | null;
|
||||||
|
lostTicketMinor: number;
|
||||||
|
gracePeriodExitMin: number;
|
||||||
|
overstay: "reprice";
|
||||||
|
}
|
||||||
|
export interface TariffVersion {
|
||||||
|
id: string;
|
||||||
|
tariffId: string;
|
||||||
|
effectiveFrom: string;
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
createdBy?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
export interface TariffState {
|
||||||
|
tariffId: string;
|
||||||
|
active: TariffVersion | null;
|
||||||
|
versions: TariffVersion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTariff(): Promise<TariffState> {
|
||||||
|
return apiFetch<TariffState>("/api/tariff");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish a new immutable tariff version (becomes the active rate card). */
|
||||||
|
export function publishTariffVersion(body: {
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
effectiveFrom?: string;
|
||||||
|
}): Promise<TariffVersion> {
|
||||||
|
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permits --------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PermitCredential {
|
||||||
|
kind: "rf" | "qr";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
export interface Permit {
|
||||||
|
id: string;
|
||||||
|
holderName: string | null;
|
||||||
|
contact: string | null;
|
||||||
|
maxConcurrent: number | null;
|
||||||
|
validFrom: string | null;
|
||||||
|
validTo: string | null;
|
||||||
|
status: "active" | "suspended" | "revoked";
|
||||||
|
credentials: PermitCredential[];
|
||||||
|
plates: string[];
|
||||||
|
}
|
||||||
|
export type PermitInput = Omit<Permit, "id" | "status"> & {
|
||||||
|
status?: Permit["status"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchPermits(): Promise<{ permits: Permit[] }> {
|
||||||
|
return apiFetch("/api/permits");
|
||||||
|
}
|
||||||
|
export function createPermit(body: PermitInput): Promise<Permit> {
|
||||||
|
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
|
||||||
|
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function revokePermit(id: string): Promise<Permit> {
|
||||||
|
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" });
|
||||||
|
}
|
||||||
|
export function deletePermit(id: string): Promise<void> {
|
||||||
|
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shifts ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ShiftStatus {
|
||||||
|
operator: string;
|
||||||
|
open: { startedAt: string } | null;
|
||||||
|
/** Live physical drawer balance (cash payments + cash movements). */
|
||||||
|
drawerMinor: number;
|
||||||
|
currency: string | null;
|
||||||
|
}
|
||||||
|
export interface ShiftReport {
|
||||||
|
operator: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string;
|
||||||
|
cashTotalMinor: number;
|
||||||
|
cardTotalMinor: number;
|
||||||
|
currency: string | null;
|
||||||
|
paymentCount: number;
|
||||||
|
// Drawer (carries across shifts).
|
||||||
|
openingFloatMinor: number;
|
||||||
|
cashAddedMinor: number;
|
||||||
|
cashRemovedMinor: number;
|
||||||
|
expectedDrawerMinor: number;
|
||||||
|
printed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchShift(): Promise<ShiftStatus> {
|
||||||
|
return apiFetch("/api/shift/current");
|
||||||
|
}
|
||||||
|
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||||
|
return apiFetch("/api/shift/open", { method: "POST" });
|
||||||
|
}
|
||||||
|
export function closeShift(): Promise<ShiftReport> {
|
||||||
|
return apiFetch("/api/shift/close", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
|
||||||
|
export function recordCashMovement(
|
||||||
|
amountMinor: number,
|
||||||
|
reason: string,
|
||||||
|
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||||||
|
return apiFetch("/api/cash-movement", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ amountMinor, reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Site config / occupancy ----------------------------------------------
|
||||||
|
|
||||||
|
export interface Occupancy {
|
||||||
|
count: number;
|
||||||
|
capacity: number | null;
|
||||||
|
free: number | null;
|
||||||
|
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;
|
||||||
|
parkName: string | null;
|
||||||
|
operatorName: string | null;
|
||||||
|
/** NIUS — Albanian tax/identification number. */
|
||||||
|
nius: string | null;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchOccupancy(): Promise<Occupancy> {
|
||||||
|
return apiFetch("/api/occupancy");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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. */
|
||||||
|
export function fetchEvents(limit = 100): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||||
|
return apiFetch(`/api/events?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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");
|
||||||
|
}
|
||||||
|
/** PUT a partial config — only the fields supplied are changed. */
|
||||||
|
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
|
||||||
|
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
|
||||||
|
}
|
||||||
|
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||||||
|
return saveSiteConfig({ capacity });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Bloomberg-terminal aesthetic: dense, dark, monospace, keyboard-first.
|
||||||
|
Tailwind v4 — design tokens live here in @theme (no tailwind.config.js).
|
||||||
|
The booth runs on a fixed appliance display; we optimise for a dark room,
|
||||||
|
glanceable status colour, and high information density over whitespace. */
|
||||||
|
@theme {
|
||||||
|
/* Surfaces — near-black, layered greys for panels/borders. */
|
||||||
|
--color-term-bg: #0a0e12;
|
||||||
|
--color-term-panel: #11161c;
|
||||||
|
--color-term-panel-2: #161d25;
|
||||||
|
--color-term-border: #232c37;
|
||||||
|
--color-term-muted: #6b7785;
|
||||||
|
--color-term-text: #c9d3de;
|
||||||
|
|
||||||
|
/* Status accents — the terminal's signal colours. */
|
||||||
|
--color-term-amber: #f5a623; /* primary accent / headings / focus */
|
||||||
|
--color-term-green: #2ecc71; /* entry / ok / free */
|
||||||
|
--color-term-red: #ff4d4f; /* exit / fault / full */
|
||||||
|
--color-term-cyan: #38bdf8; /* payment / info */
|
||||||
|
|
||||||
|
/* Monospace stack — IBM Plex Mono / JetBrains first, system mono fallback. */
|
||||||
|
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
|
||||||
|
"Menlo", "Consolas", monospace;
|
||||||
|
|
||||||
|
/* Tight radius — terminals are square. */
|
||||||
|
--radius-term: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-term-bg);
|
||||||
|
color: var(--color-term-text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
/* Crisp text and no rubber-banding on the fixed appliance display. */
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal scrollbars — thin, dark, unobtrusive. */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-term-border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A visible keyboard-focus ring in the amber accent (keyboard-first UI). */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 1px solid var(--color-term-amber);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Small formatting helpers for the booth. Money is integer MINOR units (never a
|
||||||
|
// float — matches the tariff/ledger model); duration is whole minutes.
|
||||||
|
|
||||||
|
/** Format integer minor units + ISO-4217 currency as a major-unit string. */
|
||||||
|
export function formatMoney(amountMinor: number, currency: string): string {
|
||||||
|
const major = amountMinor / 100;
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(major);
|
||||||
|
} catch {
|
||||||
|
// Unknown/garbled currency code — fall back to a plain number + the code.
|
||||||
|
return `${major.toFixed(2)} ${currency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human duration between two ISO times, e.g. "2h 14m" / "47m" / "0m". */
|
||||||
|
export function formatDuration(fromIso: string, toIso: string): string {
|
||||||
|
const ms = Date.parse(toIso) - Date.parse(fromIso);
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return "—";
|
||||||
|
const mins = Math.floor(ms / 60_000);
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local time-of-day HH:MM:SS from an ISO string. */
|
||||||
|
export function formatTime(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// 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",
|
||||||
|
permits: "Permits",
|
||||||
|
site: "Site",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
live: "LIVE",
|
||||||
|
connecting: "CONNECTING",
|
||||||
|
offline: "OFFLINE",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
evtEntry: "ENTRY",
|
||||||
|
evtExit: "EXIT",
|
||||||
|
evtPay: "PAY",
|
||||||
|
evtVoid: "VOID",
|
||||||
|
evtOpenCmd: "OPEN→",
|
||||||
|
evtOpenObserved: "OPEN✓",
|
||||||
|
evtShiftOpen: "SHIFT+",
|
||||||
|
evtShiftZ: "SHIFT Z",
|
||||||
|
evtCashMovement: "CASH",
|
||||||
|
evtAnomaly: "ANOMALY",
|
||||||
|
},
|
||||||
|
tariff: {
|
||||||
|
title: "Tariff",
|
||||||
|
noRateCard: "No rate card published yet — the pay station can't charge until you publish one.",
|
||||||
|
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
|
||||||
|
currency: "Currency",
|
||||||
|
freeEntryGrace: "Free entry grace (min)",
|
||||||
|
billingIncrement: "Billing increment (min)",
|
||||||
|
dailyCap: "Daily cap (blank = none)",
|
||||||
|
dailyCapPh: "e.g. 12.00",
|
||||||
|
lostTicketFee: "Lost-ticket fee",
|
||||||
|
exitGrace: "Exit walk-back grace (min)",
|
||||||
|
rateBlocks: "Rate blocks",
|
||||||
|
rateBlocksHint: "Consumed in order as time accrues. \"Up to (min)\" is the block's upper bound; leave the last block's bound blank for \"thereafter\". Price is per billing increment.",
|
||||||
|
upToMin: "Up to (min)",
|
||||||
|
pricePerIncrement: "Price / increment",
|
||||||
|
thereafter: "thereafter",
|
||||||
|
egExample: "e.g. 60",
|
||||||
|
remove: "Remove",
|
||||||
|
addBlock: "+ Add block",
|
||||||
|
publishNewVersion: "Publish new version",
|
||||||
|
publishing: "Publishing…",
|
||||||
|
publishedOk: "New tariff version published — it's now the active rate card.",
|
||||||
|
},
|
||||||
|
permits: {
|
||||||
|
title: "Permits",
|
||||||
|
unnamed: "(unnamed)",
|
||||||
|
unbound: "unbound",
|
||||||
|
car_one: "{{count}} car",
|
||||||
|
car_other: "{{count}} cars",
|
||||||
|
cred: "cred",
|
||||||
|
plates: "{{count}} plate(s)",
|
||||||
|
edit: "Edit",
|
||||||
|
revoke: "Revoke",
|
||||||
|
delete: "Delete",
|
||||||
|
noPermitsYet: "No permits yet.",
|
||||||
|
addPermit: "+ Add permit",
|
||||||
|
newPermit: "New permit",
|
||||||
|
editPermit: "Edit permit",
|
||||||
|
holderName: "Holder name",
|
||||||
|
contact: "Contact",
|
||||||
|
carLimit: "Car limit",
|
||||||
|
limitCarsInAtOnce: "limit cars in at once",
|
||||||
|
validFrom: "Valid from",
|
||||||
|
validTo: "Valid to",
|
||||||
|
isoDateOptional: "ISO date (optional)",
|
||||||
|
boundPlates: "Bound plates",
|
||||||
|
commaSeparatedOptional: "comma-separated (optional)",
|
||||||
|
credentialsCardQr: "Credentials (card / QR)",
|
||||||
|
rfCardTag: "RF card/tag",
|
||||||
|
qr: "QR",
|
||||||
|
credentialValue: "credential value",
|
||||||
|
addCredential: "+ credential",
|
||||||
|
needCredentialOrPlate: "A permit needs at least one credential OR one bound plate.",
|
||||||
|
save: "Save",
|
||||||
|
cancel: "Cancel",
|
||||||
|
permitSaved: "Permit saved.",
|
||||||
|
confirmRevoke: "Revoke permit for {{name}}? It will be refused at the barrier.",
|
||||||
|
confirmDelete: "Delete permit 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).",
|
||||||
|
},
|
||||||
|
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}}.",
|
||||||
|
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,227 @@
|
|||||||
|
// 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",
|
||||||
|
permits: "Lejet",
|
||||||
|
site: "Vendi",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
live: "DREJTPËRDREJT",
|
||||||
|
connecting: "DUKE U LIDHUR",
|
||||||
|
offline: "JASHTË LINJE",
|
||||||
|
},
|
||||||
|
booth: {
|
||||||
|
processTicket: "Proceso biletën",
|
||||||
|
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||||
|
open: "Hap",
|
||||||
|
occupancy: "Zënia",
|
||||||
|
occUnavailable: "zënia e padisponueshme",
|
||||||
|
inside: "brenda",
|
||||||
|
of: "nga",
|
||||||
|
uncapped: "pa kufi",
|
||||||
|
free: "lirë",
|
||||||
|
lotFull: "● parkimi plot",
|
||||||
|
liveFeed: "Aktiviteti i drejtpërdrejtë",
|
||||||
|
events: "ngjarje",
|
||||||
|
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||||
|
activeSessions: "Sesionet aktive",
|
||||||
|
insideCount: "brenda",
|
||||||
|
noActiveSessions: "Asnjë sesion aktiv.",
|
||||||
|
inAt: "në",
|
||||||
|
openPayExit: "Hap pagesën / daljen",
|
||||||
|
openBarrier: "Hap barrierën",
|
||||||
|
openBarrierTitle: "Hapje barriere me ndërhyrje njerëzore (e regjistruar)",
|
||||||
|
barrierOpened: "barriera u hap",
|
||||||
|
openManually: "hape me dorë",
|
||||||
|
// session row badges
|
||||||
|
badgeExiting: "duke dalë",
|
||||||
|
badgePaid: "paguar",
|
||||||
|
badgeUnpaid: "papaguar",
|
||||||
|
// event types (live feed labels)
|
||||||
|
evtEntry: "HYRJE",
|
||||||
|
evtExit: "DALJE",
|
||||||
|
evtPay: "PAGESË",
|
||||||
|
evtVoid: "ANULIM",
|
||||||
|
evtOpenCmd: "HAP→",
|
||||||
|
evtOpenObserved: "HAP✓",
|
||||||
|
evtShiftOpen: "TURN+",
|
||||||
|
evtShiftZ: "TURN Z",
|
||||||
|
evtCashMovement: "ARKË",
|
||||||
|
evtAnomaly: "ANOMALI",
|
||||||
|
},
|
||||||
|
tariff: {
|
||||||
|
title: "Tarifa",
|
||||||
|
noRateCard: "Asnjë kartë tarifore e publikuar — arka nuk mund të faturojë derisa të publikoni një.",
|
||||||
|
activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.",
|
||||||
|
currency: "Monedha",
|
||||||
|
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
|
||||||
|
billingIncrement: "Intervali i faturimit (min)",
|
||||||
|
dailyCap: "Kufiri ditor (bosh = pa kufi)",
|
||||||
|
dailyCapPh: "p.sh. 12.00",
|
||||||
|
lostTicketFee: "Tarifa për biletë të humbur",
|
||||||
|
exitGrace: "Periudha e kthimit në dalje (min)",
|
||||||
|
rateBlocks: "Blloqet tarifore",
|
||||||
|
rateBlocksHint: "Konsumohen me radhë me kalimin e kohës. \"Deri në (min)\" është kufiri i sipërm i bllokut; lëre bosh kufirin e bllokut të fundit për \"më pas\". Çmimi është për interval faturimi.",
|
||||||
|
upToMin: "Deri në (min)",
|
||||||
|
pricePerIncrement: "Çmimi / interval",
|
||||||
|
thereafter: "më pas",
|
||||||
|
egExample: "p.sh. 60",
|
||||||
|
remove: "Hiq",
|
||||||
|
addBlock: "+ Shto bllok",
|
||||||
|
publishNewVersion: "Publiko version të ri",
|
||||||
|
publishing: "Duke publikuar…",
|
||||||
|
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
|
||||||
|
},
|
||||||
|
permits: {
|
||||||
|
title: "Lejet",
|
||||||
|
unnamed: "(pa emër)",
|
||||||
|
unbound: "pa kufizim",
|
||||||
|
car_one: "{{count}} makinë",
|
||||||
|
car_other: "{{count}} makina",
|
||||||
|
cred: "kredencial",
|
||||||
|
plates: "{{count}} targë(a)",
|
||||||
|
edit: "Ndrysho",
|
||||||
|
revoke: "Anulo",
|
||||||
|
delete: "Fshij",
|
||||||
|
noPermitsYet: "Asnjë leje ende.",
|
||||||
|
addPermit: "+ Shto leje",
|
||||||
|
newPermit: "Leje e re",
|
||||||
|
editPermit: "Ndrysho lejen",
|
||||||
|
holderName: "Emri i mbajtësit",
|
||||||
|
contact: "Kontakti",
|
||||||
|
carLimit: "Kufiri i makinave",
|
||||||
|
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
|
||||||
|
validFrom: "Vlen nga",
|
||||||
|
validTo: "Vlen deri",
|
||||||
|
isoDateOptional: "Datë ISO (opsionale)",
|
||||||
|
boundPlates: "Targat e lidhura",
|
||||||
|
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
||||||
|
credentialsCardQr: "Kredencialet (kartë / QR)",
|
||||||
|
rfCardTag: "Kartë/etiketë RF",
|
||||||
|
qr: "QR",
|
||||||
|
credentialValue: "vlera e kredencialit",
|
||||||
|
addCredential: "+ kredencial",
|
||||||
|
needCredentialOrPlate: "Një leje kërkon të paktën një kredencial OSE një targë të lidhur.",
|
||||||
|
save: "Ruaj",
|
||||||
|
cancel: "Anulo",
|
||||||
|
permitSaved: "Leja u ruajt.",
|
||||||
|
confirmRevoke: "Të anulohet leja për {{name}}? Do të refuzohet te barriera.",
|
||||||
|
confirmDelete: "Të fshihet leja për {{name}}? (Ngjarjet e kaluara ruhen.)",
|
||||||
|
statusActive: "aktive",
|
||||||
|
statusSuspended: "pezulluar",
|
||||||
|
statusRevoked: "anuluar",
|
||||||
|
},
|
||||||
|
site: {
|
||||||
|
occupancy: "Zënia:",
|
||||||
|
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).",
|
||||||
|
},
|
||||||
|
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}}.",
|
||||||
|
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,41 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { 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[];
|
||||||
|
setStatus: (s: WsStatus) => void;
|
||||||
|
setOccupancy: (o: Occupancy) => void;
|
||||||
|
pushEvent: (e: LedgerEvent) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLiveStore = create<LiveState>((set) => ({
|
||||||
|
status: "connecting",
|
||||||
|
occupancy: null,
|
||||||
|
feed: [],
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
reset: () => set({ status: "connecting", occupancy: null, feed: [] }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { 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 }
|
||||||
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||||
|
| { kind: "printer-status"; event: unknown };
|
||||||
|
|
||||||
|
/** 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 } = 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);
|
||||||
|
} 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 });
|
||||||
|
} 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
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -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,202 @@
|
|||||||
|
import {
|
||||||
|
createRootRouteWithContext,
|
||||||
|
createRoute,
|
||||||
|
createRouter,
|
||||||
|
Link,
|
||||||
|
Outlet,
|
||||||
|
redirect,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Lang, SessionUser } from "./api.js";
|
||||||
|
import { logout, setLanguagePref } from "./api.js";
|
||||||
|
import { queryClient } from "./lib/query.js";
|
||||||
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
|
import { BoothScreen } from "./BoothScreen.js";
|
||||||
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
|
import { TariffComposer } from "./TariffComposer.js";
|
||||||
|
import { PermitManager } from "./PermitManager.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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="/permits" label={t("nav.permits")} />}
|
||||||
|
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
{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>
|
||||||
|
</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 permitsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/permits",
|
||||||
|
beforeLoad: ({ context }) => adminOnly(context),
|
||||||
|
component: () => <PermitManager />,
|
||||||
|
});
|
||||||
|
const siteRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/site",
|
||||||
|
beforeLoad: ({ context }) => adminOnly(context),
|
||||||
|
component: () => <SiteSettings canEdit={true} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeTree = rootRoute.addChildren([
|
||||||
|
indexRoute,
|
||||||
|
boothRoute,
|
||||||
|
shiftRoute,
|
||||||
|
setupRoute,
|
||||||
|
tariffRoute,
|
||||||
|
permitsRoute,
|
||||||
|
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,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,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Parking dev: pin route source addresses (WSL2 mirrored-mode fix)
|
||||||
|
# Run after WSL has populated the mirrored interfaces/addresses.
|
||||||
|
After=network.target wsl-pro.service
|
||||||
|
Wants=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
# Idempotent; safe to re-run. Path is the repo checkout on this dev box.
|
||||||
|
ExecStart=/home/julian/projects/JS/parking-system/deploy/wsl-fix-route-source.sh eth1
|
||||||
|
# Mirrored-mode addresses can land slightly after boot; one retry covers the race.
|
||||||
|
ExecStartPost=/bin/sh -c 'sleep 3; /home/julian/projects/JS/parking-system/deploy/wsl-fix-route-source.sh eth1 || true'
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# WSL2 mirrored-mode source-address fix (dev box only).
|
||||||
|
#
|
||||||
|
# Problem: in WSL2 mirrored networking the Windows host's interfaces — and ALL
|
||||||
|
# their IPs — are cloned into Linux on every boot. When two device subnets land
|
||||||
|
# on one NIC (e.g. 192.168.1.x AND 10.0.10.x on eth1), the kernel's connected
|
||||||
|
# routes come up `scope link` with NO preferred source, and source selection can
|
||||||
|
# pick the WRONG address (sourcing 10.0.10.x traffic from 192.168.1.123). ARP
|
||||||
|
# still resolves (L2), so the device looks REACHABLE while every ping/TCP times
|
||||||
|
# out. See wiki/concepts/wsl-dev-networking.md.
|
||||||
|
#
|
||||||
|
# Fix: for each connected `scope link` route, pin its preferred `src` to THIS
|
||||||
|
# host's own address in that same subnet. No hardcoded IPs — derived at runtime,
|
||||||
|
# so it also covers future device subnets. Idempotent; a no-op when nothing needs
|
||||||
|
# fixing. Runs at boot via parking-net.service.
|
||||||
|
#
|
||||||
|
# Production note: the real appliance is bare-metal Linux, not WSL — there this
|
||||||
|
# is just static networkd/netplan config. This script exists only for the dev box.
|
||||||
|
# NB: intentionally NOT `set -e`. This is a best-effort boot fixer; an individual
|
||||||
|
# `ip` call failing (e.g. a route not up yet) must not abort the rest.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
fix_iface() {
|
||||||
|
local iface="$1"
|
||||||
|
# Each connected /N route on this iface that the kernel manages (proto kernel,
|
||||||
|
# scope link) — i.e. the directly-attached subnets. Capture the full line so we
|
||||||
|
# can preserve attributes (notably `metric`) when we replace the route.
|
||||||
|
ip -4 route show dev "$iface" proto kernel scope link | while read -r line; do
|
||||||
|
local subnet="${line%% *}" # e.g. "10.0.10.0/24"
|
||||||
|
local prefix="${subnet%/*}"
|
||||||
|
# Preserve a metric if the route has one (mirrored-mode routes carry e.g. 281);
|
||||||
|
# replacing without it would change the route's priority.
|
||||||
|
local metric=""
|
||||||
|
case "$line" in *" metric "*) metric="metric ${line##* metric }";; esac
|
||||||
|
|
||||||
|
# Find THIS host's own address inside the same subnet — the correct src.
|
||||||
|
local hostip=""
|
||||||
|
local cidr
|
||||||
|
for cidr in $(ip -4 -o addr show dev "$iface" | awk '{print $4}'); do
|
||||||
|
if ipcalc_net "$cidr" "$subnet"; then hostip="${cidr%/*}"; break; fi
|
||||||
|
done
|
||||||
|
[ -n "$hostip" ] || continue
|
||||||
|
|
||||||
|
local current
|
||||||
|
current=$(ip -4 route get "$prefix" 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1)
|
||||||
|
[ "$current" = "$hostip" ] && continue # already correct — no-op
|
||||||
|
|
||||||
|
# `replace` creates-or-updates, so it works whether or not the route is
|
||||||
|
# present yet (avoids the boot-race RTNETLINK "No such file" that `change` hits).
|
||||||
|
# Non-fatal: a single failure must not abort the whole boot fixer.
|
||||||
|
if ip route replace "$subnet" dev "$iface" proto kernel scope link src "$hostip" $metric; then
|
||||||
|
echo "pinned $subnet -> src $hostip (was ${current:-none})"
|
||||||
|
else
|
||||||
|
echo "warn: could not pin $subnet -> src $hostip" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# True if address $1 (a.b.c.d/p) is inside subnet $2 (n.n.n.0/p), same prefix len.
|
||||||
|
ipcalc_net() {
|
||||||
|
local addr="${1%/*}" alen="${1#*/}"
|
||||||
|
local net="${2%/*}" nlen="${2#*/}"
|
||||||
|
[ "$alen" = "$nlen" ] || return 1
|
||||||
|
# Compare the network part by masking both to /nlen.
|
||||||
|
local a n
|
||||||
|
a=$(mask_to_net "$addr" "$nlen")
|
||||||
|
n=$(mask_to_net "$net" "$nlen")
|
||||||
|
[ "$a" = "$n" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mask an IPv4 dotted-quad to its /len network address.
|
||||||
|
mask_to_net() {
|
||||||
|
local ip="$1" len="$2"
|
||||||
|
local IFS=. ; read -r o1 o2 o3 o4 <<<"$ip"
|
||||||
|
local int=$(( (o1<<24) + (o2<<16) + (o3<<8) + o4 ))
|
||||||
|
local mask=$(( len == 0 ? 0 : (0xFFFFFFFF << (32 - len)) & 0xFFFFFFFF ))
|
||||||
|
local net=$(( int & mask ))
|
||||||
|
echo "$(( (net>>24)&255 )).$(( (net>>16)&255 )).$(( (net>>8)&255 )).$(( net&255 ))"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
# Default to eth1 (the mirrored LAN NIC here); accept overrides as args.
|
||||||
|
local ifaces=("${@:-eth1}")
|
||||||
|
# Boot race: WSL mirrored mode can populate the interface's addresses/routes a
|
||||||
|
# beat after the unit starts. Wait (bounded) for at least one connected route
|
||||||
|
# to appear on the first interface before pinning.
|
||||||
|
local i tries=0
|
||||||
|
for i in "${ifaces[@]}"; do
|
||||||
|
while [ "$tries" -lt 15 ] \
|
||||||
|
&& [ -z "$(ip -4 route show dev "$i" proto kernel scope link 2>/dev/null)" ]; do
|
||||||
|
sleep 1; tries=$((tries + 1))
|
||||||
|
done
|
||||||
|
break
|
||||||
|
done
|
||||||
|
for i in "${ifaces[@]}"; do
|
||||||
|
ip link show "$i" >/dev/null 2>&1 && fix_iface "$i"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
CREATE TABLE `events` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`index` integer NOT NULL,
|
|
||||||
`type` text NOT NULL,
|
|
||||||
`direction` text,
|
|
||||||
`lane` integer NOT NULL,
|
|
||||||
`source` text,
|
|
||||||
`identity` text,
|
|
||||||
`occurred_at` text NOT NULL,
|
|
||||||
`prev_hash` text,
|
|
||||||
`signature` text NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `events_index_unique` ON `events` (`index`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `users` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`username` text NOT NULL,
|
|
||||||
`password_hash` text NOT NULL,
|
|
||||||
`role` text NOT NULL,
|
|
||||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
CREATE TABLE `blocklist` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`kind` text NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`reason` text,
|
||||||
|
`active` integer DEFAULT true NOT NULL,
|
||||||
|
`added_by` text,
|
||||||
|
`added_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `device_events` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`device_id` text,
|
||||||
|
`category` text,
|
||||||
|
`kind` text NOT NULL,
|
||||||
|
`detail` text,
|
||||||
|
`occurred_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `devices` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`category` text NOT NULL,
|
||||||
|
`driver_id` text NOT NULL,
|
||||||
|
`config` text NOT NULL,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `ledger_events` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`index` integer NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`direction` text,
|
||||||
|
`source` text,
|
||||||
|
`identity` text,
|
||||||
|
`payload` text,
|
||||||
|
`occurred_at` text NOT NULL,
|
||||||
|
`prev_hash` text,
|
||||||
|
`signature` text NOT NULL,
|
||||||
|
`key_id` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `ledger_events_index_unique` ON `ledger_events` (`index`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `permit_credentials` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`permit_id` text NOT NULL,
|
||||||
|
`kind` text NOT NULL,
|
||||||
|
`value` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `permit_plates` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`permit_id` text NOT NULL,
|
||||||
|
`plate` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `permits` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`holder_name` text,
|
||||||
|
`contact` text,
|
||||||
|
`max_concurrent` integer DEFAULT 1,
|
||||||
|
`valid_from` text,
|
||||||
|
`valid_to` text,
|
||||||
|
`status` text DEFAULT 'active' NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `sessions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`identity` text,
|
||||||
|
`source` text,
|
||||||
|
`permit_id` text,
|
||||||
|
`entered_at` text NOT NULL,
|
||||||
|
`exited_at` text,
|
||||||
|
`state` text DEFAULT 'open' NOT NULL,
|
||||||
|
`last_event_index` integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `setup_state` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`completed_at` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `site_config` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`capacity` integer,
|
||||||
|
`updated_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `snapshots` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`direction` text NOT NULL,
|
||||||
|
`device_id` text,
|
||||||
|
`identity` text,
|
||||||
|
`content_type` text NOT NULL,
|
||||||
|
`bytes` blob NOT NULL,
|
||||||
|
`captured_at` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `tariff_versions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`tariff_id` text NOT NULL,
|
||||||
|
`effective_from` text NOT NULL,
|
||||||
|
`currency` text NOT NULL,
|
||||||
|
`structure` text NOT NULL,
|
||||||
|
`created_by` text,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `tariffs` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`scope` text DEFAULT 'site' NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`password_hash` text NOT NULL,
|
||||||
|
`role` text NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
CREATE TABLE `lane_devices` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`lane` integer NOT NULL,
|
|
||||||
`category` text NOT NULL,
|
|
||||||
`driver_id` text NOT NULL,
|
|
||||||
`config` text NOT NULL,
|
|
||||||
`enabled` integer DEFAULT true NOT NULL,
|
|
||||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `setup_state` (
|
|
||||||
`id` integer PRIMARY KEY NOT NULL,
|
|
||||||
`completed_at` text
|
|
||||||
);
|
|
||||||
@@ -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;
|
||||||
@@ -1,11 +1,179 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "721bbb8f-b929-4018-9420-0ae75b03ff93",
|
"id": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"events": {
|
"blocklist": {
|
||||||
"name": "events",
|
"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": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -35,13 +203,6 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"lane": {
|
|
||||||
"name": "lane",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"source": {
|
"source": {
|
||||||
"name": "source",
|
"name": "source",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -56,6 +217,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"payload": {
|
||||||
|
"name": "payload",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"occurred_at": {
|
"occurred_at": {
|
||||||
"name": "occurred_at",
|
"name": "occurred_at",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -76,11 +244,18 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_id": {
|
||||||
|
"name": "key_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
"events_index_unique": {
|
"ledger_events_index_unique": {
|
||||||
"name": "events_index_unique",
|
"name": "ledger_events_index_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
"index"
|
"index"
|
||||||
],
|
],
|
||||||
@@ -92,6 +267,426 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"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
|
||||||
|
},
|
||||||
|
"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": {
|
"users": {
|
||||||
"name": "users",
|
"name": "users",
|
||||||
"columns": {
|
"columns": {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "1073123c-0df9-4109-84bf-7f23b95ec5bd",
|
"id": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
|
||||||
"prevId": "721bbb8f-b929-4018-9420-0ae75b03ff93",
|
"prevId": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
|
||||||
"tables": {
|
"tables": {
|
||||||
"events": {
|
"blocklist": {
|
||||||
"name": "events",
|
"name": "blocklist",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -14,43 +14,90 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"index": {
|
"kind": {
|
||||||
"name": "index",
|
"name": "kind",
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"name": "type",
|
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"direction": {
|
"value": {
|
||||||
"name": "direction",
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"name": "reason",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"lane": {
|
"active": {
|
||||||
"name": "lane",
|
"name": "active",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
},
|
},
|
||||||
"source": {
|
"added_by": {
|
||||||
"name": "source",
|
"name": "added_by",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"identity": {
|
"added_at": {
|
||||||
"name": "identity",
|
"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",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -61,39 +108,18 @@
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false,
|
||||||
},
|
"default": "(current_timestamp)"
|
||||||
"prev_hash": {
|
|
||||||
"name": "prev_hash",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"signature": {
|
|
||||||
"name": "signature",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"events_index_unique": {
|
|
||||||
"name": "events_index_unique",
|
|
||||||
"columns": [
|
|
||||||
"index"
|
|
||||||
],
|
|
||||||
"isUnique": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"indexes": {},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"lane_devices": {
|
"devices": {
|
||||||
"name": "lane_devices",
|
"name": "devices",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -102,13 +128,6 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"lane": {
|
|
||||||
"name": "lane",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"category": {
|
"category": {
|
||||||
"name": "category",
|
"name": "category",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -153,6 +172,306 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"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": {
|
"setup_state": {
|
||||||
"name": "setup_state",
|
"name": "setup_state",
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -177,6 +496,239 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"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": {
|
"users": {
|
||||||
"name": "users",
|
"name": "users",
|
||||||
"columns": {
|
"columns": {
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,29 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1781389618205,
|
"when": 1781632874398,
|
||||||
"tag": "0000_absent_rocket_raccoon",
|
"tag": "0000_baseline",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1781416636098,
|
"when": 1781682176094,
|
||||||
"tag": "0001_cuddly_maria_hill",
|
"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
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+241
-16
@@ -1,11 +1,17 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
// Schema notes:
|
// Schema notes:
|
||||||
// - `events` is APPEND-ONLY. Never expose UPDATE/DELETE on it. A correction or
|
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
|
||||||
// void is a new row of type 'void'. Each row chains to the previous via
|
// • `ledger_events` — the APPEND-ONLY, hash-chained, ATECC608-SIGNED business ledger.
|
||||||
// `prevHash` and is signed by the ATECC608 (`signature`). This is the core
|
// Never UPDATE/DELETE. A correction or void is a new row of type 'void'. Each row
|
||||||
// anti-fraud integrity mechanism. See wiki/concepts/append-only-event-chain.md.
|
// chains via `prevHash` and is signed (`signature`). The anti-fraud record; sessions,
|
||||||
|
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
|
||||||
|
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
|
||||||
|
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
|
||||||
|
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a
|
||||||
|
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
|
||||||
|
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
|
||||||
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
|
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
|
||||||
// See wiki/entities/local-jwt-auth.md.
|
// See wiki/entities/local-jwt-auth.md.
|
||||||
|
|
||||||
@@ -16,35 +22,99 @@ 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)`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const events = sqliteTable("events", {
|
// --- The signed business ledger (formerly `events`) ----------------------
|
||||||
|
// Holds ONLY business/accountability facts: vehicle_entry, vehicle_exit, payment,
|
||||||
|
// void, shift_z_report, plus witness-grade barrier_open_command/observed, anomaly.
|
||||||
|
// `payload` carries type-specific data (amount, tariffVersionId, sessionRef, tender,
|
||||||
|
// plate confidence…) and is part of the SIGNED canonical form, so it is tamper-evident
|
||||||
|
// like the rest of the row. See packages/shared ParkingEventType + LedgerPayload.
|
||||||
|
export const ledgerEvents = sqliteTable("ledger_events", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
// Monotonic chain index. Gaps are alarms (see event-log-ingestion).
|
// Monotonic chain index. Gaps are alarms (see event-log-ingestion).
|
||||||
index: integer("index").notNull().unique(),
|
index: integer("index").notNull().unique(),
|
||||||
type: text("type").notNull(),
|
type: text("type").notNull(),
|
||||||
direction: text("direction", { enum: ["entry", "exit"] }),
|
direction: text("direction", { enum: ["entry", "exit"] }),
|
||||||
lane: integer("lane").notNull(),
|
|
||||||
source: text("source"),
|
source: text("source"),
|
||||||
identity: text("identity"),
|
identity: text("identity"),
|
||||||
|
// Type-specific business payload (JSON). Signed as part of the canonical form.
|
||||||
|
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||||
occurredAt: text("occurred_at").notNull(),
|
occurredAt: text("occurred_at").notNull(),
|
||||||
// Hash of the previous event (hex). Null only for the genesis event.
|
// Hash of the previous event (hex). Null only for the genesis event.
|
||||||
prevHash: text("prev_hash"),
|
prevHash: text("prev_hash"),
|
||||||
// ATECC608 signature over the canonical event payload (hex).
|
// ATECC608 signature over the canonical event payload (hex).
|
||||||
signature: text("signature").notNull(),
|
signature: text("signature").notNull(),
|
||||||
|
// Which signer/key produced `signature` (e.g. "sw-hmac-v1", "atecc608-slot0"),
|
||||||
|
// so old events stay verifiable across a signer swap. See packages/shared Signer.
|
||||||
|
keyId: text("key_id").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-lane device assignments chosen by the admin during first-run setup.
|
// --- Device telemetry (unsigned, prunable) -------------------------------
|
||||||
// One row per (lane, category, instance). `driverId` references a driver in the
|
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
|
||||||
// @parking/devices registry; `config` is that driver's JSON config (host, port,
|
// offline, reader read, raw input edges. Keyed to a `devices` instance. No
|
||||||
// credentials…). Lets the system stay device-agnostic and admin-configurable.
|
// prevHash/signature — this stream may rotate/prune.
|
||||||
// See wiki/concepts/device-registry.md and first-run-setup.md.
|
export const deviceEvents = sqliteTable("device_events", {
|
||||||
export const laneDevices = sqliteTable("lane_devices", {
|
id: text("id").primaryKey(),
|
||||||
|
// The `devices` instance that produced it (raw provenance).
|
||||||
|
deviceId: text("device_id"),
|
||||||
|
category: text("category", {
|
||||||
|
enum: ["access", "reader", "camera", "printer"],
|
||||||
|
}),
|
||||||
|
// e.g. "input", "relay", "status", "read", "snapshot".
|
||||||
|
kind: text("kind").notNull(),
|
||||||
|
// Free-form telemetry detail (input number + edge, status flags, error…).
|
||||||
|
detail: text("detail", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||||
|
occurredAt: text("occurred_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Camera snapshots (unsigned, prunable, blob-in-DB) -------------------
|
||||||
|
// An entry/exit snapshot captured asynchronously AFTER the barrier opens — evidence,
|
||||||
|
// not a gate (camera failure never blocks an open; see entry/exit flows). Stored as a
|
||||||
|
// BLOB so the appliance keeps a single backed-up file with nothing scattered on disk.
|
||||||
|
// Kept in its own table (not inline in device_events) so the hot telemetry scans don't
|
||||||
|
// drag image bytes, and so images can be pruned independently. The signed
|
||||||
|
// vehicle_entry/exit references a snapshot by `id` in its payload — the image is an
|
||||||
|
// independent record (anti-fraud), unsigned and prunable. Retention policy is an open
|
||||||
|
// question — see wiki/concepts/entry-exit-points.md. Served via GET /api/snapshots/:id.
|
||||||
|
export const snapshots = sqliteTable("snapshots", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
|
||||||
|
// The camera `devices` instance that captured it (raw provenance).
|
||||||
|
deviceId: text("device_id"),
|
||||||
|
// The session/credential ref (ticket id, plate, permit) — links to the ledger event.
|
||||||
|
identity: text("identity"),
|
||||||
|
contentType: text("content_type").notNull(),
|
||||||
|
bytes: blob("bytes").notNull().$type<Buffer>(),
|
||||||
|
capturedAt: text("captured_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Device assignments (first-run setup) --------------------------------
|
||||||
|
// One row per device instance. `driverId` references a driver in the @parking/devices
|
||||||
|
// registry; `config` is that driver's JSON config. There is NO lane: a parking lot is
|
||||||
|
// one pool of spaces with a flexible set of entry/exit points. Direction lives INSIDE
|
||||||
|
// the config, per the hardware:
|
||||||
|
// - access controller: config.relays = [{ relay, direction: entry|exit|both, button? }]
|
||||||
|
// — one physical board has several relays; each relay opens one barrier in one
|
||||||
|
// direction (or both). `button` = the input terminal the entry button is wired to
|
||||||
|
// (transient entry trigger; absent = no button at that barrier).
|
||||||
|
// - reader / camera: config.controllerId + config.relay BIND it to the barrier it sits
|
||||||
|
// at; its direction is INHERITED from that relay. Unbound → falls back to a
|
||||||
|
// direction picked in config.
|
||||||
|
// See device-registry.md, first-run-setup.md, wiki/concepts/entry-exit-points.md.
|
||||||
|
export const devices = sqliteTable("devices", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
lane: integer("lane").notNull(),
|
|
||||||
category: text("category", {
|
category: text("category", {
|
||||||
enum: ["access", "reader", "camera", "printer"],
|
enum: ["access", "reader", "camera", "printer"],
|
||||||
}).notNull(),
|
}).notNull(),
|
||||||
@@ -64,7 +134,162 @@ export const setupState = sqliteTable("setup_state", {
|
|||||||
completedAt: text("completed_at"),
|
completedAt: text("completed_at"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Single-row site settings (admin-configurable). The home for site-wide knobs;
|
||||||
|
// `capacity` is the nominal space count the FULL gate refuses transient entry at
|
||||||
|
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
|
||||||
|
// Park identity/metadata (all optional) lives here too — display name, the legal
|
||||||
|
// operator, the NIUS tax id, address and contact. These feed the ticket/receipt
|
||||||
|
// header (park name + NIUS are commonly required on an Albanian parking receipt)
|
||||||
|
// and admin display. All nullable: the lot runs fine with none set.
|
||||||
|
// See wiki/concepts/site-metadata.md.
|
||||||
|
export const siteConfig = sqliteTable("site_config", {
|
||||||
|
id: integer("id").primaryKey(), // always 1
|
||||||
|
capacity: integer("capacity"), // null = no capacity limit
|
||||||
|
/** Park display name shown on the ticket header / UI (e.g. "Acme Parking"). */
|
||||||
|
parkName: text("park_name"),
|
||||||
|
/** Legal entity operating the lot, for receipts (may differ from parkName). */
|
||||||
|
operatorName: text("operator_name"),
|
||||||
|
/** NIUS — Albanian tax/identification number, printed on the receipt when set. */
|
||||||
|
nius: text("nius"),
|
||||||
|
/** Free-text postal address (multi-line allowed). */
|
||||||
|
address: text("address"),
|
||||||
|
/** Contact phone — also used for the ticket "lost ticket? call …" footer. */
|
||||||
|
phone: text("phone"),
|
||||||
|
/** Contact email. */
|
||||||
|
email: text("email"),
|
||||||
|
/** Default for the booth pay modal's "print exit ticket" checkbox. Site-wide
|
||||||
|
* because it's booth GEOGRAPHY: when the booth is far from the exit, the
|
||||||
|
* customer pays at the booth and self-exits later by scanning a printed exit
|
||||||
|
* voucher (= the ticket id reprinted, now paid). When near the exit, the booth
|
||||||
|
* opens the barrier directly. The operator may still override per transaction.
|
||||||
|
* Stored 0/1 (SQLite has no bool). See wiki/concepts/booth-exit-flow.md. */
|
||||||
|
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Tariffs (composable, versioned) -------------------------------------
|
||||||
|
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
|
||||||
|
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
|
||||||
|
// A session reprices against the version in force at its entry time; the `payment`
|
||||||
|
// ledger event records the tariffVersionId used. "One active tariff per site" today;
|
||||||
|
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
|
||||||
|
export const tariffs = sqliteTable("tariffs", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
// Only "site" used now; "zone" reserved for multi-tariff later.
|
||||||
|
scope: text("scope", { enum: ["site", "zone"] }).notNull().default("site"),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tariffVersions = sqliteTable("tariff_versions", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
tariffId: text("tariff_id").notNull(),
|
||||||
|
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
|
||||||
|
effectiveFrom: text("effective_from").notNull(),
|
||||||
|
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
|
||||||
|
currency: text("currency").notNull(),
|
||||||
|
// The composable rate card (stepped blocks + caps/grace). Shape: TariffStructure
|
||||||
|
// in packages/shared. Immutable once published.
|
||||||
|
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
|
||||||
|
createdBy: text("created_by"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Permits (subscriptions) ---------------------------------------------
|
||||||
|
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event.
|
||||||
|
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null =
|
||||||
|
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a
|
||||||
|
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md.
|
||||||
|
export const permits = sqliteTable("permits", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
holderName: text("holder_name"),
|
||||||
|
contact: text("contact"),
|
||||||
|
// Car-count binding: how many of the permit's cars may be inside at once.
|
||||||
|
// null = unbound. Default 1.
|
||||||
|
maxConcurrent: integer("max_concurrent").default(1),
|
||||||
|
validFrom: text("valid_from"),
|
||||||
|
validTo: text("valid_to"),
|
||||||
|
status: text("status", { enum: ["active", "suspended", "revoked"] })
|
||||||
|
.notNull()
|
||||||
|
.default("active"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane.
|
||||||
|
export const permitCredentials = sqliteTable("permit_credentials", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
permitId: text("permit_id").notNull(),
|
||||||
|
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plate binding (optional). When a permit has plate rows, a matching plate read is
|
||||||
|
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
||||||
|
export const permitPlates = sqliteTable("permit_plates", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
permitId: text("permit_id").notNull(),
|
||||||
|
plate: text("plate").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Blocklist (banlist) -------------------------------------------------
|
||||||
|
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
|
||||||
|
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
|
||||||
|
export const blocklist = sqliteTable("blocklist", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
kind: text("kind", { enum: ["plate", "card", "qr"] }).notNull(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
reason: text("reason"),
|
||||||
|
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
addedBy: text("added_by"),
|
||||||
|
addedAt: text("added_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Sessions (PROJECTION cache) -----------------------------------------
|
||||||
|
// NOT a source of truth — a rebuildable fold over ledger_events for fast queries
|
||||||
|
// (occupancy, pay-station lookup, anti-passback, plate search). Always reconstructable
|
||||||
|
// from the signed chain; never the authority for "paid". See wiki/concepts/parking-session.md.
|
||||||
|
export const sessions = sqliteTable("sessions", {
|
||||||
|
// The session key = the entry's identity (ticket id or plate).
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
// Identity that opened the session, and how it was read.
|
||||||
|
identity: text("identity"),
|
||||||
|
source: text("source"),
|
||||||
|
// null while transient; set when matched to a permit.
|
||||||
|
permitId: text("permit_id"),
|
||||||
|
enteredAt: text("entered_at").notNull(),
|
||||||
|
// null until exit; presence = CLOSED.
|
||||||
|
exitedAt: text("exited_at"),
|
||||||
|
// Derived state for quick filtering: open | paid | closed | voided.
|
||||||
|
state: text("state", { enum: ["open", "paid", "closed", "voided"] })
|
||||||
|
.notNull()
|
||||||
|
.default("open"),
|
||||||
|
// Index of the last ledger event folded into this row (cache freshness / rebuild).
|
||||||
|
lastEventIndex: integer("last_event_index"),
|
||||||
|
});
|
||||||
|
|
||||||
export type UserRow = typeof users.$inferSelect;
|
export type UserRow = typeof users.$inferSelect;
|
||||||
export type EventRow = typeof events.$inferSelect;
|
export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
|
||||||
export type LaneDeviceRow = typeof laneDevices.$inferSelect;
|
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
|
||||||
|
export type SnapshotRow = typeof snapshots.$inferSelect;
|
||||||
|
export type DeviceRow = typeof devices.$inferSelect;
|
||||||
export type SetupStateRow = typeof setupState.$inferSelect;
|
export type SetupStateRow = typeof setupState.$inferSelect;
|
||||||
|
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
||||||
|
export type TariffRow = typeof tariffs.$inferSelect;
|
||||||
|
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||||
|
export type PermitRow = typeof permits.$inferSelect;
|
||||||
|
export type PermitCredentialRow = typeof permitCredentials.$inferSelect;
|
||||||
|
export type PermitPlateRow = typeof permitPlates.$inferSelect;
|
||||||
|
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||||
|
export type SessionRow = typeof sessions.$inferSelect;
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ export const dingtianDriver: AccessDriver = {
|
|||||||
description:
|
description:
|
||||||
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
|
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
|
||||||
transports: ["udp"],
|
transports: ["udp"],
|
||||||
|
pushesToBackend: true, // HTTP-pushes input/button events to the backend (Input Link URL)
|
||||||
configFields: [
|
configFields: [
|
||||||
hostField,
|
hostField,
|
||||||
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
|
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
|
||||||
|
import type { AccessDriver, DeviceConfig } from "../registry.js";
|
||||||
|
import { stubLog } from "./common.js";
|
||||||
|
|
||||||
|
// Stub access controller — a no-op barrier for BENCH TESTING the entry/exit/permit
|
||||||
|
// flows without real relay hardware. `pulseOpen` just logs "intent to open"; it
|
||||||
|
// performs no device I/O, so it can stand in on a lane while the real
|
||||||
|
// [[dingtian-relay]] isn't connected. NOT for production. See first-run-setup.md.
|
||||||
|
|
||||||
|
class StubAccess implements AccessControlDevice {
|
||||||
|
readonly driverId = "stub-access";
|
||||||
|
constructor(_config: DeviceConfig) {}
|
||||||
|
async connect(): Promise<void> {}
|
||||||
|
async disconnect(): Promise<void> {}
|
||||||
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
|
return { status: "ready", detail: "stub (no real barrier)" };
|
||||||
|
}
|
||||||
|
async pulseOpen(doorId: number): Promise<void> {
|
||||||
|
stubLog(this.driverId, `pulseOpen door ${doorId} (stub — no relay fired)`);
|
||||||
|
}
|
||||||
|
async getDoorStatus(): Promise<"open" | "closed"> {
|
||||||
|
return "closed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stubAccessDriver: AccessDriver = {
|
||||||
|
id: "stub-access",
|
||||||
|
category: "access",
|
||||||
|
label: "Stub barrier (bench testing — no relay)",
|
||||||
|
description:
|
||||||
|
"A no-op access controller for testing the flows without hardware. pulseOpen only logs; no relay is fired. Not for production.",
|
||||||
|
transports: ["tcp-ip"],
|
||||||
|
configFields: [],
|
||||||
|
create: (c) => new StubAccess(c),
|
||||||
|
};
|
||||||
@@ -1,57 +1,120 @@
|
|||||||
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js";
|
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js";
|
||||||
import type { CameraDriver, DeviceConfig } from "../registry.js";
|
import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js";
|
||||||
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
|
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
|
||||||
|
import { digestGet } from "./http-digest.js";
|
||||||
|
|
||||||
// Camera drivers — entry/exit snapshot-on-event. The image is stored and
|
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
||||||
// referenced from the signed event as an independent fraud-control record.
|
// HTTP when an event fires; the bytes are stored and referenced from the signed
|
||||||
// Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL. STUBS only.
|
// event as an independent fraud-control record (the camera PULLS, it never pushes
|
||||||
|
// to us). Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL and
|
||||||
|
// channel encoding. Both use HTTP Digest auth (see ./http-digest.ts).
|
||||||
|
//
|
||||||
|
// VERIFIED on hardware (2026-06-15): a Hikvision unit at 10.0.10.121 returns a
|
||||||
|
// 2688×1520 JPEG from /ISAPI/Streaming/channels/101/picture with Digest auth.
|
||||||
|
// See wiki/entities/lpr-camera.md.
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
class HttpCamera implements CameraDevice {
|
||||||
|
readonly #host: string;
|
||||||
|
readonly #port: number;
|
||||||
|
readonly #user: string;
|
||||||
|
readonly #password: string;
|
||||||
|
readonly #channel: number;
|
||||||
|
readonly #timeout: number;
|
||||||
|
// Source outbound from the device-facing NIC on a multi-homed host (the
|
||||||
|
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
|
||||||
|
readonly #localAddress: string | undefined;
|
||||||
|
|
||||||
class StubCamera implements CameraDevice {
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly driverId: string,
|
readonly driverId: string,
|
||||||
protected readonly config: DeviceConfig,
|
config: DeviceConfig,
|
||||||
protected readonly snapshotPath: string,
|
/** Builds the snapshot path from the configured channel. */
|
||||||
) {}
|
private readonly snapshotPath: (channel: number) => string,
|
||||||
async connect(): Promise<void> {
|
) {
|
||||||
stubLog(this.driverId, `connect ${this.config.host} (${this.snapshotPath})`);
|
this.#host = String(config.host);
|
||||||
}
|
this.#port = Number(config.port ?? 80);
|
||||||
async disconnect(): Promise<void> {
|
this.#user = String(config.username ?? "");
|
||||||
stubLog(this.driverId, "disconnect");
|
this.#password = String(config.password ?? "");
|
||||||
|
this.#channel = Number(config.channel ?? 1);
|
||||||
|
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||||
|
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {}
|
||||||
|
async disconnect(): Promise<void> {}
|
||||||
|
|
||||||
async healthCheck(): Promise<DeviceHealth> {
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
return { status: "ready", detail: "stub" };
|
// The only honest liveness probe for a snapshot camera is to actually pull a
|
||||||
|
// frame: it exercises reachability + auth + the path/channel in one shot.
|
||||||
|
try {
|
||||||
|
const res = await this.#get();
|
||||||
|
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` };
|
||||||
|
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" };
|
||||||
|
return { status: "degraded", detail: `HTTP ${res.status}` };
|
||||||
|
} catch (err) {
|
||||||
|
return { status: "offline", detail: (err as Error).message };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
|
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
|
||||||
// Real driver: GET http(s)://host{snapshotPath}, store bytes, return ref.
|
const res = await this.#get();
|
||||||
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction}`);
|
if (res.status !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stubLog(this.driverId, `captureSnapshot ${ctx.direction} (${res.body.length} bytes)`);
|
||||||
return {
|
return {
|
||||||
imageRef: `stub://${this.driverId}/lane${ctx.lane}/${ctx.direction}/${Date.now()}`,
|
bytes: res.body,
|
||||||
contentType: "image/jpeg",
|
contentType: res.contentType || "image/jpeg",
|
||||||
capturedAt: new Date().toISOString(),
|
capturedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#get() {
|
||||||
|
return digestGet({
|
||||||
|
host: this.#host,
|
||||||
|
port: this.#port,
|
||||||
|
path: this.snapshotPath(this.#channel),
|
||||||
|
user: this.#user,
|
||||||
|
password: this.#password,
|
||||||
|
timeoutMs: this.#timeout,
|
||||||
|
localAddress: this.#localAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, { key: "channel", label: "Channel", type: "number" as const, required: false, default: 1 }];
|
const channelField: ConfigField = {
|
||||||
|
key: "channel",
|
||||||
|
label: "Channel",
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
default: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
|
||||||
|
|
||||||
export const hikvisionDriver: CameraDriver = {
|
export const hikvisionDriver: CameraDriver = {
|
||||||
id: "hikvision",
|
id: "hikvision",
|
||||||
category: "camera",
|
category: "camera",
|
||||||
label: "Hikvision camera",
|
label: "Hikvision camera",
|
||||||
description: "Hikvision snapshot via ISAPI.",
|
description: "Hikvision snapshot via ISAPI (HTTP Digest).",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip"],
|
||||||
configFields: cameraConfigFields,
|
configFields: cameraConfigFields,
|
||||||
// /ISAPI/Streaming/channels/<id>/picture
|
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
|
||||||
create: (c) => new StubCamera("hikvision", c, "/ISAPI/Streaming/channels/101/picture"),
|
create: (c) =>
|
||||||
|
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dahuaDriver: CameraDriver = {
|
export const dahuaDriver: CameraDriver = {
|
||||||
id: "dahua",
|
id: "dahua",
|
||||||
category: "camera",
|
category: "camera",
|
||||||
label: "Dahua camera",
|
label: "Dahua camera",
|
||||||
description: "Dahua snapshot via CGI.",
|
description: "Dahua snapshot via CGI (HTTP Digest).",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip"],
|
||||||
configFields: cameraConfigFields,
|
configFields: cameraConfigFields,
|
||||||
// /cgi-bin/snapshot.cgi?channel=<n>
|
// Dahua channels are 0-based on the CGI; the admin enters 1-based.
|
||||||
create: (c) => new StubCamera("dahua", c, "/cgi-bin/snapshot.cgi"),
|
create: (c) =>
|
||||||
|
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { request as httpRequest } from "node:http";
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
|
||||||
|
// Client-side HTTP Digest auth (RFC 2617, MD5, qop=auth) for talking TO devices
|
||||||
|
// that challenge with `WWW-Authenticate: Digest` — e.g. Hikvision ISAPI cameras.
|
||||||
|
// (The server-side counterpart, which VERIFIES device→backend pushes, lives in
|
||||||
|
// apps/server/src/digest-auth.ts.) Devices on the isolated VLAN can't present a
|
||||||
|
// trusted TLS cert, so plain-HTTP Digest is the available auth: the password is
|
||||||
|
// never on the wire, only a nonce-keyed hash. See wiki/concepts/network-isolation.md.
|
||||||
|
|
||||||
|
const md5 = (s: string) => createHash("md5").update(s).digest("hex");
|
||||||
|
|
||||||
|
/** Parse a `WWW-Authenticate: Digest …` header into its k=v fields. */
|
||||||
|
function parseChallenge(header: string): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
const re = /(\w+)=(?:"([^"]*)"|([^,]*))/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(header))) out[m[1]!] = (m[2] ?? m[3] ?? "").trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the `Authorization: Digest …` response value for a challenge. */
|
||||||
|
function buildAuthHeader(
|
||||||
|
c: Record<string, string>,
|
||||||
|
user: string,
|
||||||
|
password: string,
|
||||||
|
method: string,
|
||||||
|
uri: string,
|
||||||
|
): string {
|
||||||
|
const realm = c.realm ?? "";
|
||||||
|
const nonce = c.nonce ?? "";
|
||||||
|
const qop = c.qop?.split(",")[0]?.trim(); // server may offer "auth,auth-int"
|
||||||
|
const ha1 = md5(`${user}:${realm}:${password}`);
|
||||||
|
const ha2 = md5(`${method}:${uri}`);
|
||||||
|
|
||||||
|
const parts: string[] = [
|
||||||
|
`username="${user}"`,
|
||||||
|
`realm="${realm}"`,
|
||||||
|
`nonce="${nonce}"`,
|
||||||
|
`uri="${uri}"`,
|
||||||
|
];
|
||||||
|
|
||||||
|
let response: string;
|
||||||
|
if (qop === "auth") {
|
||||||
|
const cnonce = randomBytes(8).toString("hex");
|
||||||
|
const nc = "00000001";
|
||||||
|
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
|
||||||
|
parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
|
||||||
|
} else {
|
||||||
|
// Legacy RFC 2069 (no qop) — Hikvision uses qop=auth, but be tolerant.
|
||||||
|
response = md5(`${ha1}:${nonce}:${ha2}`);
|
||||||
|
}
|
||||||
|
parts.push(`response="${response}"`);
|
||||||
|
if (c.opaque) parts.push(`opaque="${c.opaque}"`);
|
||||||
|
return `Digest ${parts.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestGetResult {
|
||||||
|
readonly status: number;
|
||||||
|
readonly contentType: string;
|
||||||
|
readonly body: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestGetOptions {
|
||||||
|
readonly host: string;
|
||||||
|
readonly port: number;
|
||||||
|
readonly path: string;
|
||||||
|
readonly user: string;
|
||||||
|
readonly password: string;
|
||||||
|
readonly timeoutMs: number;
|
||||||
|
/** Bind outbound to the device-facing NIC on a multi-homed host. */
|
||||||
|
readonly localAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOnce(
|
||||||
|
o: DigestGetOptions,
|
||||||
|
authHeader?: string,
|
||||||
|
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (authHeader) headers["authorization"] = authHeader;
|
||||||
|
const req = httpRequest(
|
||||||
|
{
|
||||||
|
host: o.host,
|
||||||
|
port: o.port,
|
||||||
|
path: o.path,
|
||||||
|
method: "GET",
|
||||||
|
timeout: o.timeoutMs,
|
||||||
|
localAddress: o.localAddress,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (c) => chunks.push(c as Buffer));
|
||||||
|
res.on("end", () => resolve({ res, body: Buffer.concat(chunks) }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.on("timeout", () => req.destroy(new Error("digest GET timeout")));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET a resource with HTTP Digest auth. Does the standard two-shot handshake:
|
||||||
|
* the first request (no Authorization) draws a 401 + challenge, the second
|
||||||
|
* carries the computed response. If the server doesn't challenge (200 straight
|
||||||
|
* away, or no auth required), the first response is returned as-is.
|
||||||
|
*/
|
||||||
|
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
||||||
|
const first = await getOnce(o);
|
||||||
|
if (first.res.statusCode !== 401) {
|
||||||
|
return {
|
||||||
|
status: first.res.statusCode ?? 0,
|
||||||
|
contentType: String(first.res.headers["content-type"] ?? ""),
|
||||||
|
body: first.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const challengeHeader = String(first.res.headers["www-authenticate"] ?? "");
|
||||||
|
if (!/^digest/i.test(challengeHeader)) {
|
||||||
|
// 401 but not Digest (e.g. Basic-only) — surface it; caller decides.
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
contentType: String(first.res.headers["content-type"] ?? ""),
|
||||||
|
body: first.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = parseChallenge(challengeHeader);
|
||||||
|
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
|
||||||
|
const second = await getOnce(o, auth);
|
||||||
|
return {
|
||||||
|
status: second.res.statusCode ?? 0,
|
||||||
|
contentType: String(second.res.headers["content-type"] ?? ""),
|
||||||
|
body: second.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
|
|
||||||
import { registry } from "../registry.js";
|
import { registry } from "../registry.js";
|
||||||
import { dingtianDriver } from "./access-dingtian.js";
|
import { dingtianDriver } from "./access-dingtian.js";
|
||||||
|
import { stubAccessDriver } from "./access-stub.js";
|
||||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
import { rongtaDriver } from "./printer-rongta.js";
|
import { rongtaDriver } from "./printer-rongta.js";
|
||||||
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
|
|
||||||
let registered = false;
|
let registered = false;
|
||||||
|
|
||||||
@@ -14,8 +15,10 @@ export function registerBuiltinDrivers(): void {
|
|||||||
if (registered) return;
|
if (registered) return;
|
||||||
registered = true;
|
registered = true;
|
||||||
registry.register(dingtianDriver);
|
registry.register(dingtianDriver);
|
||||||
|
registry.register(stubAccessDriver);
|
||||||
registry.register(wiegandReaderDriver);
|
registry.register(wiegandReaderDriver);
|
||||||
registry.register(tcpipReaderDriver);
|
registry.register(tcpipReaderDriver);
|
||||||
|
registry.register(geeQrReaderDriver);
|
||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
registry.register(dahuaDriver);
|
registry.register(dahuaDriver);
|
||||||
registry.register(rongtaDriver);
|
registry.register(rongtaDriver);
|
||||||
@@ -23,8 +26,10 @@ export function registerBuiltinDrivers(): void {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
dingtianDriver,
|
dingtianDriver,
|
||||||
|
stubAccessDriver,
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
|
geeQrReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
MonitorableDevice,
|
MonitorableDevice,
|
||||||
PrinterDevice,
|
PrinterDevice,
|
||||||
PrinterStatus,
|
PrinterStatus,
|
||||||
|
PrintReport,
|
||||||
TicketData,
|
TicketData,
|
||||||
} from "../interfaces.js";
|
} from "../interfaces.js";
|
||||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||||
@@ -39,30 +40,140 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the full ESC/POS byte stream for an entry ticket. */
|
// --- Scannable symbol (printer-generated, no image rendering) -----------------
|
||||||
function renderTicket(data: TicketData): Buffer {
|
// 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||||
|
function renderReport(report: PrintReport): Buffer {
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
INIT,
|
INIT,
|
||||||
|
SELECT_CP852,
|
||||||
ALIGN_CENTER,
|
ALIGN_CENTER,
|
||||||
BOLD_ON,
|
BOLD_ON,
|
||||||
DOUBLE_ON,
|
line(report.title),
|
||||||
line("PARKING"),
|
|
||||||
DOUBLE_OFF,
|
|
||||||
BOLD_OFF,
|
|
||||||
line(),
|
|
||||||
line(`Lane ${data.lane}`),
|
|
||||||
line(),
|
|
||||||
BOLD_ON,
|
|
||||||
line(data.ticketId),
|
|
||||||
BOLD_OFF,
|
BOLD_OFF,
|
||||||
ALIGN_LEFT,
|
ALIGN_LEFT,
|
||||||
line(),
|
line(),
|
||||||
line(`Issued: ${data.issuedAt}`),
|
...report.lines.map((l) => line(l)),
|
||||||
|
FEED_AND_CUT,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the park-identity header from site metadata. Prints the park name large
|
||||||
|
* (or "PARKING" if unset), then operator / NIUS / address lines that are present.
|
||||||
|
* NIUS and the rest only print when set. Non-ASCII renders via CP852 (see line()). */
|
||||||
|
function renderHeader(h: TicketData["header"]): Buffer {
|
||||||
|
const parts: Buffer[] = [ALIGN_CENTER, BOLD_ON, DOUBLE_ON, line(h?.parkName || "PARKING"), DOUBLE_OFF, BOLD_OFF];
|
||||||
|
if (h?.operatorName) parts.push(line(h.operatorName));
|
||||||
|
if (h?.nius) parts.push(line(STR.nius(h.nius)));
|
||||||
|
if (h?.address) {
|
||||||
|
// Address may be multi-line; print each line centered.
|
||||||
|
for (const ln of h.address.split(/\r?\n/)) if (ln.trim()) parts.push(line(ln.trim()));
|
||||||
|
}
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the full ESC/POS byte stream for an entry ticket.
|
||||||
|
* Header (park identity) → 1D Code128 barcode of the ticket id → the id in large
|
||||||
|
* digits → issue time → optional lost-ticket footer. Code128 is read by ANY legacy
|
||||||
|
* 1D barcode scanner the booth might have; the printed digits are the fallback if
|
||||||
|
* every reader fails (operator hand-keys the all-numeric code). Text is Albanian.
|
||||||
|
* See wiki/concepts/ticket-encoding.md and site-metadata.md. */
|
||||||
|
function renderTicket(data: TicketData): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
INIT,
|
||||||
|
SELECT_CP852,
|
||||||
|
renderHeader(data.header),
|
||||||
|
line(),
|
||||||
|
// The scannable barcode + the same code in large human-readable digits.
|
||||||
|
code128(data.ticketId),
|
||||||
|
line(),
|
||||||
|
BOLD_ON,
|
||||||
|
DOUBLE_ON,
|
||||||
|
line(data.ticketId),
|
||||||
|
DOUBLE_OFF,
|
||||||
|
BOLD_OFF,
|
||||||
|
line(),
|
||||||
|
line(STR.issuedAt(data.issuedAt)),
|
||||||
|
// Contact footer (lost-ticket help) if a phone is set.
|
||||||
|
...(data.header?.phone ? [line(STR.lostTicket(data.header.phone))] : []),
|
||||||
FEED_AND_CUT,
|
FEED_AND_CUT,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -201,7 +312,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
|
|
||||||
async printTicket(data: TicketData): Promise<void> {
|
async printTicket(data: TicketData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
||||||
stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`);
|
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async printReport(report: PrintReport): Promise<void> {
|
||||||
|
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||||
|
stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -58,3 +58,28 @@ export const tcpipReaderDriver: ReaderDriver = {
|
|||||||
configFields: [hostField, portField(9000)],
|
configFields: [hostField, portField(9000)],
|
||||||
create: (c) => new StubReader("tcpip-reader", c),
|
create: (c) => new StubReader("tcpip-reader", c),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// GEE/Fondvision QR access reader (e.g. GEE-QR-ER80). A PUSH device: on each scan
|
||||||
|
// it HTTP-GETs our backend (/qa/mcardsea.<ext>) carrying its serial (cjihao); the
|
||||||
|
// backend resolves the lane by matching that serial to this device's `serial`
|
||||||
|
// config, decides, and replies the verdict (drives the beep). No host-side
|
||||||
|
// connection — the adapter is a stub; the real integration is the HTTP endpoint
|
||||||
|
// (apps/server routes/qr-reader.ts). See wiki/entities/gee-qr-er80.md.
|
||||||
|
export const geeQrReaderDriver: ReaderDriver = {
|
||||||
|
id: "gee-qr-reader",
|
||||||
|
category: "reader",
|
||||||
|
label: "GEE/Fondvision QR reader (HTTP push)",
|
||||||
|
description:
|
||||||
|
"QR/barcode access reader that HTTP-pushes each scan to the backend. Set its server IP/port to this host in the vendor tool; enter its serial here so scans resolve to this lane.",
|
||||||
|
transports: ["tcp-ip"],
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "serial",
|
||||||
|
label: "Device serial (cjihao)",
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
create: (c) => new StubReader("gee-qr-reader", c),
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js";
|
|||||||
export {
|
export {
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
dingtianDriver,
|
dingtianDriver,
|
||||||
|
stubAccessDriver,
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
|
geeQrReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
|
|||||||
@@ -174,26 +174,52 @@ export interface CameraDevice extends Device {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SnapshotContext {
|
export interface SnapshotContext {
|
||||||
readonly lane: number;
|
|
||||||
readonly direction: "entry" | "exit";
|
readonly direction: "entry" | "exit";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Snapshot {
|
export interface Snapshot {
|
||||||
/** Storage reference for the captured image (file path / blob id). */
|
/** The captured image bytes. The DRIVER fetches them over the network; the
|
||||||
readonly imageRef: string;
|
* CALLER (entry/exit flow) owns storage and minting a durable reference —
|
||||||
|
* keeping the device adapter free of any filesystem/blob-store dependency. */
|
||||||
|
readonly bytes: Buffer;
|
||||||
readonly contentType: string;
|
readonly contentType: string;
|
||||||
readonly capturedAt: string; // ISO-8601
|
readonly capturedAt: string; // ISO-8601
|
||||||
|
/** Storage reference (file path / blob id), set once the caller has stored
|
||||||
|
* the bytes. Absent on the value the driver returns. */
|
||||||
|
readonly imageRef?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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 lane: number;
|
|
||||||
readonly issuedAt: string; // ISO-8601
|
readonly issuedAt: string; // ISO-8601
|
||||||
|
/** Park identity for the header. Absent → driver prints the generic "PARKING". */
|
||||||
|
readonly header?: TicketHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrinterDevice extends Device {
|
export interface PrinterDevice extends Device {
|
||||||
printTicket(data: TicketData): Promise<void>;
|
printTicket(data: TicketData): Promise<void>;
|
||||||
|
/** Print a free-form text report (a shift Z-report, a receipt). `lines` are
|
||||||
|
* printed as-is; the driver adds a header/cut. Kept generic so the business
|
||||||
|
* layer composes the content. See wiki/concepts/shift.md. */
|
||||||
|
printReport(report: PrintReport): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrintReport {
|
||||||
|
readonly title: string;
|
||||||
|
readonly lines: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Live printer status (consumable / mechanical faults) ----------------
|
// --- Live printer status (consumable / mechanical faults) ----------------
|
||||||
|
|||||||
@@ -27,8 +27,19 @@ export interface ConfigField {
|
|||||||
readonly help?: string;
|
readonly help?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A JSON-serializable config value. Mostly flat scalars (host, port, credentials),
|
||||||
|
* but some configs carry nested structure — e.g. an access controller's
|
||||||
|
* `relays: [{ relay, direction, button? }]` map. See entry-exit-points.md. */
|
||||||
|
export type ConfigValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| ConfigValue[]
|
||||||
|
| { [k: string]: ConfigValue };
|
||||||
|
|
||||||
/** Opaque per-instance config the admin fills in (host, port, credentials…). */
|
/** Opaque per-instance config the admin fills in (host, port, credentials…). */
|
||||||
export type DeviceConfig = Record<string, string | number | boolean>;
|
export type DeviceConfig = Record<string, ConfigValue>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A driver: metadata describing a supported device model/family, the config
|
* A driver: metadata describing a supported device model/family, the config
|
||||||
@@ -42,6 +53,13 @@ export interface DeviceDriver<T extends Device = Device> {
|
|||||||
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
|
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
|
||||||
readonly transports: readonly string[];
|
readonly transports: readonly string[];
|
||||||
readonly configFields: readonly ConfigField[];
|
readonly configFields: readonly ConfigField[];
|
||||||
|
/**
|
||||||
|
* True if the device calls BACK to our backend (HTTP push) and therefore needs
|
||||||
|
* a backend IP configured at assign time. Pull-only devices (cameras poll a
|
||||||
|
* snapshot, the relay is commanded) leave this false so the setup wizard hides
|
||||||
|
* the "Backend push IP" field. See wiki/concepts/device-input-flow.md.
|
||||||
|
*/
|
||||||
|
readonly pushesToBackend?: boolean;
|
||||||
/** Build a live adapter instance from validated config. */
|
/** Build a live adapter instance from validated config. */
|
||||||
create(config: DeviceConfig): T;
|
create(config: DeviceConfig): T;
|
||||||
}
|
}
|
||||||
@@ -130,6 +148,11 @@ class DeviceRegistry {
|
|||||||
}
|
}
|
||||||
return byCategory;
|
return byCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Driver ids that push to the backend (need a backend IP at assign time). */
|
||||||
|
pushCapable(): string[] {
|
||||||
|
return [...this.#drivers.values()].filter((d) => d.pushesToBackend).map((d) => d.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CatalogEntry {
|
export interface CatalogEntry {
|
||||||
|
|||||||
@@ -13,39 +13,210 @@ export type Direction = "entry" | "exit";
|
|||||||
export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual";
|
export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An append-only parking event. Records are never mutated; corrections are new
|
* A signed business-LEDGER event. Records are never mutated; corrections are new
|
||||||
* events. `prevHash` chains each event to the previous one; `signature` is the
|
* events. `prevHash` chains each event to the previous one; `signature` is the
|
||||||
* ATECC608 signature over the event contents. See wiki/append-only-event-chain.
|
* ATECC608 signature over the canonical contents (which INCLUDE `payload`).
|
||||||
|
* Distinct from device telemetry — see wiki/decisions/event-streams-split.md.
|
||||||
*/
|
*/
|
||||||
export interface ParkingEvent {
|
export interface LedgerEvent {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
readonly type: ParkingEventType;
|
readonly type: LedgerEventType;
|
||||||
readonly direction: Direction | null;
|
readonly direction: Direction | null;
|
||||||
readonly lane: number;
|
readonly lane: number;
|
||||||
readonly source: IdentitySource | null;
|
readonly source: IdentitySource | null;
|
||||||
/** Card number, plate, ticket id, etc. — depends on `source`. */
|
/** Card number, plate, ticket id, etc. — depends on `source`. */
|
||||||
readonly identity: string | null;
|
readonly identity: string | null;
|
||||||
|
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
|
||||||
|
readonly payload: LedgerPayload | null;
|
||||||
readonly occurredAt: string; // ISO-8601
|
readonly occurredAt: string; // ISO-8601
|
||||||
/** Hash of the previous event in the chain (hex). Null only for genesis. */
|
/** Hash of the previous event in the chain (hex). Null only for genesis. */
|
||||||
readonly prevHash: string | null;
|
readonly prevHash: string | null;
|
||||||
/** ATECC608 signature over the canonical event payload (hex). */
|
/** ATECC608 signature over the canonical event payload (hex). */
|
||||||
readonly signature: string;
|
readonly signature: string;
|
||||||
|
/** Which signer/key produced `signature` (verifiable across a signer swap). */
|
||||||
|
readonly keyId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParkingEventType =
|
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */
|
||||||
// A raw device input (e.g. a Dingtian button press) was received and recorded.
|
export type LedgerEventType =
|
||||||
// NOT a confirmed entry — the richer `vehicle_entry` is appended later by the
|
|
||||||
// entry flow once a ticket prints and the barrier is commanded.
|
|
||||||
| "input_received"
|
|
||||||
| "vehicle_entry"
|
| "vehicle_entry"
|
||||||
| "vehicle_exit"
|
| "vehicle_exit"
|
||||||
|
| "payment"
|
||||||
| "void"
|
| "void"
|
||||||
|
// Witness-grade: a host-commanded open, and an independently-observed open
|
||||||
|
// (loop/sensor) — reconciled against each other.
|
||||||
| "barrier_open_command"
|
| "barrier_open_command"
|
||||||
| "barrier_open_observed"
|
| "barrier_open_observed"
|
||||||
|
// Manned-mode shift boundary: an operator takes over (shift_open) / hands over
|
||||||
|
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
|
||||||
|
| "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). */
|
||||||
|
export type Tender = "cash" | "card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-specific data carried on a ledger event's `payload`. All amounts are
|
||||||
|
* integer minor units in the named currency — never floats. Fields are optional
|
||||||
|
* because they're event-type-specific; the producer fills what applies.
|
||||||
|
*/
|
||||||
|
export interface LedgerPayload {
|
||||||
|
/** The parking_session this event concerns (entry/exit/payment/void). */
|
||||||
|
readonly sessionRef?: string;
|
||||||
|
/** payment: amount in minor units, its currency, and how it was tendered. */
|
||||||
|
readonly amountMinor?: number;
|
||||||
|
readonly currency?: string;
|
||||||
|
readonly tender?: Tender;
|
||||||
|
/** payment: which tariff_version priced it (reproducible repricing). */
|
||||||
|
readonly tariffVersionId?: string;
|
||||||
|
/** payment: gross/discount/net split when a validation applied. */
|
||||||
|
readonly grossMinor?: number;
|
||||||
|
readonly discountMinor?: number;
|
||||||
|
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
||||||
|
readonly fxRate?: number | null;
|
||||||
|
/** void / anomaly / override: a human/machine reason code. */
|
||||||
|
readonly reason?: string;
|
||||||
|
/** plate/vehicle from the vision service (advisory). */
|
||||||
|
readonly plate?: string;
|
||||||
|
readonly plateConfidence?: number;
|
||||||
|
/** Free-form for forward-compat without a schema change. */
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
|
||||||
|
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composable rate card stored in a tariff_version.structure. Pure data the
|
||||||
|
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace;
|
||||||
|
* a flat rate is just one block. See wiki/concepts/tariff.md.
|
||||||
|
*/
|
||||||
|
export interface TariffStructure {
|
||||||
|
/** Free if exited within this (drop-off/turnaround). */
|
||||||
|
readonly gracePeriodEntryMin: number;
|
||||||
|
/** Billing granularity; partial increments round UP. */
|
||||||
|
readonly incrementMin: number;
|
||||||
|
/** Consumed in order as duration accrues; last block may be open-ended. */
|
||||||
|
readonly blocks: readonly TariffBlock[];
|
||||||
|
/** Cap per rolling 24h (null = no cap). */
|
||||||
|
readonly dailyCapMinor: number | null;
|
||||||
|
/** Flat charge when there's no entry id (admin may override at the moment). */
|
||||||
|
readonly lostTicketMinor: number;
|
||||||
|
/** Pay-on-foot walk-back window: minutes after payment to reach the car. */
|
||||||
|
readonly gracePeriodExitMin: number;
|
||||||
|
/** How an overstay top-up is charged. "reprice" = recompute(entry→now) − paid. */
|
||||||
|
readonly overstay: "reprice";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TariffBlock {
|
||||||
|
/** Upper bound of this block in minutes; null = open-ended (thereafter). */
|
||||||
|
readonly uptoMin: number | null;
|
||||||
|
readonly priceMinorPerIncrement: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the parking fee (integer minor units) for a stay, from a TariffStructure.
|
||||||
|
* PURE + deterministic + offline — the pay station calls it with asOf = now; the
|
||||||
|
* result is fixed into a signed `payment` event, so it must be reproducible.
|
||||||
|
*
|
||||||
|
* Algorithm (wiki/concepts/tariff.md): round duration UP to incrementMin; free if
|
||||||
|
* within entry grace; else walk the stay one rolling-24h segment at a time, charging
|
||||||
|
* each increment at its block's rate (blocks consumed in order by cumulative minutes),
|
||||||
|
* capping each segment at dailyCapMinor. Times are ISO-8601; bad input → 0 (caller
|
||||||
|
* validates the tariff exists first).
|
||||||
|
*/
|
||||||
|
export function computeFee(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructure,
|
||||||
|
): number {
|
||||||
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
|
const rawMinutes = ms / 60_000;
|
||||||
|
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
||||||
|
// 60 min — otherwise rounding-up would defeat the grace window).
|
||||||
|
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
|
||||||
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||||
|
|
||||||
|
const DAY = 24 * 60;
|
||||||
|
let total = 0;
|
||||||
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
|
let segFee = 0;
|
||||||
|
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
||||||
|
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
||||||
|
for (let within = 0; segStart + within < segEnd; within += inc) {
|
||||||
|
segFee += rateAt(tariff.blocks, within);
|
||||||
|
}
|
||||||
|
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
|
||||||
|
total += segFee;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an admin-authored tariff structure. Returns [] if valid, else a list
|
||||||
|
* of human-readable problems. Pure — used by the composer route (and any caller)
|
||||||
|
* so a malformed rate card can never be published. See wiki/concepts/tariff.md.
|
||||||
|
*/
|
||||||
|
export function validateTariffStructure(s: unknown): string[] {
|
||||||
|
const errs: string[] = [];
|
||||||
|
if (!s || typeof s !== "object") return ["structure must be an object"];
|
||||||
|
const t = s as Partial<TariffStructure>;
|
||||||
|
|
||||||
|
const nonNegInt = (v: unknown, label: string) => {
|
||||||
|
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
|
||||||
|
};
|
||||||
|
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin");
|
||||||
|
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin");
|
||||||
|
nonNegInt(t.lostTicketMinor, "lostTicketMinor");
|
||||||
|
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||||||
|
errs.push("incrementMin must be a positive integer");
|
||||||
|
}
|
||||||
|
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor");
|
||||||
|
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||||
|
|
||||||
|
if (!Array.isArray(t.blocks) || t.blocks.length === 0) {
|
||||||
|
errs.push("blocks must be a non-empty array");
|
||||||
|
} else {
|
||||||
|
let prevBound = 0;
|
||||||
|
t.blocks.forEach((b, i) => {
|
||||||
|
const last = i === t.blocks!.length - 1;
|
||||||
|
nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`);
|
||||||
|
if (b?.uptoMin == null) {
|
||||||
|
if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`);
|
||||||
|
} else {
|
||||||
|
if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
|
||||||
|
errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
|
||||||
|
} else {
|
||||||
|
prevBound = b.uptoMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price of the increment that starts at `cumulativeMin` — the block whose range
|
||||||
|
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
|
||||||
|
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||||
|
let prev = 0;
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.uptoMin == null || cumulativeMin < b.uptoMin) return b.priceMinorPerIncrement;
|
||||||
|
prev = b.uptoMin;
|
||||||
|
void prev;
|
||||||
|
}
|
||||||
|
// No open-ended block and past the last bound: charge the last block's rate.
|
||||||
|
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export const ROLES: readonly Role[] = [
|
export const ROLES: readonly Role[] = [
|
||||||
"admin",
|
"admin",
|
||||||
"operator",
|
"operator",
|
||||||
|
|||||||
Generated
+1267
-7
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, domain, business, anti-fraud, access-control]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-15
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Anti-Passback
|
||||||
|
|
||||||
|
Stop one credential/ticket from getting **two cars in** without an exit between — the classic
|
||||||
|
"pass the card/ticket back over the fence" abuse. A control on the entry validation, leaning on the
|
||||||
|
session projection.
|
||||||
|
|
||||||
|
## The rule
|
||||||
|
|
||||||
|
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an
|
||||||
|
OPEN [[parking-session|session]].** At entry:
|
||||||
|
|
||||||
|
```
|
||||||
|
identify vehicle → is there already an OPEN session for this id?
|
||||||
|
no → proceed (mint vehicle_entry, open)
|
||||||
|
yes → passback violation → refuse or flag (see policy)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
|
||||||
|
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
|
||||||
|
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`.
|
||||||
|
|
||||||
|
## Interaction with the limits already designed
|
||||||
|
|
||||||
|
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
|
||||||
|
id is always a violation (or a re-print/duplication attempt).
|
||||||
|
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Policy (operator choice)
|
||||||
|
|
||||||
|
- **Hard** — refuse the second entry (strict; risks stranding a legitimate car after a *missed
|
||||||
|
exit*, which is common — tailgated out, sensor missed).
|
||||||
|
- **Soft** — allow but **flag an `anomaly`** (the type exists) for review. Safer against
|
||||||
|
false-positives from missed exits, consistent with the append-only "record + flag, don't block"
|
||||||
|
ethos elsewhere.
|
||||||
|
- Likely **soft by default**, hard as an opt-in for high-control sites.
|
||||||
|
|
||||||
|
## Honest limits
|
||||||
|
|
||||||
|
- Depends on **reliable exit detection** — if exits are routinely missed (no exit loop/plate read),
|
||||||
|
passback produces false positives; tune to the site's exit fidelity.
|
||||||
|
- A spoofed/duplicated ticket QR is caught here (same id already open) — complements
|
||||||
|
[[ticket-encoding]]'s opaque-id requirement.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- Default policy (soft/hard) and per-site override.
|
||||||
|
- Grace for legitimate quick re-entry vs. the missed-exit false-positive.
|
||||||
@@ -21,9 +21,25 @@ Three layered properties:
|
|||||||
self-consistent — someone who owns the machine still cannot forge a valid entry.
|
self-consistent — someone who owns the machine still cannot forge a valid entry.
|
||||||
|
|
||||||
It only becomes trustworthy as an external fraud control when paired with [[reconciliation]]
|
It only becomes trustworthy as an external fraud control when paired with [[reconciliation]]
|
||||||
against an authority the operator can't alter. Every device event — including those ingested
|
against an authority the operator can't alter.
|
||||||
from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side
|
|
||||||
chain.
|
## Two event streams — the signed ledger vs. device telemetry (decision 2026-06-15)
|
||||||
|
|
||||||
|
These are **different concerns and live in different tables**:
|
||||||
|
|
||||||
|
- **`ledger_events`** — this signed, hash-chained, [[atecc608]]-signed **business ledger**:
|
||||||
|
`vehicle_entry` / `vehicle_exit` / `payment` / `void` / `shift_z_report`, plus the witness-grade
|
||||||
|
`barrier_open_command` / `barrier_open_observed` and `anomaly`. This is the anti-fraud record that
|
||||||
|
[[reconciliation]] runs against; sessions/[[tariff]]/occupancy are projections over it. (This is
|
||||||
|
the table formerly called `events`.)
|
||||||
|
- **`device_events`** — **unsigned operational telemetry**: relay fired, printer paper-out, camera
|
||||||
|
offline, reader read, raw input edges. High-volume, churny, **not** anti-fraud; may rotate/prune.
|
||||||
|
Keeping it out of the signed chain keeps the ledger small and high-value.
|
||||||
|
|
||||||
|
> A raw button press is **device telemetry**, not a business fact. It lands in `device_events`; the
|
||||||
|
> entry flow then mints a **signed `vehicle_entry`** in the ledger once a ticket prints and the
|
||||||
|
> barrier is commanded. (This supersedes the earlier "every device event lands in the chain" framing
|
||||||
|
> and the `input_received`-as-signed-event approach — see [[device-input-flow]].)
|
||||||
|
|
||||||
## Implementation (apps/server)
|
## Implementation (apps/server)
|
||||||
|
|
||||||
@@ -58,22 +74,28 @@ so old events stay verifiable.
|
|||||||
> *accidental* corruption, but an operator with the signing key + DB access could re-sign a
|
> *accidental* corruption, but an operator with the signing key + DB access could re-sign a
|
||||||
> forged chain. This is the central reason #6 matters.
|
> forged chain. This is the central reason #6 matters.
|
||||||
|
|
||||||
### What currently feeds the log
|
### Business-layer event types (the ledger)
|
||||||
|
|
||||||
Dingtian **input (button) pushes** → bus → `input_received` events (see [[device-input-flow]],
|
The [[parking-session]] domain folds over these **signed ledger** events:
|
||||||
[[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` —
|
|
||||||
the richer entry event waits for the entry flow (ticket print + barrier command).
|
|
||||||
|
|
||||||
- **`lane`** is now resolved from the firing device. A `LaneMap` (`apps/server/src/lane-map.ts`)
|
- `vehicle_entry` / `vehicle_exit` — a stay's endpoints; `identity` carries the ticket id or plate.
|
||||||
caches `lane_devices.id → lane`, built at startup and refreshed by the setup routes on every
|
- `payment` — a settled fee at the pay station, referencing the session it pays for (amount in
|
||||||
assign/unassign. Device events carry the device instance id, not a lane; the handler looks it
|
integer minor units; see [[tariff]]). Making "paid" a signed event — not a mutable row — is the
|
||||||
up. A device with no mapping (assigned without a lane, or a stale id) logs **`lane: -1`** and a
|
whole point: an operator can't forge it or silently delete it.
|
||||||
warning — never `0`, which is a real lane — and is still recorded (the chain is append-only;
|
- `void` — a correction / lost-ticket write-off; like every other void here it is an **appended
|
||||||
nothing is dropped).
|
event, never an erasure**.
|
||||||
- **`source` stays `null`** for `input_received`, and deliberately so: `source` is an
|
- `shift_z_report` — the signed per-[[shift]] takings summary.
|
||||||
`IdentitySource` (`wiegand | lpr | qr | ticket | manual`) — *how a vehicle was identified* — not
|
|
||||||
a device/IP field. A raw button push has no vehicle identity. The device provenance lives in
|
A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason
|
||||||
**`identity`** (e.g. `dingtian:<id> input:1/on`).
|
the chain exists. See [[parking-session]].
|
||||||
|
|
||||||
|
### As-built (table split done)
|
||||||
|
|
||||||
|
The split above is implemented: raw Dingtian **input (button) pushes** are **device telemetry** in
|
||||||
|
**`device_events`** (unsigned, prunable), keyed to the firing `devices` instance. Only the business
|
||||||
|
`vehicle_entry` the press drives is signed into **`ledger_events`**. The signed events carry **no
|
||||||
|
`lane`** — the pool-of-spaces model has none (dropped 2026-06-16; see [[entry-exit-points]]), and
|
||||||
|
the canonical form bumped `sw-hmac-v1` → `sw-hmac-v2` accordingly.
|
||||||
|
|
||||||
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
|
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
|
||||||
|
|
||||||
@@ -91,7 +113,9 @@ host. **Proven on hardware**: a binary relay command sent directly to the device
|
|||||||
So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** —
|
So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** —
|
||||||
the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an
|
the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an
|
||||||
**independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input
|
**independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input
|
||||||
→ which DOES push + log; the [[lpr-camera]]; payment/Z-report). **A physical open with no matching
|
→ which DOES push + log; the [[opencv-anpr-service|vision service]]'s plate **and vehicle** read;
|
||||||
signed command is the fraud signal.** Both the witness sources and the reconciliation logic are
|
payment/Z-report). **A physical open with no matching signed command is the fraud signal** — and,
|
||||||
**NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't
|
with vehicle verification, **a plate that enters/exits on a different car** is too (the
|
||||||
|
plate-spoofing case). Both the witness sources and the reconciliation logic are **NOT yet built** —
|
||||||
|
this is the main open gap. Prevention (VLAN isolation so the attacker can't
|
||||||
reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop.
|
reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop.
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, domain, booth, exit, payment, threat-model]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-17
|
||||||
|
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** — entered, no `vehicle_exit` yet (still inside), 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 exited **and** past grace (presumed truly gone).
|
||||||
|
|
||||||
|
### 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")
|
||||||
|
— **NEVER a second `vehicle_exit`** (a second exit would double-count occupancy and corrupt the
|
||||||
|
ledger's meaning). It is an audited *re-open*, not a new exit.
|
||||||
|
|
||||||
|
**Guard — no payment, no button.** The "Open barrier" action is shown/active **only for sessions that
|
||||||
|
have a payment** (paid, or paid-and-exited-in-grace). An **unpaid** open session has **no barrier-open
|
||||||
|
affordance at all** — the row routes to the [[#operator-flow|pay/exit modal]] instead. The
|
||||||
|
no-unpaid-bypass rule is enforced structurally: the button simply does not exist for an unpaid car.
|
||||||
|
(A future reason-required *force exit* for genuine disputes would be a separately-audited path — see
|
||||||
|
Open.)
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, domain, business, occupancy]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-15
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Capacity & Occupancy
|
||||||
|
|
||||||
|
How many vehicles are inside, how many spaces remain, and what happens when the lot is full.
|
||||||
|
|
||||||
|
## Occupancy is a projection (like everything else)
|
||||||
|
|
||||||
|
`occupancy = count(open [[parking-session|sessions]])` — an entry with no matching exit. It is a
|
||||||
|
**fold over the signed [[append-only-event-chain]]**, never a hand-maintained counter (a counter is
|
||||||
|
editable and drifts; the chain is the truth). Spaces-free = `capacity − occupancy`.
|
||||||
|
|
||||||
|
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
|
||||||
|
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
|
||||||
|
- Permit concurrency (`maxConcurrent`, see [[permit]]) is the same kind of fold, scoped to one
|
||||||
|
permit's open sessions.
|
||||||
|
|
||||||
|
## Full → refuse entry + FULL sign
|
||||||
|
|
||||||
|
- When `occupancy ≥ capacity`, the entry flow **refuses** (no `vehicle_entry`, no barrier open) and
|
||||||
|
can drive a **"FULL" sign** (a relay/output, via the device adapter layer).
|
||||||
|
- **Safety/policy nuance:** "full" blocks *entry* only — **exit always works** ([[fail-state-safety]]:
|
||||||
|
exit fails open; never trap a vehicle). Permit holders may be allowed in past a "transient full"
|
||||||
|
threshold (reserve spaces for subscribers) — an optional policy knob.
|
||||||
|
- **Counting drift is real:** tailgating (two cars, one entry) and missed reads make the live count
|
||||||
|
diverge from physical reality. The count is the *system's* occupancy; periodic ground-truth (a
|
||||||
|
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
|
||||||
|
not silently corrected.
|
||||||
|
|
||||||
|
## "Full" is a soft, operator-configurable policy
|
||||||
|
|
||||||
|
Refusing at capacity is the **default**, not an absolute. An operator may opt into
|
||||||
|
**[[valet-overcapacity|valet over-capacity]]** — accept the car into operator custody (keys handed
|
||||||
|
over, stacked beyond the marked count) instead of refusing. So the FULL gate is a policy knob
|
||||||
|
(refuse vs. valet-accept), set by the operator per site. Valet is a manned-mode feature with its
|
||||||
|
own custody/session shape — see [[valet-overcapacity]] (deferred).
|
||||||
|
|
||||||
|
## As-built (2026-06-16)
|
||||||
|
|
||||||
|
- **Occupancy** = `occupancyCount` (`apps/server/src/occupancy.ts`): a fold over the ledger —
|
||||||
|
entries minus exits per identity, count those `> 0`. `getOccupancy` returns `{count, capacity,
|
||||||
|
free, full}`.
|
||||||
|
- **Capacity** is a single-row `site_config` table (admin-set; `null` = uncapped). Routes
|
||||||
|
(`routes/site.ts`): `GET /api/occupancy` + `GET /api/site-config` (any role), `PUT /api/site-config`
|
||||||
|
(admin; non-negative int or null).
|
||||||
|
- **FULL gate** is in the **transient entry flow**: `occupancy.full` → refuse (no ticket, no
|
||||||
|
`vehicle_entry`, no open) + signed `anomaly`. **Permit entry is NOT gated** here — subscribers are
|
||||||
|
admitted past transient-full (their own `maxConcurrent` still applies); occupancy can read
|
||||||
|
over-capacity (`free` negative) when permits enter a full lot, as intended.
|
||||||
|
- **UI** `SiteSettings`: live occupancy + FULL badge (everyone); capacity editor (admin).
|
||||||
|
- Verified: fill to cap → 3rd transient refused; permit still admitted past full; exit frees a
|
||||||
|
slot; RBAC (operator can't set capacity); verifyChain ok. Physical FULL-sign relay output is
|
||||||
|
**deferred** (needs a sign device).
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- Zone/level granularity at launch vs. single capacity number.
|
||||||
|
- Reserve-for-permits **threshold** (a soft transient cap below the hard capacity) — currently
|
||||||
|
permits are simply ungated; a tunable threshold is the richer version.
|
||||||
|
- Physical FULL-sign relay output (a sign-device role).
|
||||||
|
- The valet over-capacity mode + custody model ([[valet-overcapacity]]).
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, security, integrity, offline-first, anti-fraud]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-15
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clock Integrity
|
||||||
|
|
||||||
|
Fees are a function of **time** ([[tariff]]: `fee = f(enteredAt, asOf)`), and the event chain is
|
||||||
|
ordered/timestamped. So **the host clock is part of the trust model** — and on an offline appliance
|
||||||
|
([[offline-first]], no NTP guarantee) it's a real attack surface, fitting the
|
||||||
|
[[threat-model|operator-as-adversary]] frame:
|
||||||
|
|
||||||
|
- **Backdating to cut a fee** — wind the clock back so a long stay computes as short, or so an exit
|
||||||
|
timestamps before its entry.
|
||||||
|
- **Forward/backward jumps** that corrupt durations, the rolling-24h cap, or shift boundaries
|
||||||
|
([[shift]]).
|
||||||
|
- An operator with host access changing the system time deliberately.
|
||||||
|
|
||||||
|
## What protects it
|
||||||
|
|
||||||
|
- **Monotonic chain order is independent of wall-clock.** The [[append-only-event-chain]] `index`
|
||||||
|
is strictly increasing regardless of timestamps, so **reordering** is caught even if timestamps
|
||||||
|
are forged. But the *durations* used for pricing still rely on the wall clock — so:
|
||||||
|
- **Detect clock anomalies and record them as events.** A timestamp that goes **backwards** between
|
||||||
|
consecutive chain events, or jumps implausibly, is an `anomaly` (the type already exists) — signed
|
||||||
|
and surfaced to [[reconciliation]], not silently accepted.
|
||||||
|
- **Hardware-backed time where possible.** A battery-backed RTC on the appliance; the
|
||||||
|
[[atecc608]]/secure element and [[disk-os-hardening]] reduce casual tampering. An operator
|
||||||
|
changing time should require privilege the booth login doesn't have.
|
||||||
|
- **Opportunistic trusted sync** when a [[reconciliation]] channel is briefly online (the same
|
||||||
|
USB/hotspot path) — set/check the clock against an external authority, log any correction as an
|
||||||
|
event.
|
||||||
|
|
||||||
|
## Stance
|
||||||
|
|
||||||
|
Like the rest of the system: **prevention (hardened host, privileged-only time change) first,
|
||||||
|
detection (anomaly on clock regression, reconciliation) as the backstop.** The clock can't be made
|
||||||
|
unforgeable on an offline box, but a forged clock can be made **visible**.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- RTC / time source on the chosen appliance ([[bom]]).
|
||||||
|
- Tolerance thresholds for "implausible" jumps before flagging.
|
||||||
|
- Whether to hard-refuse an event on a backwards clock vs. record-and-flag (record-and-flag matches
|
||||||
|
the append-only ethos — never drop).
|
||||||
@@ -33,6 +33,6 @@ principle. The choice of *which* adapter to trust is the [[trust-boundary]] deci
|
|||||||
|
|
||||||
> **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the
|
> **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the
|
||||||
> supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras),
|
> supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras),
|
||||||
> and the admin assigns one per lane during [[first-run-setup]]. Adding hardware support = one
|
> and the admin assigns instances during [[first-run-setup]]. Adding hardware support = one
|
||||||
> more registered driver, no business-logic change. (The implemented interfaces add a
|
> more registered driver, no business-logic change. (The implemented interfaces add a
|
||||||
> `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.)
|
> `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ replies), so the driver **serializes** all controller I/O. Override the broadcas
|
|||||||
2. Admin clicks **Scan** → `GET /api/setup/discover/:driverId` (admin-only).
|
2. Admin clicks **Scan** → `GET /api/setup/discover/:driverId` (admin-only).
|
||||||
3. The server runs `discover()` and **health-checks each found device** so the admin sees
|
3. The server runs `discover()` and **health-checks each found device** so the admin sees
|
||||||
reachability before assigning.
|
reachability before assigning.
|
||||||
4. Selecting a result **auto-fills serial + host**; the admin then assigns it to a lane.
|
4. Selecting a result **auto-fills serial + host**; the admin then assigns + binds it.
|
||||||
|
|
||||||
## Deployment notes
|
## Deployment notes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, devices, monitoring, telemetry]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-15
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Device Events (telemetry)
|
||||||
|
|
||||||
|
The **unsigned** operational record of what the hardware did and reported — distinct from the
|
||||||
|
signed business [[append-only-event-chain|ledger]] (see [[event-streams-split]]). For monitoring,
|
||||||
|
diagnostics, and live booth status — **not** anti-fraud.
|
||||||
|
|
||||||
|
## What lands here
|
||||||
|
|
||||||
|
- **Relays/barriers:** relay fired/released, pulseOpen issued (the *device-side* echo; the
|
||||||
|
authoritative `barrier_open_command` is a signed ledger event).
|
||||||
|
- **Printers:** paper-out / near-end / cover-open / cutter / offline (already polled —
|
||||||
|
[[printer-status-monitoring]]).
|
||||||
|
- **Cameras:** reachable/offline, snapshot success/failure ([[lpr-camera]]).
|
||||||
|
- **Readers / inputs:** a raw read, raw input edges (Dingtian button `input N on/off` —
|
||||||
|
[[device-input-flow]]).
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
|
||||||
|
- **Unsigned, not chained** — no `prevHash`/`signature`. It's telemetry, so it carries none of the
|
||||||
|
ledger's integrity machinery.
|
||||||
|
- **Disposable** — high-volume and churny; **may rotate/prune** on a retention policy (the ledger
|
||||||
|
never does).
|
||||||
|
- **Device-keyed** — references the `devices` instance (raw device provenance). No `lane`
|
||||||
|
(pool-of-spaces model — see [[entry-exit-points]]).
|
||||||
|
|
||||||
|
## The boundary that matters
|
||||||
|
|
||||||
|
A device event is *evidence the host saw something happen*; it does **not** by itself authorize or
|
||||||
|
record a business fact. A button press here becomes a **signed `vehicle_entry`** in the ledger only
|
||||||
|
after the entry flow runs (ticket + barrier command). This keeps device chatter on the device side
|
||||||
|
of the [[device-adapter-pattern|adapter boundary]] and the signed ledger focused on money/access.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- Retention/rotation policy (size- or age-based).
|
||||||
|
- Whether any witness-grade device fact (e.g. a loop-sensor `barrier_open_observed`) should *also*
|
||||||
|
write a signed ledger entry for [[reconciliation]] — see [[append-only-event-chain]].
|
||||||
@@ -39,7 +39,7 @@ neither is the real boundary:
|
|||||||
|
|
||||||
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
|
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
|
||||||
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
|
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
|
||||||
device + stored in `lane_devices` by the harden step (below).
|
device + stored in `devices` by the harden step (below).
|
||||||
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
|
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
|
||||||
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
|
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
|
||||||
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
|
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
|
||||||
@@ -55,7 +55,7 @@ fix preconditions (disable `input_link_relay`) → **harden** → set up input p
|
|||||||
capability ([[device-registry|HardenableDevice]]):
|
capability ([[device-registry|HardenableDevice]]):
|
||||||
|
|
||||||
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
|
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
|
||||||
`lane_devices` so the backend can keep commanding the relay.
|
`devices` so the backend can keep commanding the relay.
|
||||||
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
|
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
|
||||||
binary (relay control) + UDP2 string (status read) — fewer open doors.
|
binary (relay control) + UDP2 string (status read) — fewer open doors.
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ the clear. We **empirically tested the device** to pick the strongest achievable
|
|||||||
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
|
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
|
||||||
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
|
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
|
||||||
generated by the backend on **device assign**, written to the device's `input_link_url` config,
|
generated by the backend on **device assign**, written to the device's `input_link_url` config,
|
||||||
and stored in `lane_devices` — the admin never types a URL or secret. HTTPS would be stronger but
|
and stored in `devices` — the admin never types a URL or secret. HTTPS would be stronger but
|
||||||
the device can't do it here; Digest + the signed log is the practical answer on a flat network.
|
the device can't do it here; Digest + the signed log is the practical answer on a flat network.
|
||||||
See `apps/server/src/digest-auth.ts`.
|
See `apps/server/src/digest-auth.ts`.
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ updated: 2026-06-15
|
|||||||
How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to
|
How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to
|
||||||
"**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported
|
"**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported
|
||||||
**drivers**, grouped by category; the [[first-run-setup]] UI reads it so an
|
**drivers**, grouped by category; the [[first-run-setup]] UI reads it so an
|
||||||
operator can choose a device per lane and fill in its connection config.
|
operator can choose a device and fill in its connection config.
|
||||||
|
|
||||||
> Implementation-derived (from `packages/devices`), not the source doc.
|
> Implementation-derived (from `packages/devices`), not the source doc.
|
||||||
|
|
||||||
@@ -33,10 +33,11 @@ driver; **no business-logic change** — this is the [[device-adapter-pattern]]
|
|||||||
|
|
||||||
## Why a registry (not hard-coded wiring)
|
## Why a registry (not hard-coded wiring)
|
||||||
|
|
||||||
- The admin chooses between **multiple devices per category** at install time, per lane
|
- The admin chooses between **multiple devices per category** at install time
|
||||||
(mirrors the "mixable per lane" principle — see [[trust-boundary]], [[entry-exit-readers]]).
|
(a controller's relays mix entry/exit; readers bind to them — see [[entry-exit-points]],
|
||||||
|
[[trust-boundary]], [[entry-exit-readers]]).
|
||||||
- Config is **validated against the driver's declared fields** before persisting.
|
- Config is **validated against the driver's declared fields** before persisting.
|
||||||
- Selections persist in the `lane_devices` table and drive runtime adapter construction.
|
- Selections persist in the `devices` table and drive runtime adapter construction.
|
||||||
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
|
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
|
||||||
the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did,
|
the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did,
|
||||||
before removal; the [[dingtian-relay]] uses a fixed IP).
|
before removal; the [[dingtian-relay]] uses a fixed IP).
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, architecture, devices, setup]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Entry / Exit Points (pool-of-spaces model)
|
||||||
|
|
||||||
|
A parking lot is **one pool of spaces** with a flexible set of **entry points** and **exit
|
||||||
|
points** — any number of each, in any combination (1 in + 1 out, 1 in + 2 out, 2 in + 1 out, …).
|
||||||
|
There is **no "lane"** concept anywhere in the system (dropped 2026-06-16 — see below).
|
||||||
|
|
||||||
|
## Direction lives on the relay, not the controller
|
||||||
|
|
||||||
|
An access controller (e.g. a [[dingtian-relay]] board) has **several relays** — each relay opens
|
||||||
|
one barrier. Direction is a property of **each relay**, declared in the controller's config:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// access `devices` row — one Dingtian board
|
||||||
|
config: {
|
||||||
|
host: "192.168.1.100",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1 }, // entry barrier; entry button on input 1
|
||||||
|
{ relay: 2, direction: "exit" } // exit barrier; opened by a reader, no button
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `direction`: `entry` | `exit` | `both` (`both` = one barrier/relay serving in and out).
|
||||||
|
- `button`: the **input terminal** the transient **entry button** is wired to. Only entry/both
|
||||||
|
relays have one. Absent = no button at that barrier (subscriber/reader-driven only).
|
||||||
|
|
||||||
|
The four real layouts all fall out of this:
|
||||||
|
|
||||||
|
| Layout | Controllers | Relays |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 barrier, both directions | 1 | `{relay:1, both, button:1}` |
|
||||||
|
| 2 barriers, 1 board | 1 | `{relay:1, entry, button:1}`, `{relay:2, exit}` |
|
||||||
|
| 2 barriers far apart | 2 | board A `{relay:1, entry}`, board B `{relay:1, exit}` |
|
||||||
|
| 1 entry + 2 exit | 3 | A entry; B, C each exit |
|
||||||
|
|
||||||
|
## Readers / cameras BIND to a relay
|
||||||
|
|
||||||
|
A reader or camera points at the barrier it physically sits at, via its config:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
config: { ...readerConfig, controllerId: "<access devices.id>", relay: 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Its **direction is inherited** from that relay. So an exit read opens **exactly that relay** —
|
||||||
|
no ambiguity even with multiple exit barriers ("the relay at that reader", decided 2026-06-16).
|
||||||
|
Binding is optional: an unbound device falls back to a `config.direction` + the first relay
|
||||||
|
site-wide of that direction (keeps the single-barrier case trivial). LPR is a snapshot sink —
|
||||||
|
an ANPR service ([[opencv-anpr-service]]) POSTs the plate as a `plate` read to the reader
|
||||||
|
endpoint, flowing through the same dispatcher.
|
||||||
|
|
||||||
|
## Resolution (one module: `apps/server/src/device-resolve.ts`)
|
||||||
|
|
||||||
|
- **Button press** → `relayForButton(controllerId, terminal)` → the entry relay whose `button`
|
||||||
|
matches → entry flow → `pulseOpen(relay)`.
|
||||||
|
- **Reader/permit/LPR read** → `relayForDevice(reader)` → the bound relay → `pulseOpen(relay)`;
|
||||||
|
direction inherited.
|
||||||
|
- **Snapshots** → `devicesByDirection("camera", dir)` → every camera serving that direction.
|
||||||
|
|
||||||
|
A directional barrier that contradicts the car's open-session state (an exit barrier scanned by a
|
||||||
|
car not inside, or an entry barrier by a car already in) is a wrong-barrier / [[anti-passback]]
|
||||||
|
refusal. A `both` relay defers to session state.
|
||||||
|
|
||||||
|
## The flows
|
||||||
|
|
||||||
|
| Flow | Trigger | Opens |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Transient entry | entry **button** press | the entry relay (button-mapped) → ticket prints |
|
||||||
|
| Transient exit | voucher scan at exit reader | the exit relay (reader-bound), if paid+grace |
|
||||||
|
| Subscriber entry | QR/RFID/plate at entry reader | the entry relay (reader-bound), if permit valid |
|
||||||
|
| Subscriber exit | QR/RFID/plate at exit reader | the exit relay (reader-bound), if permit valid |
|
||||||
|
|
||||||
|
Every open also fires a [[camera snapshot|append-only-event-chain]] (async, never blocks the open).
|
||||||
|
|
||||||
|
## Why no lane
|
||||||
|
|
||||||
|
"Lane" was a leftover from a rows-of-gates mental model. It added nothing here:
|
||||||
|
|
||||||
|
- **Occupancy** is a site-wide fold over the ledger (entries − exits); it never grouped by lane.
|
||||||
|
- **Device grouping** is now done by the reader→relay binding, far more precisely than a lane key.
|
||||||
|
- **Anti-fraud** doesn't use it — the signed chain, the "open must match a signed event" check,
|
||||||
|
and [[reconciliation]] all work on *what happened*, not *which gate*. The relay's direction
|
||||||
|
already catches an exit firing an entry barrier, better than a lane number would.
|
||||||
|
|
||||||
|
Dropping it removed `lane` from `ledger_events`, `device_events`, `sessions`, and the device
|
||||||
|
table (renamed `lane_devices` → `devices`). Because `lane` was part of the **signed canonical
|
||||||
|
form**, this is a versioned change: the canonical array no longer includes lane, and the signer
|
||||||
|
keyId bumped `sw-hmac-v1` → `sw-hmac-v2`. v1 events won't verify under v2 — intentional, gated by
|
||||||
|
each event's stored `keyId` (done pre-deployment, on throwaway data, so zero real cost). See
|
||||||
|
[[append-only-event-chain]].
|
||||||
|
|
||||||
|
## Camera snapshots (evidence, not a gate)
|
||||||
|
|
||||||
|
Captured **after** the barrier opens, **never awaited** — a camera failure can't delay or block an
|
||||||
|
open (the signed ledger is the decision). Stored as a **BLOB in the `snapshots` table** (single
|
||||||
|
backed-up DB, nothing scattered on disk), in its own table so hot telemetry scans don't drag image
|
||||||
|
bytes and images prune independently. Linked to the signed `vehicle_entry/exit` by `identity`.
|
||||||
|
Served read-only via `GET /api/snapshots/:id`. **Retention is unresolved** — see [[open-questions]].
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
[[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] ·
|
||||||
|
[[append-only-event-chain]] · [[barrier-not-a-door]] · [[opencv-anpr-service]] ·
|
||||||
|
[[dingtian-relay]] · [[first-run-setup]]
|
||||||
@@ -22,6 +22,12 @@ There are **two populations** of users, and they map to **two integration paths*
|
|||||||
| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down |
|
| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down |
|
||||||
| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path |
|
| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path |
|
||||||
| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No |
|
| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No |
|
||||||
|
| **[[gee-qr-er80]] QR reader (serial)** | Host only | Host (reads serial → `read` bus) | No |
|
||||||
|
|
||||||
|
> Concrete host-side reader on hand: the **[[gee-qr-er80]]** (QR over RS-232/RS-485). Note autonomy
|
||||||
|
> is moot here anyway — the current relay ([[dingtian-relay]]) has **no onboard card list**, so even
|
||||||
|
> a Wiegand reader would be host-decided. So we take the serial/QR path straight to the host's
|
||||||
|
> `read` bus.
|
||||||
|
|
||||||
## Key points
|
## Key points
|
||||||
|
|
||||||
@@ -32,6 +38,10 @@ There are **two populations** of users, and they map to **two integration paths*
|
|||||||
keeps autonomy + native event log.
|
keeps autonomy + native event log.
|
||||||
- **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled"
|
- **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled"
|
||||||
mode), so one lane serves permit + casual.
|
mode), so one lane serves permit + casual.
|
||||||
|
- **Each reader BINDS to a controller relay** (`config.controllerId` + `relay`) — the barrier it
|
||||||
|
sits at — and inherits that relay's direction (entry/exit/both). An exit read opens exactly that
|
||||||
|
relay; an entry read the entry relay. This is how separate in/out readers are disambiguated, with
|
||||||
|
no "lane". See [[entry-exit-points]].
|
||||||
- **Host-in-the-loop is good for fraud detection** — two independent records (host's signed
|
- **Host-in-the-loop is good for fraud detection** — two independent records (host's signed
|
||||||
[[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any
|
[[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any
|
||||||
mismatch is an anomaly.
|
mismatch is an anomaly.
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ updated: 2026-06-15
|
|||||||
# First-Run Setup (device selection)
|
# First-Run Setup (device selection)
|
||||||
|
|
||||||
The admin install flow that makes the system **device-agnostic in practice**: on first run, an
|
The admin install flow that makes the system **device-agnostic in practice**: on first run, an
|
||||||
admin assigns devices **per lane** by choosing from the [[device-registry]] catalog and entering
|
admin adds **controllers** (each declaring its relays — entry/exit/both — and the entry-button
|
||||||
each device's connection config.
|
terminal) and then **readers/cameras/printers** bound to a controller relay, choosing from the
|
||||||
|
[[device-registry]] catalog and entering each device's connection config. There is **no lane** —
|
||||||
|
the pool-of-spaces model; see [[entry-exit-points]].
|
||||||
|
|
||||||
> Implementation-derived (from `apps/server` + `apps/web`), not the source doc.
|
> Implementation-derived (from `apps/server` + `apps/web`), not the source doc.
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ each device's connection config.
|
|||||||
device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest-
|
device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest-
|
||||||
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
|
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
|
||||||
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
|
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
|
||||||
orphan/half-configured rows. On success persists to `lane_devices`.
|
orphan/half-configured rows. On success persists to `devices`.
|
||||||
4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our
|
4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our
|
||||||
row is removed; the device itself is not un-hardened/un-configured (a stale push from an
|
row is removed; the device itself is not un-hardened/un-configured (a stale push from an
|
||||||
unknown device id is already rejected, and re-assigning reconfigures it).
|
unknown device id is already rejected, and re-assigning reconfigures it).
|
||||||
@@ -34,25 +36,25 @@ each device's connection config.
|
|||||||
|
|
||||||
## Config granularity — multi-instance per category
|
## Config granularity — multi-instance per category
|
||||||
|
|
||||||
The data model is **multi-instance**: `lane_devices` holds **one row per instance**, keyed by a
|
The data model is **multi-instance**: `devices` holds **one row per instance**, keyed by a
|
||||||
generated `id`, with no one-per-(lane, category) constraint. So a lane can have **more than one of
|
generated `id`. So the site can have **more than one of every category** — multiple controllers,
|
||||||
every category** — e.g. two printers (an entry dispenser + a booth printer; see
|
readers, cameras, and printers (e.g. an entry dispenser + a booth printer; see
|
||||||
[[printer-roles-failover]]), multiple readers, multiple cameras. `assign` always inserts a new row
|
[[printer-roles-failover]]). `assign` always inserts a new row (never an upsert), and `state`
|
||||||
(never an upsert), and `state` returns the full list.
|
returns the full list.
|
||||||
|
|
||||||
The `SetupWizard` reflects this: each category shows the **list of assigned instances** for the
|
The `SetupWizard` reflects this: each category shows the **list of assigned instances** (with
|
||||||
current lane (with **Remove**) plus an **Add another** form — not a single fixed slot. `select`-type
|
**Remove**) plus an **Add another** form — not a single fixed slot. `select`-type config fields
|
||||||
config fields (e.g. a printer's role) render as dropdowns.
|
(e.g. a printer's role) render as dropdowns.
|
||||||
|
|
||||||
Organized **per lane** — each lane gets its access controller(s), reader(s), camera(s), and
|
There is **no lane**. Direction lives on each access **relay**; readers/cameras **bind** to a
|
||||||
printer(s), each with its own connection settings. Matches the architecture's "mixable per lane"
|
controller relay (`config.controllerId` + `relay`) — the barrier they serve — and inherit its
|
||||||
reality (a lane can serve permit holders via [[wiegand]] and casual via host-side reads on one
|
direction. The wizard adds controllers first, then binds the other devices to a relay. See
|
||||||
relay — see [[entry-exit-readers]]).
|
[[entry-exit-points]], [[entry-exit-readers]].
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
- The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]).
|
- The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]).
|
||||||
- Device **credentials are stored in `lane_devices.config`** — protect at rest
|
- Device **credentials are stored in `devices.config`** — protect at rest
|
||||||
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
|
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
|
||||||
- **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`,
|
- **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`,
|
||||||
`webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never
|
`webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, domain, business, anti-fraud]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-15
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Parking Session
|
||||||
|
|
||||||
|
The core business-domain entity: one vehicle's stay, from entry to exit, plus the money owed and
|
||||||
|
paid for it. Everything on the business side — [[tariff|tariffs]], payment, [[reconciliation]],
|
||||||
|
revenue reporting — hangs off the session. This page defines what a session **is** and, just as
|
||||||
|
importantly, what it is **not**.
|
||||||
|
|
||||||
|
> Scope decision (2026-06-15): build the **transient** (casual, pay-for-duration) session first;
|
||||||
|
> layer **permit holders** on top as a second identity source that short-circuits payment. Mixed
|
||||||
|
> site, transient-first — see [[entry-exit-readers]] ("two populations, one shared relay") and
|
||||||
|
> [[session-model]].
|
||||||
|
|
||||||
|
## A session is a PROJECTION over the signed event log — not a mutable table
|
||||||
|
|
||||||
|
This is the single most important rule, and it falls straight out of the [[threat-model]] (the
|
||||||
|
adversary is the insider who can edit the database) and the [[append-only-event-chain]]:
|
||||||
|
|
||||||
|
- The **events** table is the ledger and the **only** source of truth. `vehicle_entry`,
|
||||||
|
`vehicle_exit`, `payment`, `void` are all **appended + signed**, never updated or deleted.
|
||||||
|
- A **session** is a **read-model folded from those events** — open when an entry has no matching
|
||||||
|
exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in
|
||||||
|
a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable
|
||||||
|
from the chain and never authoritative** ([[append-only-event-chain]]), scaled to the business
|
||||||
|
domain.
|
||||||
|
- **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen
|
||||||
|
exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets
|
||||||
|
the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator
|
||||||
|
can't forge or silently delete — a deletion breaks the chain visibly. See [[session-model]] for
|
||||||
|
the rejected mutable-table alternative.
|
||||||
|
|
||||||
|
## Identity — how an entry is tied to its exit
|
||||||
|
|
||||||
|
A session needs a key that survives from entry to exit. Two populations, two keys
|
||||||
|
([[entry-exit-readers]]):
|
||||||
|
|
||||||
|
- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]])
|
||||||
|
or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field.
|
||||||
|
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid
|
||||||
|
permit means the session owes nothing — the PAY step is skipped (see below).
|
||||||
|
|
||||||
|
## Lifecycle (pay-on-foot / pay station model)
|
||||||
|
|
||||||
|
Payment is **decoupled from exit** (decision 2026-06-15, matching the [[autonomous-direction|
|
||||||
|
unmanned]] roadmap): the customer pays at a central station before walking back to the car; the
|
||||||
|
exit lane only *validates* that the session is settled.
|
||||||
|
|
||||||
|
```
|
||||||
|
ENTRY (lane) vehicle_entry event → session OPEN
|
||||||
|
(ticket printed / plate read; barrier opens)
|
||||||
|
PAY (pay station) payment event {sessionRef, fee, paidAt}
|
||||||
|
→ session PAID (grace window starts)
|
||||||
|
EXIT (lane) validate: PAID && now ≤ paidAt + graceMinutes ?
|
||||||
|
yes → vehicle_exit event → session CLOSED → pulseOpen
|
||||||
|
no → reject → re-pay overstay top-up at station, then exit
|
||||||
|
```
|
||||||
|
|
||||||
|
States, as derived from events:
|
||||||
|
|
||||||
|
| State | Condition (over the event chain) |
|
||||||
|
| --- | --- |
|
||||||
|
| **OPEN** | a `vehicle_entry` with no later matching `vehicle_exit` |
|
||||||
|
| **PAID** | OPEN + a `payment` event covering the fee due, within its grace window |
|
||||||
|
| **CLOSED** | a matching `vehicle_exit` event exists |
|
||||||
|
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
|
||||||
|
|
||||||
|
Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close.
|
||||||
|
|
||||||
|
## Edge cases the model must name (not yet designed in full)
|
||||||
|
|
||||||
|
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
|
||||||
|
stateful rule; handled as a second `payment` event, fee = f(time since paid).
|
||||||
|
- **Lost ticket** — no entry id to match. A default flat "lost ticket" fee (see [[tariff]]), **or
|
||||||
|
an amount the admin sets at the moment** (operator judgement — e.g. they can establish entry time
|
||||||
|
from [[opencv-anpr-service|plate]] capture or CCTV and charge accordingly, or apply a fixed
|
||||||
|
penalty). Recorded as a `payment` (with the chosen amount + a reason) + a `void`/annotation so it
|
||||||
|
reconciles; the admin-set amount is captured in the signed event, attributed.
|
||||||
|
- **Manual override** — an operator/admin opens the barrier for a stuck or disputed car, or writes
|
||||||
|
off a session, as a deliberate act. Each is a **signed, reason-coded event**
|
||||||
|
(`barrier_open_command` / a void with reason) — so an override is *authorized and logged*, while
|
||||||
|
an open with **no** such signed event remains the fraud signal ([[append-only-event-chain]]). The
|
||||||
|
override is the legitimate counterpart to the out-of-band-open anomaly.
|
||||||
|
- **Forced / fail-open exit** — barrier failed open ([[fail-state-safety]]): the vehicle leaves with
|
||||||
|
**no `vehicle_exit`**. This is an open session that never closes — a **reconciliation anomaly by
|
||||||
|
design** ([[append-only-event-chain]]'s "physical open with no signed command"), not something to
|
||||||
|
paper over. (A *manual* override above is the signed, non-anomalous version.)
|
||||||
|
- **Re-entry / never-exited** — stale open sessions (drove out tailgating, sensor missed). Surface
|
||||||
|
as anomalies; never auto-close silently.
|
||||||
|
|
||||||
|
## What this unblocks (build order)
|
||||||
|
|
||||||
|
The device layer left the entry flow dangling — the session domain is that next step. Schema + code
|
||||||
|
follow this page and [[tariff]]; the decision is recorded in [[session-model]].
|
||||||
|
|
||||||
|
### As-built (2026-06-15)
|
||||||
|
|
||||||
|
- **Entry flow** (`apps/server/src/entry-flow.ts`): access-device input edge → print ticket
|
||||||
|
(failover) → signed `vehicle_entry` → `pulseOpen`. Holds (anomaly, no open, no entry) if printing
|
||||||
|
fails. See [[device-input-flow]].
|
||||||
|
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
|
||||||
|
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
|
||||||
|
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built.
|
||||||
|
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) →
|
||||||
|
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
|
||||||
|
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
|
||||||
|
stays closed. Validation folds the **ledger** (authoritative), then updates the `sessions` cache.
|
||||||
|
- **Not a fail-state:** an unpaid reject keeps the barrier closed deliberately (driver returns to
|
||||||
|
the pay station); "exit fails open" ([[fail-state-safety]]) is about the *system* being unable
|
||||||
|
to decide (host/power loss), not an unpaid car.
|
||||||
|
- **Pay station** (`apps/server/src/pay-station.ts`, routes `GET /api/pay/quote` + `POST /api/pay`):
|
||||||
|
look up the open session → resolve the active tariff version (latest `effectiveFrom ≤ entry`) →
|
||||||
|
`computeFee` → append a signed `payment` event (amount, currency, tender, `tariffVersionId`,
|
||||||
|
`graceExitMin`). An operator `overrideMinor` covers lost-ticket/dispute (recorded as the charged
|
||||||
|
amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays
|
||||||
|
out of the app — `tender` only records cash/card; card capture is the standalone P2PE terminal.
|
||||||
|
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
|
||||||
|
session closed, `verifyChain` ok.
|
||||||
|
|
||||||
|
> **Resolved (2026-06-16):** the earlier "no entry/exit direction" gap is closed by the
|
||||||
|
> [[entry-exit-points]] model. Direction lives on each access **relay**; readers/cameras bind to a
|
||||||
|
> relay and inherit it. The "lane" concept was dropped entirely (pool-of-spaces) — separate in/out
|
||||||
|
> readers are distinguished by their relay binding, not a lane.
|
||||||
@@ -13,7 +13,7 @@ still print when the outside dispenser jams or drops off the network.
|
|||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
Each printer instance (a `lane_devices` row, category `printer`) declares a **role** in its
|
Each printer instance (a `devices` row, category `printer`) declares a **role** in its
|
||||||
config:
|
config:
|
||||||
|
|
||||||
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
|
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user