Compare commits
64 Commits
main
..
e4827c9651
| Author | SHA1 | Date | |
|---|---|---|---|
| e4827c9651 | |||
| c0a775818b | |||
| 30e7fe85de | |||
| bfb6ab0b36 | |||
| 0074e82a2a | |||
| bbf61c48df | |||
| 00f3d141b6 | |||
| f31e57b4ae | |||
| 040c0ff4ca | |||
| 8444bf34c3 | |||
| 808fb26ab6 | |||
| ef0ecadff9 | |||
| d0841c8601 | |||
| d71ba82999 | |||
| 9c9f777784 | |||
| 486f8deae6 | |||
| 3e6773a6d5 | |||
| cf1ff5676d | |||
| 91cc79b14e | |||
| dfa76346d6 | |||
| c9a2ef81a9 | |||
| b8ddda86e7 | |||
| bba988c4e8 | |||
| 5697137c52 | |||
| ca8c7f2fa2 | |||
| f87e4c0d6b | |||
| 4e2e4feedb | |||
| 48660d3ec8 | |||
| 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,8 @@ dist/
|
||||
/*.png
|
||||
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
||||
/dingtian/
|
||||
/QRCode_sdk*/
|
||||
|
||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||
graphify-out/
|
||||
parking.sqlite*.bak-*
|
||||
@@ -8,6 +8,13 @@
|
||||
# Generate one with: openssl rand -hex 32
|
||||
JWT_SECRET=
|
||||
|
||||
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
|
||||
# Generate with: openssl rand -hex 32
|
||||
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
|
||||
# dev, but set a dedicated key before production. Events store the key that signed
|
||||
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
||||
EVENT_SIGNING_KEY=
|
||||
|
||||
# Optional ----------------------------------------------------------------
|
||||
# PORT=3000
|
||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||
@@ -18,3 +25,7 @@ JWT_SECRET=
|
||||
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
||||
# ADMIN_USER=admin
|
||||
# ADMIN_PASS=
|
||||
|
||||
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
|
||||
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
|
||||
WS_ALLOWED_ORIGINS=http://localhost:5173
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/jwt": "10.1.0",
|
||||
"@fastify/static": "9.1.3",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@parking/db": "workspace:*",
|
||||
"@parking/devices": "workspace:*",
|
||||
"@parking/shared": "workspace:*",
|
||||
|
||||
@@ -62,14 +62,14 @@ if (existing && process.env.FORCE !== "1") {
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
if (existing) {
|
||||
await db.update(users).set({ passwordHash, role: "admin" }).where(eq(users.id, existing.id));
|
||||
await db.update(users).set({ passwordHash, roleId: "admin" }).where(eq(users.id, existing.id));
|
||||
console.log(`reset password for admin "${username}"`);
|
||||
} else {
|
||||
await db.insert(users).values({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
roleId: "admin",
|
||||
});
|
||||
console.log(`created admin "${username}"`);
|
||||
}
|
||||
|
||||
+92
-13
@@ -1,16 +1,22 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Role } from "@parking/shared";
|
||||
import { eq, rolePermissions, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||
|
||||
// Local JWT auth helpers — fully local, no external identity provider
|
||||
// (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it);
|
||||
// a separate readable CSRF cookie + matching header defends mutations
|
||||
// (double-submit). See wiki/entities/local-jwt-auth.md.
|
||||
//
|
||||
// Authorization is DYNAMIC RBAC: the token carries the user's `roleId`, and each
|
||||
// guarded route resolves that role's PERMISSION SET (cached in memory) and checks
|
||||
// the permission it requires. Editing a role takes effect on the next request —
|
||||
// no re-login, no token bloat, no stale perms. See @parking/shared PERMISSIONS.
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: { sub: string; username: string; role: Role; csrf: string };
|
||||
user: { sub: string; username: string; role: Role; csrf: string };
|
||||
payload: { sub: string; username: string; roleId: string; csrf: string };
|
||||
user: { sub: string; username: string; roleId: string; csrf: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +24,15 @@ export const TOKEN_COOKIE = "parking_token";
|
||||
export const CSRF_COOKIE = "parking_csrf";
|
||||
export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
/** Token lifetime, also used as the cookie maxAge. */
|
||||
export const TOKEN_TTL = "8h";
|
||||
export const TOKEN_TTL_SECONDS = 8 * 60 * 60;
|
||||
// Session lifetime: the JWT has NO expiry — a login is valid until explicit
|
||||
// logout. Booth reality breaks any fixed clock (relief late/absent, forced double
|
||||
// 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.
|
||||
@@ -55,7 +67,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
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).
|
||||
reply.setCookie(CSRF_COOKIE, csrf, {
|
||||
@@ -63,7 +75,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge: TOKEN_TTL_SECONDS,
|
||||
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,17 +102,84 @@ function assertCsrf(req: FastifyRequest): void {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Permission resolution + cache -------------------------------------------
|
||||
// A role's permission set is read from `role_permissions` and cached in memory.
|
||||
// SQLite is single-writer/single-process here, so a module-level Map is a correct
|
||||
// cache: every role / role-permission mutation calls bumpPermsCache() to clear it,
|
||||
// and the next request re-reads. The built-in `admin` role always resolves to the
|
||||
// FULL permission set in code (never trusts the DB rows for it), so administration
|
||||
// can't be accidentally narrowed.
|
||||
|
||||
const ADMIN_PERMS: ReadonlySet<Permission> = new Set(PERMISSIONS);
|
||||
const permsCache = new Map<string, ReadonlySet<Permission>>();
|
||||
|
||||
// The DB handle the permission resolver reads from. Set ONCE at startup via
|
||||
// initAuth() so route guards don't each have to thread `db` (several route
|
||||
// modules only receive a monitor/service, not the db). Single-process server.
|
||||
let authDb: Db | null = null;
|
||||
|
||||
/** Wire the permission resolver to the app's DB. Call once in buildServer(). */
|
||||
export function initAuth(db: Db): void {
|
||||
authDb = db;
|
||||
permsCache.clear();
|
||||
}
|
||||
|
||||
/** Clear the permission cache. Call after ANY write to roles / role_permissions
|
||||
* (or a user's roleId) so the change takes effect on the next request. */
|
||||
export function bumpPermsCache(): void {
|
||||
permsCache.clear();
|
||||
}
|
||||
|
||||
/** The permission set for a role id, cached. `admin` is always the full set. */
|
||||
export function permissionsFor(roleId: string): ReadonlySet<Permission> {
|
||||
if (roleId === ADMIN_ROLE_ID) return ADMIN_PERMS;
|
||||
const hit = permsCache.get(roleId);
|
||||
if (hit) return hit;
|
||||
if (!authDb) throw new Error("auth not initialised (call initAuth)");
|
||||
const rows = authDb
|
||||
.select({ permission: rolePermissions.permission })
|
||||
.from(rolePermissions)
|
||||
.where(eq(rolePermissions.roleId, roleId))
|
||||
.all();
|
||||
const set = new Set(rows.map((r) => r.permission as Permission));
|
||||
permsCache.set(roleId, set);
|
||||
return set;
|
||||
}
|
||||
|
||||
/** True if the role grants every listed permission. */
|
||||
export function roleHasPermissions(
|
||||
roleId: string,
|
||||
required: readonly Permission[],
|
||||
): boolean {
|
||||
const granted = permissionsFor(roleId);
|
||||
return required.every((p) => granted.has(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* preHandler role guard. Verifies the JWT (from the HttpOnly cookie), enforces
|
||||
* CSRF on mutations, then checks the role. Authorization is a simple per-route
|
||||
* role check — no Casbin/RBAC engine needed at this scale.
|
||||
* preHandler permission guard. Verifies the JWT (from the HttpOnly cookie),
|
||||
* enforces CSRF on mutations, then requires the user's role to grant ALL of the
|
||||
* listed permissions. Authorization is a per-route permission check against the
|
||||
* dynamic, admin-composed role grid — no Casbin/RBAC engine needed at this scale.
|
||||
*/
|
||||
export function requireRole(...allowed: Role[]) {
|
||||
export function requirePermission(...required: Permission[]) {
|
||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
||||
assertCsrf(req);
|
||||
if (!req.user || !allowed.includes(req.user.role)) {
|
||||
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* preHandler that requires a valid signed-in session but NO specific permission —
|
||||
* for "about me" routes (/me, change own language) every authenticated user may
|
||||
* call regardless of role. Still enforces CSRF on mutations.
|
||||
*/
|
||||
export async function requireAuth(
|
||||
req: FastifyRequest,
|
||||
_reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
await req.jwtVerify();
|
||||
assertCsrf(req);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||
import {
|
||||
printWithFailover,
|
||||
registry,
|
||||
type PrinterDevice,
|
||||
type PrinterInstance,
|
||||
type ReceiptData,
|
||||
type TicketHeader,
|
||||
} from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devicesByDirection } from "./device-resolve.js";
|
||||
|
||||
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
|
||||
// from the exit, the customer pays at the booth and walks a printed voucher to the
|
||||
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
|
||||
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
|
||||
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
|
||||
//
|
||||
// This mirrors the entry flow's printer selection + header build, but prints on the
|
||||
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
|
||||
|
||||
/** Park identity for the voucher header, from site_config (all fields optional). */
|
||||
function ticketHeader(db: Db): TicketHeader | undefined {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
parkName: row.parkName,
|
||||
operatorName: row.operatorName,
|
||||
nius: row.nius,
|
||||
address: row.address,
|
||||
phone: row.phone,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build live printer instances for failover selection (entry direction covers the
|
||||
* booth-receipt role too — the booth printer is configured on the entry side). */
|
||||
function loadPrinters(db: Db): PrinterInstance[] {
|
||||
const rows = devicesByDirection(db, "printer", "entry");
|
||||
const out: PrinterInstance[] = [];
|
||||
for (const row of rows) {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) continue;
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
role,
|
||||
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||
device: driver.create(cfg as never) as PrinterDevice,
|
||||
});
|
||||
} catch {
|
||||
// skip a printer whose config won't build
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The receipt figures for a paid session, folded from the SIGNED ledger
|
||||
* (authoritative). Null if there's no entry or no payment for this id — the
|
||||
* caller should have validated paid + open before printing. */
|
||||
function receiptFigures(
|
||||
db: Db,
|
||||
ticketId: string,
|
||||
): Omit<ReceiptData, "voucher" | "header"> | null {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, ticketId))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return null;
|
||||
// The LATEST payment is the one we receipt (an overstay top-up re-pays).
|
||||
let payment: (typeof rows)[number] | undefined;
|
||||
for (const r of rows) if (r.type === "payment") payment = r;
|
||||
if (!payment) return null;
|
||||
const p = (payment.payload ?? {}) as {
|
||||
amountMinor?: number;
|
||||
currency?: string;
|
||||
tender?: "cash" | "card";
|
||||
graceExitMin?: number;
|
||||
};
|
||||
return {
|
||||
ticketId,
|
||||
enteredAt: entry.occurredAt,
|
||||
paidAt: payment.occurredAt,
|
||||
amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0,
|
||||
currency: p.currency ?? "ALL",
|
||||
tender: p.tender === "card" ? "card" : "cash",
|
||||
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over
|
||||
* to the entry dispenser). The receipt is the customer's transparency record:
|
||||
* entry time, payment time, duration, amount + tender — folded from the signed
|
||||
* ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the
|
||||
* walk-back grace, so the one slip both proves payment AND self-exits at a
|
||||
* distant exit reader (this replaces the old barcode-only voucher). In standalone
|
||||
* mode (`voucher:false`) it is detail-only, printed at payment when the booth is
|
||||
* at the exit. Returns the id of the printer that printed it.
|
||||
* Throws NoPrinterAvailableError if none can; throws if the session isn't payable.
|
||||
*/
|
||||
export async function printPaymentReceipt(
|
||||
db: Db,
|
||||
ticketId: string,
|
||||
opts: { voucher: boolean },
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<string> {
|
||||
const figures = receiptFigures(db, ticketId);
|
||||
if (!figures) {
|
||||
throw new Error(`no paid session to receipt for ${ticketId}`);
|
||||
}
|
||||
const printers = loadPrinters(db);
|
||||
const data: ReceiptData = {
|
||||
...figures,
|
||||
voucher: opts.voucher,
|
||||
header: ticketHeader(db),
|
||||
};
|
||||
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
|
||||
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||
d.printReceipt(data),
|
||||
);
|
||||
logger.info(
|
||||
`${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`,
|
||||
);
|
||||
return printedBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
|
||||
* a scannable QR of the credential code + holder/validity, so the operator can hand
|
||||
* it to the customer. Used on subscription creation and on a "reprint" action.
|
||||
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
|
||||
*/
|
||||
export async function printSubscriptionCard(
|
||||
db: Db,
|
||||
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<string> {
|
||||
const printers = loadPrinters(db);
|
||||
const data = {
|
||||
code: card.code,
|
||||
holderName: card.holderName ?? null,
|
||||
validFrom: card.validFrom ?? null,
|
||||
validTo: card.validTo ?? null,
|
||||
header: ticketHeader(db),
|
||||
};
|
||||
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||
d.printSubscriptionCard(data),
|
||||
);
|
||||
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
|
||||
return printedBy;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Credential capture ("enroll a card"): lets an operator present a physical RFID
|
||||
// card/chip (or a QR) to ONE chosen reader and have its value captured for a
|
||||
// subscription credential, instead of typing it. SINGLE-SHOT + short TTL so the
|
||||
// chosen reader is only "borrowed" for one read / a few seconds; the OTHER reader is
|
||||
// never affected and keeps serving the live entry/exit flow.
|
||||
//
|
||||
// Flow: arm(deviceId) → the reader route checks tryConsume() on each read; the next
|
||||
// read from that armed reader is captured (NOT dispatched to the access flow — the
|
||||
// barrier must not open for a card being enrolled) and capture auto-disarms. The
|
||||
// booth form polls result() until the value appears (or it times out / is cancelled).
|
||||
//
|
||||
// In-memory + single-site single-writer (one booth) → no DB, no cross-process
|
||||
// concerns. See wiki/entities/subscription.md.
|
||||
|
||||
const CAPTURE_TTL_MS = Number(process.env.CAPTURE_TTL_MS ?? 30_000);
|
||||
|
||||
export type CaptureState =
|
||||
| { status: "idle" }
|
||||
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
|
||||
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
|
||||
| { status: "expired"; deviceId: string };
|
||||
|
||||
export class CredentialCapture {
|
||||
#armedDeviceId: string | null = null;
|
||||
#expiresAt = 0;
|
||||
#captured: { deviceId: string; value: string; capturedAt: number } | null = null;
|
||||
#lastExpiredDeviceId: string | null = null;
|
||||
|
||||
/** Arm a single-shot capture on one reader (by its `devices.id`). Replaces any
|
||||
* prior arming (only one capture at a time). Clears a stale captured/expired
|
||||
* result so the form starts fresh. */
|
||||
arm(deviceId: string): { expiresAt: number } {
|
||||
this.#armedDeviceId = deviceId;
|
||||
this.#expiresAt = Date.now() + CAPTURE_TTL_MS;
|
||||
this.#captured = null;
|
||||
this.#lastExpiredDeviceId = null;
|
||||
return { expiresAt: this.#expiresAt };
|
||||
}
|
||||
|
||||
/** Cancel any pending arming (operator closed the form / clicked cancel). */
|
||||
cancel(): void {
|
||||
this.#armedDeviceId = null;
|
||||
this.#expiresAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the reader route on EVERY read. If this reader is the armed one (and
|
||||
* not expired), capture the value, disarm, and return true → the caller must NOT
|
||||
* dispatch this read to the access flow. Otherwise false → dispatch normally.
|
||||
*/
|
||||
tryConsume(deviceId: string, value: string): boolean {
|
||||
if (this.#armedDeviceId == null) return false;
|
||||
if (Date.now() > this.#expiresAt) {
|
||||
// Window lapsed before a card was presented — disarm, mark expired.
|
||||
this.#lastExpiredDeviceId = this.#armedDeviceId;
|
||||
this.#armedDeviceId = null;
|
||||
this.#expiresAt = 0;
|
||||
return false;
|
||||
}
|
||||
if (deviceId !== this.#armedDeviceId) return false; // a read from the OTHER reader
|
||||
if (!value) return false;
|
||||
this.#captured = { deviceId, value, capturedAt: Date.now() };
|
||||
this.#armedDeviceId = null; // single-shot
|
||||
this.#expiresAt = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Current state for the booth form's poll. Lazily transitions armed→expired. */
|
||||
state(): CaptureState {
|
||||
if (this.#captured) return { status: "captured", ...this.#captured };
|
||||
if (this.#armedDeviceId != null) {
|
||||
if (Date.now() > this.#expiresAt) {
|
||||
this.#lastExpiredDeviceId = this.#armedDeviceId;
|
||||
this.#armedDeviceId = null;
|
||||
this.#expiresAt = 0;
|
||||
return { status: "expired", deviceId: this.#lastExpiredDeviceId };
|
||||
}
|
||||
return { status: "armed", deviceId: this.#armedDeviceId, armedAt: this.#expiresAt - CAPTURE_TTL_MS, expiresAt: this.#expiresAt };
|
||||
}
|
||||
if (this.#lastExpiredDeviceId) return { status: "expired", deviceId: this.#lastExpiredDeviceId };
|
||||
return { status: "idle" };
|
||||
}
|
||||
|
||||
/** Clear a consumed/expired result once the form has read it. */
|
||||
clear(): void {
|
||||
this.#captured = null;
|
||||
this.#lastExpiredDeviceId = null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { PrinterStatus } from "@parking/devices";
|
||||
import type { LedgerEventRow } from "@parking/db";
|
||||
|
||||
// Internal event bus for device-originated events (button presses, etc.).
|
||||
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||
@@ -8,22 +9,73 @@ import type { PrinterStatus } from "@parking/devices";
|
||||
|
||||
export interface DeviceInputEvent {
|
||||
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 edge: "on" | "off"; // active / inactive
|
||||
readonly at: string; // ISO-8601 (server receive time)
|
||||
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, subscriptions, 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 (subscription/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). */
|
||||
export interface PrinterStatusEvent {
|
||||
readonly deviceId: string; // lane_devices id
|
||||
readonly lane: number;
|
||||
readonly deviceId: string; // devices id
|
||||
readonly driverId: string;
|
||||
readonly role?: string; // entry-dispenser | booth-receipt
|
||||
readonly status: PrinterStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unified live status of ANY configured device — what the booth footer shows.
|
||||
* Every enabled device is polled: printers via their rich `readStatus()`
|
||||
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
|
||||
* reachability probe. `state` is the common traffic-light; `detail` carries the
|
||||
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
|
||||
* and wiki/concepts/device-status-monitoring.md.
|
||||
*/
|
||||
export interface DeviceStatusEvent {
|
||||
readonly deviceId: string; // devices id
|
||||
readonly driverId: string;
|
||||
readonly category: "access" | "reader" | "camera" | "printer";
|
||||
/**
|
||||
* The device's ROLE descriptor for the footer label — NOT the vendor. A
|
||||
* direction-style token the client localises and pairs with the category, so the
|
||||
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
|
||||
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
|
||||
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
|
||||
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
|
||||
* - undetermined: null (chip shows the category alone)
|
||||
*/
|
||||
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||||
readonly state: "ready" | "degraded" | "offline";
|
||||
readonly detail?: string;
|
||||
readonly checkedAt: string; // ISO-8601
|
||||
}
|
||||
|
||||
class DeviceEventBus extends EventEmitter {
|
||||
emitInput(event: DeviceInputEvent): void {
|
||||
this.emit("input", event);
|
||||
@@ -33,6 +85,15 @@ class DeviceEventBus extends EventEmitter {
|
||||
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. */
|
||||
emitPrinterStatus(event: PrinterStatusEvent): void {
|
||||
this.emit("printer-status", event);
|
||||
@@ -41,6 +102,32 @@ class DeviceEventBus extends EventEmitter {
|
||||
this.on("printer-status", cb);
|
||||
return () => this.off("printer-status", cb);
|
||||
}
|
||||
|
||||
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
|
||||
* (all categories — relays, readers, cameras, printers). Drives the booth
|
||||
* device-status footer over the WS. */
|
||||
emitDeviceStatus(event: DeviceStatusEvent): void {
|
||||
this.emit("device-status", event);
|
||||
}
|
||||
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
|
||||
this.on("device-status", cb);
|
||||
return () => this.off("device-status", cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
|
||||
* payment, void, …). The payload is the persisted row — business facts only, no
|
||||
* secrets — so it is safe to fan out to authenticated booth clients over the WS.
|
||||
* This is a read-side notification ONLY: it never feeds back into append/sign/
|
||||
* chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts.
|
||||
*/
|
||||
emitLedger(event: LedgerEventRow): void {
|
||||
this.emit("ledger", event);
|
||||
}
|
||||
onLedger(cb: (event: LedgerEventRow) => void): () => void {
|
||||
this.on("ledger", cb);
|
||||
return () => this.off("ledger", cb);
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide device event bus. */
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||
import { isMonitorable, registry } from "@parking/devices";
|
||||
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||
|
||||
// Unified live DEVICE monitor — the source for the booth's device-status footer.
|
||||
// Every enabled, configured device is probed on an interval, regardless of
|
||||
// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the
|
||||
// same capability the PrinterMonitor uses), and a relay/reader/camera via the
|
||||
// generic healthCheck() reachability probe every Device implements. The result is
|
||||
// flattened to a common traffic-light (ready | degraded | offline) + a detail
|
||||
// string, cached per device id, and emitted on the bus ONLY when it changes.
|
||||
//
|
||||
// This is device-agnostic (talks to the adapter interfaces, never a driver SDK)
|
||||
// and read-only — polling a device never drives a relay or mutates the ledger.
|
||||
// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md.
|
||||
|
||||
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
||||
|
||||
/**
|
||||
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
||||
* tokens the client localises next to the category:
|
||||
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
|
||||
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
|
||||
* than one direction; null if it declares none yet
|
||||
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt)
|
||||
*/
|
||||
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||
switch (row.category) {
|
||||
case "reader":
|
||||
case "camera": {
|
||||
const d = directionOf(db, row); // entry | exit | both
|
||||
return d;
|
||||
}
|
||||
case "access": {
|
||||
const dirs = new Set(relaysOf(row).map((r) => r.direction));
|
||||
if (dirs.size === 0) return null;
|
||||
if (dirs.size > 1) return "mixed";
|
||||
const only = [...dirs][0]; // entry | exit | both
|
||||
return only ?? null;
|
||||
}
|
||||
case "printer": {
|
||||
const role = (row.config as { role?: string }).role;
|
||||
if (role === "booth-receipt") return "booth";
|
||||
if (role === "entry-dispenser") return "lane";
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceMonitor {
|
||||
readonly #db: Db;
|
||||
readonly #log: FastifyBaseLogger;
|
||||
readonly #pollMs: number;
|
||||
/** Latest unified status per device id. */
|
||||
readonly #latest = new Map<string, DeviceStatusEvent>();
|
||||
#timer: ReturnType<typeof setInterval> | null = null;
|
||||
#ticking = false;
|
||||
|
||||
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#pollMs = pollMs;
|
||||
}
|
||||
|
||||
/** Begin polling. Idempotent. */
|
||||
start(): void {
|
||||
if (this.#timer) return;
|
||||
void this.#tick(); // immediate first pass so the footer fills without a wait
|
||||
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
|
||||
this.#timer.unref?.();
|
||||
this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.#timer) {
|
||||
clearInterval(this.#timer);
|
||||
this.#timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current snapshot for the API / a freshly-connected WS client. */
|
||||
snapshot(): DeviceStatusEvent[] {
|
||||
return [...this.#latest.values()];
|
||||
}
|
||||
|
||||
async #tick(): Promise<void> {
|
||||
if (this.#ticking) return; // never overlap polls
|
||||
this.#ticking = true;
|
||||
try {
|
||||
// Re-read the device set each tick so a newly-assigned/removed device is
|
||||
// picked up without a restart.
|
||||
const rows = await this.#db.select().from(devices).all();
|
||||
const enabled = rows.filter((r) => r.enabled);
|
||||
const present = new Set(enabled.map((r) => r.id));
|
||||
|
||||
// Drop devices that are gone/disabled (so the footer doesn't show stale ones).
|
||||
for (const id of [...this.#latest.keys()]) {
|
||||
if (!present.has(id)) this.#latest.delete(id);
|
||||
}
|
||||
|
||||
await Promise.all(enabled.map((r) => this.#poll(r)));
|
||||
} catch (err) {
|
||||
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#ticking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async #poll(row: DeviceRow): Promise<void> {
|
||||
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
||||
const base = {
|
||||
deviceId: row.id,
|
||||
driverId: row.driverId,
|
||||
category: row.category,
|
||||
roleKind: roleKindOf(this.#db, row),
|
||||
};
|
||||
|
||||
let next: DeviceStatusEvent;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) {
|
||||
// Configured against a driver that's no longer registered — surface it,
|
||||
// don't silently hide it.
|
||||
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||
} else {
|
||||
try {
|
||||
const device = driver.create(cfg as never);
|
||||
// Printers expose richer paper/cover/cutter status; everything else uses
|
||||
// the generic reachability probe. Both flatten to the same traffic-light.
|
||||
if (isMonitorable(device)) {
|
||||
const s = await device.readStatus();
|
||||
next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt };
|
||||
} else {
|
||||
const h = await device.healthCheck();
|
||||
next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
} catch (err) {
|
||||
// A probe that throws (build error, timeout) reads as offline — never crash
|
||||
// the tick, and fail toward "there's a problem" rather than false-healthy.
|
||||
next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
const prev = this.#latest.get(row.id);
|
||||
this.#latest.set(row.id, next);
|
||||
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
|
||||
this.#log.info(
|
||||
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${row.id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
|
||||
);
|
||||
deviceEvents.emitDeviceStatus(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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 terminals its entry button + presence loop are 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;
|
||||
/**
|
||||
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
||||
* Two modes, chosen by what barrier feedback exists at this lane:
|
||||
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
||||
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
||||
* controller. A press prints only while a car is present, and no second ticket
|
||||
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
|
||||
* makes one-car-one-ticket physical.
|
||||
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
||||
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
||||
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
||||
*/
|
||||
readonly presenceInput?: number;
|
||||
readonly entryCooldownSec?: 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. Carries the
|
||||
* transient-entry anti-double-press config (presence loop / cooldown) when resolved
|
||||
* from a button press, so the entry flow can enforce one-car-one-ticket. */
|
||||
export interface ResolvedRelay {
|
||||
readonly controller: DeviceRow;
|
||||
readonly relay: number;
|
||||
readonly direction: Direction;
|
||||
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
||||
readonly presenceInput?: number;
|
||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
||||
readonly entryCooldownSec?: number;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
presenceInput: spec.presenceInput,
|
||||
entryCooldownSec: spec.entryCooldownSec,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
||||
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
||||
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
|
||||
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||
*/
|
||||
export function relayForPresence(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.presenceInput === 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,422 @@
|
||||
import { randomInt, randomUUID } from "node:crypto";
|
||||
import { deviceEvents as deviceEventsTable, 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 { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||
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, relayForPresence, 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.
|
||||
//
|
||||
// ONE CAR = ONE TICKET (anti-double-press). The entry button can be physically held
|
||||
// or mashed; without a guard each press mints a fresh ticket + signed vehicle_entry
|
||||
// (corrupting occupancy and letting a transient shop the cheapest ticket at exit). The
|
||||
// guard is per-relay and CONFIGURED on the relay spec (config.relays[]), chosen by what
|
||||
// barrier feedback exists at the lane:
|
||||
// - PRESENCE loop (preferred): `presenceInput` ties ticketing to a real vehicle. A
|
||||
// press prints only while a car is present, and NO second ticket issues until the
|
||||
// loop CLEARS (car drove in) and a new car re-occupies it. We observe the loop's
|
||||
// input edges to track presence + "armed" per relay.
|
||||
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
|
||||
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
|
||||
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
||||
// See wiki/concepts/entry-double-press.md.
|
||||
|
||||
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
|
||||
interface RelayGuardState {
|
||||
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
|
||||
lastTicketAt: number;
|
||||
/** PRESENCE mode: is a vehicle currently on the loop? (from loop input edges) */
|
||||
present: boolean;
|
||||
/** PRESENCE mode: ready to issue a ticket for a NEW car. Set false after a ticket
|
||||
* prints; re-armed when the loop CLEARS (the car drove through). */
|
||||
armed: boolean;
|
||||
}
|
||||
|
||||
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>();
|
||||
/** Per-relay one-car-one-ticket state (presence + cooldown), keyed controllerId:relay. */
|
||||
readonly #guard = new Map<string, RelayGuardState>();
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Handle a device input edge. Two kinds of edge matter to this flow:
|
||||
* (1) an ENTRY BUTTON press (rising edge) → run entry, subject to the per-relay
|
||||
* anti-double-press guard; (2) a PRESENCE LOOP edge (either direction) → update
|
||||
* presence state so the guard knows when a car arrives/leaves. The same physical
|
||||
* input is never both, so we resolve each independently. */
|
||||
async onInput(e: DeviceInputEvent): Promise<void> {
|
||||
// Presence-loop edge (both directions matter): keep the per-relay state current.
|
||||
const presence = relayForPresence(this.#db, e.deviceId, e.input);
|
||||
if (presence) {
|
||||
this.#onPresenceEdge(presence, e.edge);
|
||||
return; // a loop input is not a button — nothing else to do
|
||||
}
|
||||
|
||||
if (e.edge !== "on") return; // for buttons, the 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;
|
||||
|
||||
// ANTI-DOUBLE-PRESS: is this press allowed to issue a ticket? (presence/cooldown)
|
||||
const suppressed = this.#suppressReason(resolved);
|
||||
if (suppressed) {
|
||||
this.#recordSuppressedPress(e, resolved, suppressed);
|
||||
this.#logger.info(`entry press suppressed (${this.#relayKey(resolved)}): ${suppressed}`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable per-relay key for the guard map. */
|
||||
#relayKey(r: ResolvedRelay): string {
|
||||
return `${r.controller.id}:${r.relay}`;
|
||||
}
|
||||
|
||||
/** Lazily get (or create) the guard state for a relay. New relays start ARMED and
|
||||
* with no car present, so the first press on a fresh lane works immediately. */
|
||||
#guardState(r: ResolvedRelay): RelayGuardState {
|
||||
const key = this.#relayKey(r);
|
||||
let s = this.#guard.get(key);
|
||||
if (!s) {
|
||||
s = { lastTicketAt: 0, present: false, armed: true };
|
||||
this.#guard.set(key, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Apply a presence-loop edge to a relay's state. The car ARRIVING re-arms ticketing;
|
||||
* the car LEAVING the loop (after its entry) re-arms for the NEXT car. */
|
||||
#onPresenceEdge(r: ResolvedRelay, edge: "on" | "off"): void {
|
||||
const s = this.#guardState(r);
|
||||
if (edge === "on") {
|
||||
s.present = true; // a vehicle is at the barrier
|
||||
} else {
|
||||
// Loop cleared: the car drove through (or backed off). Re-arm for the next car —
|
||||
// this is the gate that makes a *new* car necessary before another ticket.
|
||||
s.present = false;
|
||||
s.armed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
||||
* PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no
|
||||
* guard (legacy). The two can coexist — presence first, cooldown as a backstop. */
|
||||
#suppressReason(r: ResolvedRelay): string | null {
|
||||
const s = this.#guardState(r);
|
||||
|
||||
if (typeof r.presenceInput === "number") {
|
||||
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
|
||||
// ticket already issued for this still-present car).
|
||||
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
|
||||
if (!s.armed) return "ticket already issued for the car at the barrier";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
|
||||
const elapsed = Date.now() - s.lastTicketAt;
|
||||
if (elapsed < r.entryCooldownSec * 1000) {
|
||||
const remain = Math.ceil((r.entryCooldownSec * 1000 - elapsed) / 1000);
|
||||
return `within ${r.entryCooldownSec}s entry cooldown (${remain}s left)`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
|
||||
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
|
||||
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
|
||||
try {
|
||||
this.#db
|
||||
.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId: e.deviceId,
|
||||
category: "access",
|
||||
kind: "input",
|
||||
detail: {
|
||||
driverId: e.driverId,
|
||||
input: e.input,
|
||||
edge: e.edge,
|
||||
entrySuppressed: true,
|
||||
relay: r.relay,
|
||||
reason,
|
||||
},
|
||||
occurredAt: e.at,
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`suppressed-press telemetry insert failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
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. Subscribers are NOT
|
||||
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
||||
// they aren't locked out. "Full" is a soft policy seam for valet over-
|
||||
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||
const occ = getOccupancy(this.#db);
|
||||
if (occ.full) {
|
||||
// No ticket id exists for a refused entry, so mint a synthetic ref to key the
|
||||
// anomaly + its evidence snapshot together. The operator wants the photo of WHO
|
||||
// was turned away (a fraud/dispute signal), so we still fire the entry camera.
|
||||
const refusedRef = `REFUSED-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: refusedRef,
|
||||
payload: {
|
||||
...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }),
|
||||
entryRefused: true,
|
||||
full: true,
|
||||
},
|
||||
});
|
||||
this.#fireSnapshot("entry", refusedRef);
|
||||
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}`);
|
||||
// ONE CAR = ONE TICKET: a ticket is now out for the car at this barrier. Disarm +
|
||||
// stamp the cooldown so a repeat press (held button / mashing) issues no second
|
||||
// ticket. PRESENCE mode re-arms when the loop clears (car drove in); COOLDOWN mode
|
||||
// re-allows after entryCooldownSec. Done on the print success, NOT the open.
|
||||
const guard = this.#guardState(resolved);
|
||||
guard.lastTicketAt = Date.now();
|
||||
guard.armed = false;
|
||||
} 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: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false },
|
||||
});
|
||||
// Capture who is held at the barrier (evidence for the operator handling the car).
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||
// later reprices the same way at exit. Today every transient takes the SITE
|
||||
// default category (operator policy, site_config.default_vehicle_category;
|
||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
|
||||
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
||||
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
||||
// ignores it; only V2 category cards consult it.
|
||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const category =
|
||||
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||
? cfg.defaultVehicleCategory
|
||||
: DEFAULT_VEHICLE_CATEGORY;
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
direction: "entry",
|
||||
source: "ticket",
|
||||
identity: ticketId,
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
||||
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).
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
||||
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
|
||||
* held car is exactly when the operator wants the photo. */
|
||||
#fireSnapshot(direction: "entry", identity: string): void {
|
||||
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger }).catch((err) =>
|
||||
this.#logger.error(`entry snapshot error: ${(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: 11 digits = 10 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.
|
||||
*
|
||||
* Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the
|
||||
* Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN
|
||||
* ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while
|
||||
* being two digits (≈2 barcode modules) narrower than the old 13. Collisions are
|
||||
* negligible at lot scale; the unique constraints on ledger_events.index / sessions.id
|
||||
* are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.)
|
||||
* 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 < 10; 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 {
|
||||
// Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest.
|
||||
// Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation
|
||||
// (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps
|
||||
// a stray short/long string from being mistaken for a ticket. See ticket-encoding.md.
|
||||
if (!/^\d{10,14}$/.test(code)) return false;
|
||||
const body = code.slice(0, -1);
|
||||
return luhnCheckDigit(body) === code[code.length - 1];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { eq, subscriptions, type Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
|
||||
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
|
||||
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
|
||||
// We resolve them when serializing an event for the API / WS feed — never on the
|
||||
// signed record itself.
|
||||
//
|
||||
// Today: a subscription occurrence's identity is an opaque `SUBSESS-…` key. The human
|
||||
// who matters is the subscription HOLDER, whose name lives on the subscriptions row
|
||||
// (mutable master data — NOT signed into the event). We resolve payload.permitId →
|
||||
// holder_name so the feed reads "Aqif Kopertoni" rather than "SUBSESS-08cd1c52e219".
|
||||
|
||||
/** Fallback label when a subscription has no holder name (or was deleted). Matches the
|
||||
* i18n key `booth.subscriberFallback`; kept here in English for the API/log layer. */
|
||||
const SUBSCRIBER_FALLBACK = "Subscriber";
|
||||
|
||||
/** Tiny holder-name cache. Single-writer SQLite; a subscription rename is rare and the
|
||||
* feed is not security-sensitive, so a short-lived cache is plenty. Invalidate by
|
||||
* process lifetime — restart picks up renames; for live correctness the lookup is
|
||||
* cheap enough that we just read per miss. */
|
||||
const holderCache = new Map<string, string | null>();
|
||||
|
||||
/** Resolve a subscription id to its holder name (or null), memoized. */
|
||||
function holderName(db: Db, permitId: string): string | null {
|
||||
if (holderCache.has(permitId)) return holderCache.get(permitId) ?? null;
|
||||
const row = db
|
||||
.select({ holderName: subscriptions.holderName })
|
||||
.from(subscriptions)
|
||||
.where(eq(subscriptions.id, permitId))
|
||||
.get();
|
||||
const name = row?.holderName?.trim() || null;
|
||||
holderCache.set(permitId, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Drop a cached holder name (call after a subscription create/update/delete). */
|
||||
export function invalidateHolder(permitId: string): void {
|
||||
holderCache.delete(permitId);
|
||||
}
|
||||
|
||||
/** Clear the whole holder cache (call on bulk subscription changes). */
|
||||
export function clearHolderCache(): void {
|
||||
holderCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach read-time display fields to a raw ledger row before it goes to a client.
|
||||
* Currently: `subscriberLabel` for subscription occurrences. Idempotent and cheap;
|
||||
* non-subscription events pass through unchanged (no `subscriberLabel`).
|
||||
*/
|
||||
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
|
||||
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
|
||||
if (!permitId) return event;
|
||||
return { ...event, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { desc, events, type Db, type EventRow } from "@parking/db";
|
||||
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared";
|
||||
import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
|
||||
import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
|
||||
|
||||
// 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
|
||||
@@ -16,11 +16,12 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
|
||||
// so we guard it with an in-process async lock as well.
|
||||
|
||||
export interface AppendInput {
|
||||
readonly type: ParkingEventType;
|
||||
readonly lane: number;
|
||||
readonly type: LedgerEventType;
|
||||
readonly direction?: Direction | null;
|
||||
readonly source?: IdentitySource | null;
|
||||
readonly identity?: string | null;
|
||||
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
|
||||
readonly payload?: LedgerPayload | null;
|
||||
/** Event time (ISO-8601). Defaults to now. */
|
||||
readonly occurredAt?: string;
|
||||
}
|
||||
@@ -36,9 +37,9 @@ export function canonicalize(e: {
|
||||
index: number;
|
||||
type: string;
|
||||
direction: string | null;
|
||||
lane: number;
|
||||
source: string | null;
|
||||
identity: string | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
occurredAt: string;
|
||||
prevHash: string | null;
|
||||
}): string {
|
||||
@@ -46,57 +47,107 @@ export function canonicalize(e: {
|
||||
e.index,
|
||||
e.type,
|
||||
e.direction ?? null,
|
||||
e.lane,
|
||||
e.source ?? 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.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. */
|
||||
export function hashEvent(canonical: string): string {
|
||||
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
|
||||
* Returns undefined when the key that signed an event is not available. */
|
||||
export type SignerResolver = (keyId: string) => Signer | undefined;
|
||||
|
||||
export class EventLog {
|
||||
readonly #db: Db;
|
||||
readonly #signer: Signer;
|
||||
/** Picks the verifying signer per event keyId; lets a chain span key rotations
|
||||
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
|
||||
* callers that don't pass one (single-key chains, tests). */
|
||||
readonly #resolveVerifier: SignerResolver;
|
||||
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
|
||||
* to fan the event out to live booth clients (WS). It is best-effort and must
|
||||
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
|
||||
readonly #onAppended?: (row: LedgerEventRow) => void;
|
||||
/** Serialize appends: each waits for the previous to finish. */
|
||||
#tail: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(db: Db, signer: Signer) {
|
||||
constructor(
|
||||
db: Db,
|
||||
signer: Signer,
|
||||
resolveVerifier?: SignerResolver,
|
||||
onAppended?: (row: LedgerEventRow) => void,
|
||||
) {
|
||||
this.#db = db;
|
||||
this.#signer = signer;
|
||||
this.#resolveVerifier = resolveVerifier ?? (() => signer);
|
||||
this.#onAppended = onAppended;
|
||||
}
|
||||
|
||||
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
||||
append(input: AppendInput): Promise<EventRow> {
|
||||
append(input: AppendInput): Promise<LedgerEventRow> {
|
||||
const run = this.#tail.then(() => this.#appendNow(input));
|
||||
// Keep the chain going even if one append rejects (don't wedge the lock).
|
||||
this.#tail = run.catch(() => undefined);
|
||||
return run;
|
||||
// Read-side notification, AFTER the row is durably written. Wrapped so a
|
||||
// failing sink can never reject the append or break the chain lock above.
|
||||
return run.then((row) => {
|
||||
try {
|
||||
this.#onAppended?.(row);
|
||||
} catch {
|
||||
// best-effort fan-out only — swallow.
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
#appendNow(input: AppendInput): EventRow {
|
||||
#appendNow(input: AppendInput): LedgerEventRow {
|
||||
const prev = this.#db
|
||||
.select()
|
||||
.from(events)
|
||||
.orderBy(desc(events.index))
|
||||
.from(ledgerEvents)
|
||||
.orderBy(desc(ledgerEvents.index))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
const index = (prev?.index ?? 0) + 1;
|
||||
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
|
||||
const occurredAt = input.occurredAt ?? new Date().toISOString();
|
||||
const payload = input.payload ?? null;
|
||||
|
||||
const canonical = canonicalize({
|
||||
index,
|
||||
type: input.type,
|
||||
direction: input.direction ?? null,
|
||||
lane: input.lane,
|
||||
source: input.source ?? null,
|
||||
identity: input.identity ?? null,
|
||||
payload,
|
||||
occurredAt,
|
||||
prevHash,
|
||||
});
|
||||
@@ -106,26 +157,33 @@ export class EventLog {
|
||||
index,
|
||||
type: input.type,
|
||||
direction: input.direction ?? null,
|
||||
lane: input.lane,
|
||||
source: input.source ?? null,
|
||||
identity: input.identity ?? null,
|
||||
payload,
|
||||
occurredAt,
|
||||
prevHash,
|
||||
signature: this.#signer.sign(canonical),
|
||||
keyId: this.#signer.keyId,
|
||||
};
|
||||
|
||||
this.#db.insert(events).values(row).run();
|
||||
return row as EventRow;
|
||||
this.#db.insert(ledgerEvents).values(row).run();
|
||||
return row as LedgerEventRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
|
||||
* first detected break, or { ok: true }. This is what reconciliation and an
|
||||
* integrity self-check call. Catches: tampered content, reordering, a deleted
|
||||
* row (index gap), and a forged/invalid signature.
|
||||
* row (index gap), a forged/invalid signature, and an event signed under a key
|
||||
* that is no longer configured.
|
||||
*
|
||||
* Each row is verified against the signer for ITS OWN `keyId`, not the current
|
||||
* append signer — so a chain that spans a key rotation (e.g. early events under
|
||||
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
|
||||
* verifies end to end. See signer.buildVerifier.
|
||||
*/
|
||||
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
||||
const rows = this.#db.select().from(events).orderBy(events.index).all();
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
let expectedIndex = 1;
|
||||
let prevHash: string | null = null;
|
||||
for (const row of rows) {
|
||||
@@ -135,8 +193,16 @@ export class EventLog {
|
||||
if ((row.prevHash ?? null) !== prevHash) {
|
||||
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
|
||||
}
|
||||
const verifier = this.#resolveVerifier(row.keyId);
|
||||
if (!verifier) {
|
||||
return {
|
||||
ok: false,
|
||||
index: row.index,
|
||||
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
|
||||
};
|
||||
}
|
||||
const canonical = canonicalize(row);
|
||||
if (!this.#signer.verify(canonical, row.signature)) {
|
||||
if (!verifier.verify(canonical, row.signature)) {
|
||||
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
|
||||
}
|
||||
prevHash = hashEvent(canonical);
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
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, reasonPayload, renderReasonEn, 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
|
||||
/** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to
|
||||
* exit / re-open without a `payment`. */
|
||||
readonly subscription: boolean;
|
||||
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
||||
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
|
||||
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
|
||||
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
|
||||
// active tariff resolves (then we fall back to the normal paid check).
|
||||
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
|
||||
}
|
||||
|
||||
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
|
||||
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
|
||||
* the barrier didn't open (payment stands; operator opens manually). */
|
||||
export type BoothExitResult =
|
||||
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||
| { ok: true; opened: true }
|
||||
| { ok: true; opened: false; reason: string };
|
||||
|
||||
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
|
||||
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
|
||||
* intervention was recorded (signed anomaly) but the relay did not fire. */
|
||||
export type BoothReopenResult =
|
||||
| { ok: false; reason: string }
|
||||
| { ok: true; opened: boolean; reason?: string };
|
||||
|
||||
export class ExitFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
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 rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
|
||||
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
|
||||
return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason };
|
||||
}
|
||||
|
||||
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
|
||||
const freeGrace = view.paidAt == null && view.freeGrace != null;
|
||||
const paid = view.paidAt != null;
|
||||
const withinGrace =
|
||||
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||
|
||||
if (!freeGrace && (!paid || !withinGrace)) {
|
||||
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
|
||||
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
|
||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||
}
|
||||
|
||||
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||
// reader path does.
|
||||
if (freeGrace && view.freeGrace) {
|
||||
await this.#log.append({
|
||||
type: "payment",
|
||||
identity: id,
|
||||
payload: {
|
||||
sessionRef: id,
|
||||
amountMinor: 0,
|
||||
currency: view.freeGrace.currency,
|
||||
tariffVersionId: view.freeGrace.tariffVersionId,
|
||||
graceExitMin: view.freeGrace.graceExitMin,
|
||||
...reasonPayload("exit.freeGrace"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
|
||||
const resolved = firstRelayByDirection(this.#db, "exit");
|
||||
|
||||
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
|
||||
// to let the car out has been made and validated. Then attempt the open.
|
||||
await this.#signExit(id);
|
||||
|
||||
if (!resolved) {
|
||||
await this.#openFailedAnomaly(id, "no exit relay configured");
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
|
||||
}
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (!access) {
|
||||
await this.#openFailedAnomaly(id, "exit controller would not build");
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
|
||||
}
|
||||
try {
|
||||
await access.pulseOpen(resolved.relay);
|
||||
} catch (err) {
|
||||
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
|
||||
}
|
||||
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#closeSessionCache(id);
|
||||
return { ok: true, opened: true };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
|
||||
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
|
||||
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
|
||||
* opens the barrier with a signed trace.
|
||||
*
|
||||
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
|
||||
* the UI also hides the button). It re-pulses the exit relay and signs an `anomaly`
|
||||
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
|
||||
*
|
||||
* CLOSING THE SESSION (fix 2026-06-18): if the session is still OPEN (no
|
||||
* `vehicle_exit` yet), the manual re-open *is* this car leaving — so we also sign a
|
||||
* `vehicle_exit` (attributed as human-intervention). Without it the paid session
|
||||
* would linger in the Active Sessions list FOREVER, since the grace-expiry eviction
|
||||
* only applies to already-exited sessions (the T-397815c0 bug). If the session is
|
||||
* already CLOSED (a prior exit exists — the phantom re-close case), we do NOT sign a
|
||||
* second exit (that would double-count occupancy): anomaly only, as before.
|
||||
* See wiki/concepts/booth-exit-flow.md.
|
||||
*/
|
||||
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
|
||||
const id = identity.trim();
|
||||
if (!id) return { ok: false, reason: "ticket id required" };
|
||||
|
||||
const view = this.#sessionFor(id);
|
||||
if (!view) return { ok: false, reason: "no session for ticket" };
|
||||
// Authorization to re-open: a PAID transient (paid, or paid-then-exited within
|
||||
// grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must
|
||||
// assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit
|
||||
// flow instead — enforced here, not just in the UI (the no-unpaid-bypass rule).
|
||||
if (view.paidAt == null && !view.subscription) {
|
||||
return { ok: false, reason: "session not paid — no barrier open without payment" };
|
||||
}
|
||||
|
||||
const key = `reopen:${id}`;
|
||||
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
const resolved = firstRelayByDirection(this.#db, "exit");
|
||||
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
|
||||
// the physical open succeeds).
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: id,
|
||||
payload: {
|
||||
...reasonPayload("exit.manualOpen"),
|
||||
source: "booth",
|
||||
barrierReopen: true,
|
||||
...(operator ? { operator } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Close an OPEN session: the re-open is the exit. Sign the vehicle_exit so the
|
||||
// session leaves the active list + occupancy settles. Skip when already exited
|
||||
// (no double-count). Recorded as a human-intervention exit for the audit trail.
|
||||
if (view.open) {
|
||||
await this.#signExit(id, "manual");
|
||||
this.#closeSessionCache(id);
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.info(`barrier re-open also closed open session ${id} (human-intervention exit)`);
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
|
||||
}
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (!access) {
|
||||
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
|
||||
}
|
||||
try {
|
||||
await access.pulseOpen(resolved.relay);
|
||||
} catch (err) {
|
||||
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
|
||||
}
|
||||
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
|
||||
return { ok: true, opened: true };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
|
||||
* read dispatcher from the reader's binding, which has ruled out a subscription match). */
|
||||
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
const key = `${e.deviceId}:${e.value}`;
|
||||
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||
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 rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: e.value,
|
||||
payload: { ...rp, exitRefused: true },
|
||||
});
|
||||
this.#fireExitSnapshot(e.value);
|
||||
this.#logger.warn(`exit refused: no open session for ${e.value}`);
|
||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||
}
|
||||
|
||||
// FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate
|
||||
// with no pay-station visit. Mint a signed $0 `payment` first so the ledger keeps
|
||||
// its "an exit is covered by a payment" invariant, then fall through to open.
|
||||
// Only when NOT already paid (a real payment, walk-back grace, takes precedence).
|
||||
if (view.paidAt == null && view.freeGrace) {
|
||||
await this.#log.append({
|
||||
type: "payment",
|
||||
// No `source` (not operator-keyed nor a read) — the payload reason marks it.
|
||||
identity: e.value,
|
||||
payload: {
|
||||
sessionRef: e.value,
|
||||
amountMinor: 0,
|
||||
currency: view.freeGrace.currency,
|
||||
tariffVersionId: view.freeGrace.tariffVersionId,
|
||||
graceExitMin: view.freeGrace.graceExitMin,
|
||||
...reasonPayload("exit.freeGrace"),
|
||||
},
|
||||
});
|
||||
this.#logger.info(`exit free within entry-grace (${e.value})`);
|
||||
return this.#signExitAndOpen(resolved, e);
|
||||
}
|
||||
|
||||
// PAID + within walk-back grace?
|
||||
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 rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: e.value,
|
||||
payload: { ...rp, exitRefused: true, sessionRef: e.value },
|
||||
});
|
||||
this.#fireExitSnapshot(e.value);
|
||||
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
|
||||
return { accepted: false, direction: "exit", reason: rp.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`: "ticket" (booth/reader), "lpr" (plate),
|
||||
* or "manual" (a human-intervention barrier re-open that closes an open session —
|
||||
* see reopenBarrier). */
|
||||
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
|
||||
await this.#log.append({
|
||||
type: "vehicle_exit",
|
||||
direction: "exit",
|
||||
source,
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
...(source === "manual" ? reasonPayload("exit.manualOpen") : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
|
||||
#fireExitSnapshot(identity: string): void {
|
||||
void snapshotAsync({
|
||||
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: { ...reasonPayload("exit.open.failed"), detail, source: "booth", exitOpenFailed: true },
|
||||
});
|
||||
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||
}
|
||||
|
||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||
#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;
|
||||
// Same frozen-at-entry category the pay station uses, so the free-grace
|
||||
// check agrees with the booth quote for V2 category tariffs.
|
||||
const category = (entry.payload as { category?: string } | null)?.category;
|
||||
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
|
||||
if (fee === 0) {
|
||||
freeGrace = {
|
||||
tariffVersionId: tv.id,
|
||||
currency: tv.currency,
|
||||
graceExitMin: structure.gracePeriodExitMin,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
const subscription = entryPl.permit === true || entryPl.permitId != null;
|
||||
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
open: !exited,
|
||||
paidAt,
|
||||
subscription,
|
||||
graceExitMin,
|
||||
freeGrace,
|
||||
};
|
||||
}
|
||||
|
||||
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
|
||||
#tariffVersionFor(at: string) {
|
||||
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||
if (!tariff) return null;
|
||||
const versions = this.#db
|
||||
.select()
|
||||
.from(tariffVersions)
|
||||
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||
.all();
|
||||
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||
}
|
||||
|
||||
/** Build a live access adapter from a resolved controller row, or null. */
|
||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
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,236 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, appLogs, desc, eq, sql, type Db } from "@parking/db";
|
||||
import {
|
||||
LOG_LEVEL_ORDER,
|
||||
type AppLogRecord,
|
||||
type ClientLogInput,
|
||||
type LogLevel,
|
||||
type LogSource,
|
||||
} from "@parking/shared";
|
||||
|
||||
// Application/diagnostic LOG SINK — the host-side store behind the third log stream
|
||||
// (app_logs), distinct from the signed ledger and device telemetry. It persists:
|
||||
// - BACKEND warn/error/fatal, fed by a pino stream (see pinoDbStream) so any
|
||||
// app.log.warn/error lands in the DB without changing call sites.
|
||||
// - FRONTEND errors POSTed to /api/logs (failed requests, uncaught errors).
|
||||
// Everything here is UNSIGNED + prunable. Pruned by age AND a row cap so an offline
|
||||
// appliance with finite disk can't be filled by a log storm. See
|
||||
// wiki/concepts/app-logs.md, decisions/event-streams-split.md.
|
||||
|
||||
/** Only warn and above are persisted from the backend (info/debug stay stdout-only). */
|
||||
const BACKEND_PERSIST_MIN: LogLevel = "warn";
|
||||
|
||||
/** Defensive caps so one runaway log can't bloat a row (chars). */
|
||||
const MAX_MESSAGE = 4_000;
|
||||
const MAX_STACK = 16_000;
|
||||
const MAX_CONTEXT_JSON = 16_000;
|
||||
|
||||
export interface LogRetention {
|
||||
/** Delete logs older than this many days. */
|
||||
readonly maxAgeDays: number;
|
||||
/** Hard cap on total rows — the oldest beyond this are pruned. */
|
||||
readonly maxRows: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_RETENTION: LogRetention = {
|
||||
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
|
||||
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
|
||||
};
|
||||
|
||||
function clamp(s: string | null | undefined, max: number): string | null {
|
||||
if (s == null) return null;
|
||||
return s.length > max ? s.slice(0, max) : s;
|
||||
}
|
||||
|
||||
/** Serialize context to JSON, bounded — never throw on a circular/huge object. */
|
||||
function safeContext(ctx: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
|
||||
if (ctx == null) return null;
|
||||
try {
|
||||
const json = JSON.stringify(ctx);
|
||||
if (json.length <= MAX_CONTEXT_JSON) return ctx;
|
||||
return { _truncated: true, preview: json.slice(0, MAX_CONTEXT_JSON) };
|
||||
} catch {
|
||||
return { _unserializable: true };
|
||||
}
|
||||
}
|
||||
|
||||
export class LogService {
|
||||
readonly #db: Db;
|
||||
readonly #retention: LogRetention;
|
||||
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
||||
#writing = false;
|
||||
|
||||
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
||||
this.#db = db;
|
||||
this.#retention = retention;
|
||||
}
|
||||
|
||||
/** Low-level insert. Best-effort: a logging failure must never break a request or
|
||||
* recurse (a DB error here would otherwise log → insert → error → log …). */
|
||||
#insert(row: {
|
||||
level: LogLevel;
|
||||
source: LogSource;
|
||||
message: string;
|
||||
context?: Record<string, unknown> | null;
|
||||
httpStatus?: number | null;
|
||||
path?: string | null;
|
||||
stack?: string | null;
|
||||
userId?: string | null;
|
||||
userAgent?: string | null;
|
||||
createdAt?: string;
|
||||
}): void {
|
||||
if (this.#writing) return;
|
||||
this.#writing = true;
|
||||
try {
|
||||
this.#db
|
||||
.insert(appLogs)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
level: row.level,
|
||||
source: row.source,
|
||||
message: clamp(row.message, MAX_MESSAGE) ?? "",
|
||||
context: safeContext(row.context),
|
||||
httpStatus: row.httpStatus ?? null,
|
||||
path: clamp(row.path, 512),
|
||||
stack: clamp(row.stack, MAX_STACK),
|
||||
userId: row.userId ?? null,
|
||||
userAgent: clamp(row.userAgent, 512),
|
||||
createdAt: row.createdAt ?? new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
} catch {
|
||||
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
||||
// it; that's the recursion we're guarding against.)
|
||||
} finally {
|
||||
this.#writing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a BACKEND log line (called by the pino stream). Below warn is dropped. */
|
||||
recordBackend(level: LogLevel, message: string, context?: Record<string, unknown> | null): void {
|
||||
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||
this.#insert({ level, source: "backend", message, context });
|
||||
}
|
||||
|
||||
/** Persist a FRONTEND-reported log (from POST /api/logs). The server stamps the
|
||||
* user + receive time; the client supplies level/message/context. */
|
||||
recordClient(
|
||||
input: ClientLogInput,
|
||||
meta: { userId?: string | null; userAgent?: string | null },
|
||||
): void {
|
||||
this.#insert({
|
||||
level: input.level,
|
||||
source: "frontend",
|
||||
message: input.message,
|
||||
context: input.context ?? null,
|
||||
httpStatus: input.httpStatus ?? null,
|
||||
path: input.path ?? null,
|
||||
stack: input.stack ?? null,
|
||||
userId: meta.userId ?? null,
|
||||
userAgent: meta.userAgent ?? null,
|
||||
// Keep the client's capture time in context for ordering; createdAt is server time.
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Read recent logs, newest first, with optional level/source/since filters. */
|
||||
query(opts: {
|
||||
limit: number;
|
||||
level?: LogLevel;
|
||||
source?: LogSource;
|
||||
since?: string;
|
||||
}): AppLogRecord[] {
|
||||
const conds = [];
|
||||
if (opts.level) conds.push(eq(appLogs.level, opts.level));
|
||||
if (opts.source) conds.push(eq(appLogs.source, opts.source));
|
||||
if (opts.since) conds.push(sql`${appLogs.createdAt} >= ${opts.since}`);
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(appLogs)
|
||||
.where(conds.length ? and(...conds) : undefined)
|
||||
.orderBy(desc(appLogs.createdAt))
|
||||
.limit(opts.limit)
|
||||
.all();
|
||||
return rows as unknown as AppLogRecord[];
|
||||
}
|
||||
|
||||
/** Prune by age then by row cap. Returns how many rows were deleted. Safe to call
|
||||
* on a timer; cheap (indexed on created_at). */
|
||||
prune(): number {
|
||||
let deleted = 0;
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - this.#retention.maxAgeDays * 86_400_000).toISOString();
|
||||
const byAge = this.#db.delete(appLogs).where(sql`${appLogs.createdAt} < ${cutoff}`).run();
|
||||
deleted += byAge.changes ?? 0;
|
||||
|
||||
// Row cap: keep the newest maxRows, delete the rest. One subquery — find the
|
||||
// created_at boundary of the keep-window, delete older.
|
||||
const total = this.#db.select({ c: sql<number>`count(*)` }).from(appLogs).get();
|
||||
const count = total?.c ?? 0;
|
||||
if (count > this.#retention.maxRows) {
|
||||
const boundary = this.#db
|
||||
.select({ createdAt: appLogs.createdAt })
|
||||
.from(appLogs)
|
||||
.orderBy(desc(appLogs.createdAt))
|
||||
.limit(1)
|
||||
.offset(this.#retention.maxRows - 1)
|
||||
.get();
|
||||
if (boundary) {
|
||||
const byCap = this.#db
|
||||
.delete(appLogs)
|
||||
.where(sql`${appLogs.createdAt} < ${boundary.createdAt}`)
|
||||
.run();
|
||||
deleted += byCap.changes ?? 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
|
||||
* Pino writes one JSON object per line to this stream; we parse, map the numeric level
|
||||
* to a name, and persist. Returned as `{ write }` so it can be passed as pino's stream.
|
||||
* stdout still receives the same line (we tee), so console logging is unchanged.
|
||||
*/
|
||||
export function pinoDbStream(
|
||||
service: LogService,
|
||||
tee: NodeJS.WritableStream,
|
||||
): { write: (line: string) => void } {
|
||||
const NUM_TO_LEVEL: Record<number, LogLevel> = {
|
||||
10: "trace",
|
||||
20: "debug",
|
||||
30: "info",
|
||||
40: "warn",
|
||||
50: "error",
|
||||
60: "fatal",
|
||||
};
|
||||
return {
|
||||
write(line: string): void {
|
||||
// Always tee to the original destination first (don't lose stdout logging).
|
||||
try {
|
||||
tee.write(line);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(line) as {
|
||||
level?: number;
|
||||
msg?: string;
|
||||
err?: { stack?: string; message?: string };
|
||||
[k: string]: unknown;
|
||||
};
|
||||
const level = NUM_TO_LEVEL[obj.level ?? 30] ?? "info";
|
||||
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||
// Strip pino's noisy standard fields from the persisted context.
|
||||
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
|
||||
service.recordBackend(level, typeof msg === "string" ? msg : "", rest);
|
||||
} catch {
|
||||
// A non-JSON line (shouldn't happen with pino) — ignore for persistence.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,380 @@
|
||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// 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;
|
||||
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
|
||||
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
|
||||
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
|
||||
readonly subscription: boolean;
|
||||
/** The subscription id (on-chain `permitId`), when `subscription` is true. */
|
||||
readonly subscriptionId: string | null;
|
||||
/** The subscriber's holder name (for a friendly label instead of the raw key). */
|
||||
readonly subscriptionHolder: string | null;
|
||||
}
|
||||
|
||||
/** Booth session view: everything the pay/exit modal needs in one read. */
|
||||
export interface SessionLookup {
|
||||
readonly identity: string;
|
||||
readonly found: boolean;
|
||||
/** Open = entered, no exit yet. */
|
||||
readonly open: boolean;
|
||||
readonly enteredAt: string | null;
|
||||
readonly exitedAt: string | null;
|
||||
/** Latest payment time, if paid. */
|
||||
readonly paidAt: string | null;
|
||||
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
||||
readonly amountMinor: number | null;
|
||||
readonly currency: string | null;
|
||||
/** True when paid AND still within the walk-back grace window. */
|
||||
readonly withinGrace: boolean;
|
||||
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||
readonly graceExpiresAt: string | null;
|
||||
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
|
||||
readonly subscription: boolean;
|
||||
readonly subscriptionId: string | null;
|
||||
readonly subscriptionHolder: string | null;
|
||||
}
|
||||
|
||||
export class PayStation {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
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;
|
||||
|
||||
// Category was frozen in the signed vehicle_entry payload — pricing AND repricing
|
||||
// both read it from there, so a V2 category tariff yields the same amount at the
|
||||
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
|
||||
const category = (entry.payload as { category?: string } | null)?.category;
|
||||
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
|
||||
return {
|
||||
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,
|
||||
subscription: false, subscriptionId: null, subscriptionHolder: null,
|
||||
};
|
||||
}
|
||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
|
||||
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
|
||||
const exitRow = rows.find((r) => r.type === "vehicle_exit");
|
||||
const open = !exitRow;
|
||||
|
||||
let paidAt: string | null = null;
|
||||
let graceExitMin: number | null = null;
|
||||
for (const r of rows) {
|
||||
if (r.type === "payment") {
|
||||
paidAt = r.occurredAt;
|
||||
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||
}
|
||||
}
|
||||
const graceExpiresAt =
|
||||
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
|
||||
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
|
||||
|
||||
// Amount owed now (best-effort; null if no tariff resolves). Only meaningful while
|
||||
// open AND transient — a subscription is prepaid, never quoted/charged.
|
||||
let amountMinor: number | null = null;
|
||||
let currency: string | null = null;
|
||||
if (open && !isSubscription) {
|
||||
try {
|
||||
const q = this.quote(id);
|
||||
amountMinor = q.amountMinor;
|
||||
currency = q.currency;
|
||||
} catch {
|
||||
/* no active tariff — leave null; modal shows session without a price */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
identity: id, found: true, open,
|
||||
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||
paidAt, amountMinor, currency, withinGrace, graceExpiresAt,
|
||||
subscription: isSubscription, subscriptionId,
|
||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
|
||||
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
|
||||
* until grace expires). One ledger scan, grouped by identity (cheaper than N
|
||||
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
|
||||
* (authoritative — not the sessions projection cache, which can drift).
|
||||
* See wiki/concepts/booth-exit-flow.md.
|
||||
*/
|
||||
activeSessions(): ActiveSession[] {
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
|
||||
// Group the relevant events per identity in one pass.
|
||||
type Acc = {
|
||||
enteredAt?: string;
|
||||
source: string | null;
|
||||
exitedAt?: string;
|
||||
paidAt?: string;
|
||||
graceExitMin?: number;
|
||||
subscriptionId?: string | null;
|
||||
};
|
||||
const byId = new Map<string, Acc>();
|
||||
for (const r of rows) {
|
||||
const id = r.identity;
|
||||
if (!id) continue;
|
||||
if (r.type === "vehicle_entry") {
|
||||
const a = byId.get(id) ?? { source: r.source ?? null };
|
||||
a.enteredAt = r.occurredAt;
|
||||
a.source = r.source ?? a.source;
|
||||
// Subscription occurrence? The entry payload carries permit:true + permitId
|
||||
// (the on-chain field). Mark it so the booth never tries to charge it.
|
||||
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
|
||||
byId.set(id, a);
|
||||
} else if (r.type === "vehicle_exit") {
|
||||
const a = byId.get(id);
|
||||
if (a) a.exitedAt = r.occurredAt;
|
||||
} else if (r.type === "payment") {
|
||||
const a = byId.get(id);
|
||||
if (a) {
|
||||
a.paidAt = r.occurredAt;
|
||||
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const out: ActiveSession[] = [];
|
||||
for (const [identity, a] of byId) {
|
||||
if (!a.enteredAt) continue; // no entry → not a real session
|
||||
const open = a.exitedAt == null;
|
||||
const graceExpiresAt =
|
||||
a.paidAt && a.graceExitMin != null
|
||||
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
|
||||
: null;
|
||||
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
|
||||
const paid = a.paidAt != null;
|
||||
|
||||
// ACTIVE membership:
|
||||
// - exited + within grace → still shown (barrier unconfirmed, may be present);
|
||||
// - exited + past grace → presumed gone, omitted;
|
||||
// - open + UNPAID → always shown (a car owing money never ages out —
|
||||
// it's genuinely still inside until it pays, however long that takes);
|
||||
// - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace
|
||||
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
|
||||
// manual barrier re-open before that path closed the session, or a historical
|
||||
// session like T-397815c0) it would otherwise linger forever. The signed log
|
||||
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
|
||||
if (!open && !withinGrace) continue;
|
||||
if (open && paid && graceExpiresAt != null && !withinGrace) continue;
|
||||
|
||||
const isSubscription = a.subscriptionId !== undefined;
|
||||
|
||||
// Amount owed now: only meaningful for an open + unpaid TRANSIENT session. A
|
||||
// subscription is prepaid — never quote/charge it.
|
||||
let amountMinor: number | null = null;
|
||||
let currency: string | null = null;
|
||||
if (open && a.paidAt == null && !isSubscription) {
|
||||
try {
|
||||
const q = this.quote(identity);
|
||||
amountMinor = q.amountMinor;
|
||||
currency = q.currency;
|
||||
} catch {
|
||||
/* no active tariff — leave null */
|
||||
}
|
||||
}
|
||||
|
||||
out.push({
|
||||
identity,
|
||||
source: a.source,
|
||||
enteredAt: a.enteredAt,
|
||||
exitedAt: a.exitedAt ?? null,
|
||||
open,
|
||||
paidAt: a.paidAt ?? null,
|
||||
amountMinor,
|
||||
currency,
|
||||
withinGrace,
|
||||
graceExpiresAt,
|
||||
subscription: isSubscription,
|
||||
subscriptionId: a.subscriptionId ?? null,
|
||||
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
||||
});
|
||||
}
|
||||
|
||||
// Newest entry first.
|
||||
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The subscriber's holder name for a subscription id (for a friendly UI label),
|
||||
* or null. Best-effort: a deleted subscription just yields null. */
|
||||
#holderOf(subscriptionId: string | null): string | null {
|
||||
if (!subscriptionId) return null;
|
||||
try {
|
||||
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||
return row?.holderName ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
||||
#openEntry(identity: string) {
|
||||
const rows = this.#db
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { eq, laneDevices, type Db } from "@parking/db";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import {
|
||||
isMonitorable,
|
||||
registry,
|
||||
@@ -66,8 +66,8 @@ export class PrinterMonitor {
|
||||
async refreshDevices(): Promise<void> {
|
||||
const rows = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(eq(laneDevices.category, "printer"))
|
||||
.from(devices)
|
||||
.where(eq(devices.category, "printer"))
|
||||
.all();
|
||||
|
||||
const seen = new Set<string>();
|
||||
@@ -89,7 +89,6 @@ export class PrinterMonitor {
|
||||
build: () => driver.create(cfg as never),
|
||||
meta: {
|
||||
deviceId: row.id,
|
||||
lane: row.lane,
|
||||
driverId: row.driverId,
|
||||
role: typeof cfg.role === "string" ? cfg.role : undefined,
|
||||
},
|
||||
@@ -139,7 +138,7 @@ export class PrinterMonitor {
|
||||
|
||||
if (!prev || statusChanged(prev.status, status)) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import { relayForDevice } from "./device-resolve.js";
|
||||
|
||||
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||||
// can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||
// credential is (decision 2026-06-15):
|
||||
// - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
|
||||
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||
//
|
||||
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
|
||||
// resolves to exactly the barrier it sits at, and the direction is inherited from
|
||||
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
|
||||
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
||||
// session state; transient: exit).
|
||||
|
||||
export class ReadDispatcher {
|
||||
readonly #db: Db;
|
||||
readonly #exit: ExitFlow;
|
||||
readonly #subscription: SubscriptionFlow;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
|
||||
constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#exit = exit;
|
||||
this.#subscription = subscription;
|
||||
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 sub = this.#subscription.match(e);
|
||||
if (sub) {
|
||||
return this.#subscription.run(resolved, e, sub);
|
||||
}
|
||||
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
||||
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
||||
// rather than treat an entry scan as an exit.
|
||||
if (resolved.direction === "entry") {
|
||||
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
||||
}
|
||||
return this.#exit.handleAt(resolved, e);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import { eq, roles, users, type Db } from "@parking/db";
|
||||
import {
|
||||
TOKEN_TTL,
|
||||
clearAuthCookies,
|
||||
newCsrfToken,
|
||||
requireRole,
|
||||
permissionsFor,
|
||||
requireAuth,
|
||||
setAuthCookies,
|
||||
} from "../auth.js";
|
||||
|
||||
@@ -17,6 +17,46 @@ interface LoginBody {
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LANGS = ["sq", "en"] as const;
|
||||
type Lang = (typeof LANGS)[number];
|
||||
interface LanguageBody {
|
||||
language: Lang;
|
||||
}
|
||||
|
||||
const THEMES = ["dark", "light"] as const;
|
||||
type Theme = (typeof THEMES)[number];
|
||||
interface ThemeBody {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
/** The session shape the SPA bootstraps from: identity + role + its permission
|
||||
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||
* permissions are the source of truth. */
|
||||
function sessionView(
|
||||
db: Db,
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
roleId: string;
|
||||
language: string;
|
||||
theme: string;
|
||||
fullName?: string | null;
|
||||
},
|
||||
) {
|
||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||
const permissions = [...permissionsFor(user.roleId)];
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roleId: user.roleId,
|
||||
roleName: role?.name ?? user.roleId,
|
||||
permissions,
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
fullName: user.fullName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
@@ -34,12 +74,19 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
|
||||
const csrf = newCsrfToken();
|
||||
const token = await reply.jwtSign(
|
||||
{ sub: user.id, username: user.username, role: user.role, csrf },
|
||||
{ expiresIn: TOKEN_TTL },
|
||||
);
|
||||
// No expiresIn: the token is valid until explicit logout (see auth.ts). The
|
||||
// token carries roleId (not the permission list) — perms resolve per-request,
|
||||
// so a role edit applies immediately with no re-login.
|
||||
const token = await reply.jwtSign({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
roleId: user.roleId,
|
||||
csrf,
|
||||
});
|
||||
setAuthCookies(reply, token, csrf);
|
||||
return { id: user.id, username: user.username, role: user.role };
|
||||
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
||||
// read from the DB, so changing it needs no token refresh.
|
||||
return sessionView(db, user);
|
||||
});
|
||||
|
||||
app.post("/api/auth/logout", async (_req, reply) => {
|
||||
@@ -47,13 +94,49 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Who am I — used by the SPA to bootstrap session state on load.
|
||||
// Who am I — used by the SPA to bootstrap session state on load. Reads the live
|
||||
// `language` preference from the DB (not the token).
|
||||
app.get(
|
||||
"/api/auth/me",
|
||||
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
|
||||
async (req) => {
|
||||
const { sub, username, role } = req.user;
|
||||
return { id: sub, username, role };
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||
if (!row) {
|
||||
// The user was deleted while their cookie was still valid — clear it.
|
||||
clearAuthCookies(reply);
|
||||
return reply.code(401).send({ error: "session no longer valid" });
|
||||
}
|
||||
return sessionView(db, row);
|
||||
},
|
||||
);
|
||||
|
||||
// Change MY own UI language preference (any signed-in user). Persisted to the
|
||||
// users row so it's restored on the next login, from any booth. See i18n.md.
|
||||
app.put<{ Body: LanguageBody }>(
|
||||
"/api/auth/language",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const language = req.body?.language;
|
||||
if (!language || !LANGS.includes(language)) {
|
||||
return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` });
|
||||
}
|
||||
await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run();
|
||||
return { language };
|
||||
},
|
||||
);
|
||||
|
||||
// Change MY own UI theme preference (any signed-in user). Persisted to the users
|
||||
// row like `language`, so it's restored on the next login from any booth.
|
||||
app.put<{ Body: ThemeBody }>(
|
||||
"/api/auth/theme",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const theme = req.body?.theme;
|
||||
if (!theme || !THEMES.includes(theme)) {
|
||||
return reply.code(400).send({ error: `theme must be one of: ${THEMES.join(", ")}` });
|
||||
}
|
||||
await db.update(users).set({ theme }).where(eq(users.id, req.user.sub)).run();
|
||||
return { theme };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { DeviceMonitor } from "../device-monitor.js";
|
||||
|
||||
// Unified device-status snapshot for the booth footer. The DeviceMonitor polls all
|
||||
// configured devices (relays/readers/cameras via healthCheck, printers via their
|
||||
// rich readStatus) in the background; this exposes its cache. Live updates ride the
|
||||
// booth WebSocket (kind:"device-status") — this REST route is the initial load /
|
||||
// fallback. Any authenticated role may read (operational, not a setup action).
|
||||
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
|
||||
|
||||
export async function deviceStatusRoutes(
|
||||
app: FastifyInstance,
|
||||
monitor: DeviceMonitor,
|
||||
): Promise<void> {
|
||||
const guard = requirePermission("device:read");
|
||||
|
||||
app.get("/api/devices/status", { preHandler: guard }, async () => ({
|
||||
devices: monitor.snapshot(),
|
||||
}));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { 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 { 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;
|
||||
|
||||
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, events, type Db } from "@parking/db";
|
||||
import { requireRole } from "../auth.js";
|
||||
import { desc, gte, ledgerEvents, type Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { enrichEvent } from "../event-enrich.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
|
||||
// Read access to the append-only signed event log. NO write/update/delete routes
|
||||
@@ -13,17 +15,30 @@ export async function eventRoutes(
|
||||
db: Db,
|
||||
eventLog: EventLog,
|
||||
): Promise<void> {
|
||||
// Any authenticated role may read the log (it's the audit trail).
|
||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
// Reading the log (the audit trail).
|
||||
const guard = requirePermission("event:read");
|
||||
|
||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
||||
app.get<{ Querystring: { limit?: string } }>(
|
||||
// Optional `since` (ISO) scopes the page to events at/after that instant — the
|
||||
// booth passes the current shift's start so the live feed shows ONLY this shift's
|
||||
// activity (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
||||
app.get<{ Querystring: { limit?: string; since?: string } }>(
|
||||
"/api/events",
|
||||
{ preHandler: guard },
|
||||
async (req) => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all();
|
||||
return { events: rows };
|
||||
const since = (req.query.since ?? "").trim();
|
||||
const rows = db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(since ? gte(ledgerEvents.occurredAt, since) : undefined)
|
||||
.orderBy(desc(ledgerEvents.index))
|
||||
.limit(limit)
|
||||
.all();
|
||||
// Attach read-time display fields (e.g. subscriber name) without touching the
|
||||
// signed record. The cast bridges the Drizzle row to the shared LedgerEvent.
|
||||
const events = rows.map((r) => enrichEvent(db, r as unknown as LedgerEvent));
|
||||
return { events };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -32,7 +47,7 @@ export async function eventRoutes(
|
||||
// reconciliation job / "is the log intact?" check calls.
|
||||
app.get(
|
||||
"/api/events/verify",
|
||||
{ preHandler: requireRole("admin") },
|
||||
{ preHandler: requirePermission("event:read") },
|
||||
async () => eventLog.verifyChain(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { AppLogRecord, ClientLogInput, LogLevel } from "@parking/shared";
|
||||
import { requireAuth, requirePermission } from "../auth.js";
|
||||
import type { LogService } from "../log-service.js";
|
||||
|
||||
// Application/diagnostic logs (app_logs) — see wiki/concepts/app-logs.md. Two ends:
|
||||
// - POST /api/logs : the FRONTEND ships its errors here (failed requests, uncaught
|
||||
// exceptions). Any signed-in user may write (it's their own
|
||||
// browser's diagnostics); CSRF still applies (mutation).
|
||||
// - GET /api/logs : read the store — gated by `log:read` (admin/diagnostic role).
|
||||
// Writes go through the shared LogService (bounded, best-effort, reentrancy-guarded);
|
||||
// the DB sink for BACKEND warn+ is wired at the pino stream, not here.
|
||||
|
||||
const LEVELS: ReadonlySet<string> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
|
||||
|
||||
/** Cap a single ingest batch so a misbehaving client can't flood the store. */
|
||||
const MAX_BATCH = 50;
|
||||
|
||||
function isValidEntry(e: unknown): e is ClientLogInput {
|
||||
if (!e || typeof e !== "object") return false;
|
||||
const o = e as Record<string, unknown>;
|
||||
return typeof o.message === "string" && typeof o.level === "string" && LEVELS.has(o.level);
|
||||
}
|
||||
|
||||
export async function logRoutes(app: FastifyInstance, logService: LogService): Promise<void> {
|
||||
// INGEST — accept one entry or a small batch ({ entries: [...] }). Returns 204.
|
||||
// Deliberately tolerant: it never 4xx's on a malformed entry (a client erroring
|
||||
// while reporting an error shouldn't get a second error) — invalid items are skipped.
|
||||
app.post<{ Body: ClientLogInput | { entries?: unknown[] } }>(
|
||||
"/api/logs",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const body = req.body as ClientLogInput | { entries?: unknown[] };
|
||||
const raw = Array.isArray((body as { entries?: unknown[] }).entries)
|
||||
? (body as { entries: unknown[] }).entries
|
||||
: [body];
|
||||
const userId = req.user?.sub ?? null;
|
||||
const userAgent = req.headers["user-agent"] ?? null;
|
||||
for (const entry of raw.slice(0, MAX_BATCH)) {
|
||||
if (!isValidEntry(entry)) continue;
|
||||
logService.recordClient(entry, { userId, userAgent });
|
||||
}
|
||||
reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
// READ — newest first, with optional level/source/since filters + a limit. The
|
||||
// booth Logs viewer calls this. Gated by log:read.
|
||||
app.get<{ Querystring: { limit?: string; level?: string; source?: string; since?: string } }>(
|
||||
"/api/logs",
|
||||
{ preHandler: requirePermission("log:read") },
|
||||
async (req): Promise<{ logs: AppLogRecord[] }> => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 2000);
|
||||
const level = (req.query.level ?? "").trim();
|
||||
const source = (req.query.source ?? "").trim();
|
||||
const since = (req.query.since ?? "").trim();
|
||||
const logs = logService.query({
|
||||
limit,
|
||||
level: LEVELS.has(level) ? (level as LogLevel) : undefined,
|
||||
source: source === "frontend" || source === "backend" ? source : undefined,
|
||||
since: since || undefined,
|
||||
});
|
||||
return { logs };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import { NoPrinterAvailableError } from "@parking/devices";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import {
|
||||
NoOpenSessionError,
|
||||
NoTariffError,
|
||||
type PayStation,
|
||||
} from "../pay-station.js";
|
||||
import type { ExitFlow } from "../exit-flow.js";
|
||||
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
||||
import { printPaymentReceipt } 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;
|
||||
}
|
||||
interface ReceiptBody {
|
||||
identity: string;
|
||||
}
|
||||
|
||||
export async function payRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
payStation: PayStation,
|
||||
exitFlow: ExitFlow,
|
||||
shift: ShiftService,
|
||||
): Promise<void> {
|
||||
// Reads (lookup, active sessions, quote) need session/payment read; the booth
|
||||
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
|
||||
// single guard covers the whole booth flow — anyone who takes payment also reads
|
||||
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
|
||||
const guard = requirePermission("payment:create");
|
||||
const readGuard = requirePermission("session:read");
|
||||
|
||||
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
|
||||
// re-open is processed, so every taking is attributed to a shift (one operator's
|
||||
// accountability period). Read-only lookups (session/active/quote) stay ungated so
|
||||
// the modal can still DISPLAY the session and prompt the operator to open a shift.
|
||||
// Returns 409 { error, code: "no_shift" } so the UI can show the "open a shift"
|
||||
// prompt rather than a generic failure. See wiki/concepts/shift.md.
|
||||
const requireShift = async (
|
||||
_req: import("fastify").FastifyRequest,
|
||||
reply: import("fastify").FastifyReply,
|
||||
) => {
|
||||
try {
|
||||
shift.requireOpenShift();
|
||||
} catch (err) {
|
||||
if (err instanceof NoShiftOpenError) {
|
||||
return reply.code(409).send({ error: err.message, code: "no_shift" });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Active sessions for the booth list: still-open OR exited-but-within-grace
|
||||
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
|
||||
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
|
||||
app.get("/api/sessions/active", { preHandler: readGuard }, async () => ({
|
||||
sessions: payStation.activeSessions(),
|
||||
}));
|
||||
|
||||
// Session lookup for the booth pay/exit modal: entry/exit times, paid state,
|
||||
// amount owed now, walk-back-grace status. Read-only (no side effect).
|
||||
app.get<{ Params: { identity: string } }>(
|
||||
"/api/session/:identity",
|
||||
{ preHandler: readGuard },
|
||||
async (req, reply) => {
|
||||
const identity = (req.params.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
return payStation.lookup(identity);
|
||||
},
|
||||
);
|
||||
|
||||
// Booth-driven exit: validate (paid + grace, or free entry-grace) THEN sign
|
||||
// vehicle_exit + open the barrier. Maps the discriminated result to HTTP:
|
||||
// - validation reject → 409 with a reason (operator takes payment first),
|
||||
// - exit signed but barrier didn't open → 200 { opened:false } (payment stands;
|
||||
// operator opens manually; an anomaly is already signed),
|
||||
// - clean exit → 200 { opened:true }.
|
||||
app.post<{ Body: ExitBody }>(
|
||||
"/api/exit",
|
||||
{ preHandler: [guard, requireShift] },
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
const res = await exitFlow.exitForBooth(identity);
|
||||
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
|
||||
return reply.code(200).send(res);
|
||||
},
|
||||
);
|
||||
|
||||
// Human-intervention barrier re-open for an ACTIVE (paid) session — damaged
|
||||
// ticket / dead scanner / phantom re-close. Re-pulses the exit relay + signs an
|
||||
// anomaly (attributed); NEVER a second vehicle_exit. Refused without a payment
|
||||
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
|
||||
app.post<{ Body: ExitBody }>(
|
||||
"/api/barrier/reopen",
|
||||
{ preHandler: [guard, requireShift] },
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
const operator = req.user?.username;
|
||||
const res = await exitFlow.reopenBarrier(identity, operator);
|
||||
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||
return reply.code(200).send(res);
|
||||
},
|
||||
);
|
||||
|
||||
// Quote: what does this session owe right now? (No side effect.)
|
||||
app.get<{ Querystring: QuoteQuery }>(
|
||||
"/api/pay/quote",
|
||||
{ preHandler: readGuard },
|
||||
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, requireShift] },
|
||||
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, requireShift] },
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
const view = payStation.lookup(identity);
|
||||
if (!view.found || !view.open) {
|
||||
return reply.code(404).send({ error: "no open session for ticket" });
|
||||
}
|
||||
if (view.paidAt == null) {
|
||||
return reply.code(409).send({ error: "session not paid — take payment before printing a voucher" });
|
||||
}
|
||||
try {
|
||||
const printedBy = await printPaymentReceipt(db, identity, { voucher: true }, app.log);
|
||||
return reply.code(200).send({ ok: true, printedBy });
|
||||
} catch (err) {
|
||||
if (err instanceof NoPrinterAvailableError) {
|
||||
return reply.code(503).send({ error: err.message });
|
||||
}
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Print a standalone PAYMENT RECEIPT (transparency: entry/paid/duration/amount,
|
||||
// no barcode) on the booth printer. Used (a) auto, right after a payment when no
|
||||
// voucher is issued, and (b) on-demand "reprint" if the slip jammed. Requires the
|
||||
// session to be PAID. See wiki/concepts/booth-exit-flow.md.
|
||||
app.post<{ Body: ReceiptBody }>(
|
||||
"/api/receipt",
|
||||
{ preHandler: [guard, requireShift] },
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
const view = payStation.lookup(identity);
|
||||
if (!view.found) {
|
||||
return reply.code(404).send({ error: "no session for ticket" });
|
||||
}
|
||||
if (view.paidAt == null) {
|
||||
return reply.code(409).send({ error: "session not paid — nothing to receipt" });
|
||||
}
|
||||
try {
|
||||
const printedBy = await printPaymentReceipt(db, identity, { voucher: false }, app.log);
|
||||
return reply.code(200).send({ ok: true, printedBy });
|
||||
} catch (err) {
|
||||
if (err instanceof NoPrinterAvailableError) {
|
||||
return reply.code(503).send({ error: err.message });
|
||||
}
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mapError(reply: import("fastify").FastifyReply, err: unknown) {
|
||||
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 });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireRole } from "../auth.js";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { deviceEvents } from "../device-events.js";
|
||||
import type { PrinterMonitor } from "../printer-monitor.js";
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function printerRoutes(
|
||||
app: FastifyInstance,
|
||||
monitor: PrinterMonitor,
|
||||
): Promise<void> {
|
||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
const guard = requirePermission("device:read");
|
||||
|
||||
// Current status of every monitored printer (cached — no device round-trip).
|
||||
app.get("/api/printers/status", { preHandler: guard }, async () => ({
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import type { DeviceReadEvent } from "../device-events.js";
|
||||
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||
import type { CredentialCapture } from "../credential-capture.js";
|
||||
|
||||
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
||||
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||
// 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,
|
||||
capture: CredentialCapture,
|
||||
): Promise<void> {
|
||||
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||
// 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 matchedRowId = readerRowIdForSerial(serial);
|
||||
const deviceId = matchedRowId ?? serial;
|
||||
|
||||
let accepted = false;
|
||||
if (cardid) {
|
||||
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
|
||||
// value for the subscription form and do NOT run the access flow (we must not
|
||||
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
|
||||
// Reads from the OTHER reader are untouched and dispatch normally below.
|
||||
if (capture.tryConsume(deviceId, cardid)) {
|
||||
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
|
||||
accepted = true; // beep "ok" so the operator knows the card was read
|
||||
} else {
|
||||
const read: DeviceReadEvent = {
|
||||
driverId: "gee-qr-reader",
|
||||
deviceId,
|
||||
value: cardid,
|
||||
kind: "qr",
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
const outcome = await dispatcher.dispatch(read);
|
||||
accepted = outcome.accepted;
|
||||
// Per-read diagnostic: which reader (serial) sent it, which configured device
|
||||
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
|
||||
// the logs (e.g. an entry-side scan resolving to the exit relay).
|
||||
app.log.info(
|
||||
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
|
||||
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
||||
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
|
||||
);
|
||||
} catch (err) {
|
||||
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||
|
||||
// Role management (admin). Roles are DATA: an admin composes a role from the
|
||||
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
|
||||
// role. The built-in `admin` role (id ADMIN_ROLE_ID) is PROTECTED — it can't be
|
||||
// edited or deleted and always resolves to every permission in code. Every write
|
||||
// here bumps the in-memory permission cache so changes take effect on the next
|
||||
// request. See @parking/shared PERMISSIONS and ../auth.ts.
|
||||
//
|
||||
// PRIVILEGE-ESCALATION GUARD: `role:update`/`role:create` must NOT let a caller
|
||||
// grant a permission they don't themselves hold — otherwise a non-admin with
|
||||
// `role:*` could edit their own role to add (say) `tariff:update`, or mint a role
|
||||
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
|
||||
// only put permissions they ALREADY hold onto a role. An admin (full set) is
|
||||
// unrestricted, which is the intended behaviour.
|
||||
|
||||
interface RoleBody {
|
||||
name: string;
|
||||
permissions: string[];
|
||||
}
|
||||
interface UpdateBody {
|
||||
name?: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
const VALID = new Set<string>(PERMISSIONS);
|
||||
|
||||
/** Validate + dedupe a requested permission list against the code-defined grid. */
|
||||
function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | { ok: false; bad: string } {
|
||||
if (!Array.isArray(input)) return { ok: false, bad: "permissions must be an array" };
|
||||
const out = new Set<Permission>();
|
||||
for (const p of input) {
|
||||
if (typeof p !== "string" || !VALID.has(p)) return { ok: false, bad: `unknown permission: ${String(p)}` };
|
||||
out.add(p as Permission);
|
||||
}
|
||||
return { ok: true, perms: [...out] };
|
||||
}
|
||||
|
||||
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const readGuard = requirePermission("role:read");
|
||||
const createGuard = requirePermission("role:create");
|
||||
const updateGuard = requirePermission("role:update");
|
||||
const deleteGuard = requirePermission("role:delete");
|
||||
|
||||
/** A role + its permission list + how many users hold it. */
|
||||
function roleView(roleId: string) {
|
||||
const role = db.select().from(roles).where(eq(roles.id, roleId)).get();
|
||||
if (!role) return null;
|
||||
const perms = db
|
||||
.select({ permission: rolePermissions.permission })
|
||||
.from(rolePermissions)
|
||||
.where(eq(rolePermissions.roleId, roleId))
|
||||
.all()
|
||||
.map((r) => r.permission);
|
||||
const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length;
|
||||
// The admin role always reports the full grid (it's enforced in code).
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
builtin: role.builtin === 1,
|
||||
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
|
||||
userCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace a role's permission rows with `perms` (in a single pass). */
|
||||
function setPermissions(roleId: string, perms: Permission[]): void {
|
||||
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
|
||||
for (const p of perms) {
|
||||
db.insert(rolePermissions).values({ roleId, permission: p }).run();
|
||||
}
|
||||
}
|
||||
|
||||
// The full permission grid (for the role-composer checkbox UI) + every role.
|
||||
app.get("/api/roles", { preHandler: readGuard }, async () => {
|
||||
const all = db.select().from(roles).all();
|
||||
return {
|
||||
catalog: PERMISSIONS,
|
||||
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
|
||||
};
|
||||
});
|
||||
|
||||
/** Reject any permission in `perms` the caller does not themselves hold — so a
|
||||
* non-admin can't grant privileges beyond their own. Returns the offending
|
||||
* permission, or null if all are within the caller's set. (Admin holds the full
|
||||
* set, so it never trips.) */
|
||||
function escalates(callerRoleId: string, perms: Permission[]): Permission | null {
|
||||
const held = permissionsFor(callerRoleId);
|
||||
return perms.find((p) => !held.has(p)) ?? null;
|
||||
}
|
||||
|
||||
// Create a composable role from a name + a permission set.
|
||||
app.post<{ Body: RoleBody }>("/api/roles", { preHandler: createGuard }, async (req, reply) => {
|
||||
const name = (req.body?.name ?? "").trim();
|
||||
if (!name) return reply.code(400).send({ error: "name required" });
|
||||
if (db.select().from(roles).where(eq(roles.name, name)).get()) {
|
||||
return reply.code(409).send({ error: "a role with that name already exists" });
|
||||
}
|
||||
const cleaned = cleanPermissions(req.body?.permissions ?? []);
|
||||
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
|
||||
const over = escalates(req.user.roleId, cleaned.perms);
|
||||
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||
|
||||
const id = randomUUID();
|
||||
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
||||
setPermissions(id, cleaned.perms);
|
||||
bumpPermsCache();
|
||||
return reply.code(201).send(roleView(id));
|
||||
});
|
||||
|
||||
// Edit a role's name and/or permission set. The built-in admin role is locked.
|
||||
app.put<{ Params: { id: string }; Body: UpdateBody }>(
|
||||
"/api/roles/:id",
|
||||
{ preHandler: updateGuard },
|
||||
async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const role = db.select().from(roles).where(eq(roles.id, id)).get();
|
||||
if (!role) return reply.code(404).send({ error: "role not found" });
|
||||
if (role.builtin === 1) {
|
||||
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
|
||||
}
|
||||
|
||||
if (req.body?.name != null) {
|
||||
const name = req.body.name.trim();
|
||||
if (!name) return reply.code(400).send({ error: "name cannot be empty" });
|
||||
const clash = db.select().from(roles).where(eq(roles.name, name)).get();
|
||||
if (clash && clash.id !== id) return reply.code(409).send({ error: "a role with that name already exists" });
|
||||
db.update(roles).set({ name }).where(eq(roles.id, id)).run();
|
||||
}
|
||||
if (req.body?.permissions != null) {
|
||||
const cleaned = cleanPermissions(req.body.permissions);
|
||||
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
|
||||
const over = escalates(req.user.roleId, cleaned.perms);
|
||||
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||
setPermissions(id, cleaned.perms);
|
||||
}
|
||||
bumpPermsCache();
|
||||
return roleView(id);
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a role. Refused if it's built-in or any user still holds it.
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/roles/:id",
|
||||
{ preHandler: deleteGuard },
|
||||
async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const role = db.select().from(roles).where(eq(roles.id, id)).get();
|
||||
if (!role) return reply.code(404).send({ error: "role not found" });
|
||||
if (role.builtin === 1) {
|
||||
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
|
||||
}
|
||||
const holders = db.select().from(users).where(eq(users.roleId, id)).all().length;
|
||||
if (holders > 0) {
|
||||
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
||||
}
|
||||
db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run();
|
||||
db.delete(roles).where(eq(roles.id, id)).run();
|
||||
bumpPermsCache();
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
+197
-110
@@ -1,6 +1,6 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
||||
import { eq, devices, setupState, type Db } from "@parking/db";
|
||||
import {
|
||||
hasPreconditions,
|
||||
hasPushConfig,
|
||||
@@ -10,18 +10,21 @@ import {
|
||||
registry,
|
||||
setDeviceLogSink,
|
||||
type DeviceCategory,
|
||||
type DeviceConfig,
|
||||
} from "@parking/devices";
|
||||
import { requireRole } from "../auth.js";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||
|
||||
// First-run setup API. The admin reads the driver catalog and assigns devices
|
||||
// per lane. See wiki/concepts/first-run-setup.md.
|
||||
|
||||
interface AssignBody {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
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;
|
||||
* matters on multi-NIC hosts). */
|
||||
backendIp?: string;
|
||||
@@ -48,25 +51,143 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
||||
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,
|
||||
db: Db,
|
||||
// Called after the set of assignments changes (assign/unassign) so the caller
|
||||
// can refresh anything derived from it — e.g. the device id->lane map.
|
||||
onAssignmentsChanged: () => void = () => {},
|
||||
): Promise<void> {
|
||||
args: {
|
||||
id: string;
|
||||
driverId: string;
|
||||
config: DeviceConfig;
|
||||
backendIp?: string;
|
||||
existingConfig?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<ConfigureOutcome> {
|
||||
const { id, driverId, config, backendIp, existingConfig } = args;
|
||||
|
||||
// Start from any machine-only secrets already on the row (push/relay passwords
|
||||
// are redacted out of the client's copy, so an edit would otherwise drop them),
|
||||
// then layer the submitted config on top.
|
||||
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
|
||||
// The web password the admin typed is a DESIRED value, not a stored fact:
|
||||
// it's passed to the driver (via create(config) below) as the rotation
|
||||
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
|
||||
// secrets.webPassword gets saved — otherwise a failed rotation would leave
|
||||
// the DB claiming a password the device never accepted (login stays old).
|
||||
delete fullConfig.webPassword;
|
||||
// webPasswordCurrent is an input-only credential (the OLD password used to
|
||||
// authorize the change) — never persist it as typed.
|
||||
delete fullConfig.webPasswordCurrent;
|
||||
// Residual-risk warnings from device hardening (shown to the admin; the
|
||||
// save still succeeds — these are "configured, but note X" advisories).
|
||||
const hardenWarnings: string[] = [];
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = registry.create(driverId, config); // validates required fields
|
||||
} catch (err) {
|
||||
return { error: { code: 400, message: (err as Error).message } };
|
||||
}
|
||||
|
||||
// Configure the device on save (before persisting, so we don't store a row
|
||||
// for a device we couldn't configure):
|
||||
// 1. fix preconditions (e.g. disable input_link_relay so a button press
|
||||
// doesn't auto-fire its relay — host must decide first),
|
||||
// 2. harden (relay password + disable unused protocol channels), and
|
||||
// 3. set up input push (Digest creds + push URLs).
|
||||
// Each step is a device config write (the device reboots on apply).
|
||||
try {
|
||||
if (hasPreconditions(device)) {
|
||||
const fixed = await device.fixPreconditions();
|
||||
if (!fixed.ok) {
|
||||
const unfixable = fixed.issues.find((i) => !i.fixable);
|
||||
return {
|
||||
error: {
|
||||
code: 502,
|
||||
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isHardenable(device)) {
|
||||
const { secrets, warnings } = await device.harden();
|
||||
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
||||
// Surface residual-risk warnings (e.g. firmware that won't disable the
|
||||
// password-less string protocol) so the admin can act (web-UI step).
|
||||
for (const w of warnings ?? []) {
|
||||
app.log.warn(`harden(${driverId} ${id}): ${w}`);
|
||||
hardenWarnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPushConfig(device)) {
|
||||
const host = String(config.host ?? "");
|
||||
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
|
||||
const pushHost = backendIp ?? backendIpForDevice(host);
|
||||
if (!pushHost) {
|
||||
return {
|
||||
error: {
|
||||
code: 400,
|
||||
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const pushUser = "dingtian";
|
||||
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
||||
// (longer is silently truncated → auth mismatch), so keep it short.
|
||||
const pushPassword = randomBytes(12).toString("hex");
|
||||
await device.configureInputPush({
|
||||
host: pushHost,
|
||||
port: backendPort(),
|
||||
pathBase: `/api/devices/${driverId}/${id}/input`,
|
||||
auth: { user: pushUser, password: pushPassword },
|
||||
});
|
||||
fullConfig.pushUser = pushUser;
|
||||
fullConfig.pushPassword = pushPassword;
|
||||
// Record the backend IP the device was told to push to — lets us detect
|
||||
// a later mismatch if the host's IP changes.
|
||||
fullConfig.backendIp = pushHost;
|
||||
}
|
||||
} catch (err) {
|
||||
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
|
||||
}
|
||||
|
||||
return { config: fullConfig, warnings: hardenWarnings };
|
||||
}
|
||||
|
||||
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
|
||||
// Setup endpoints require an admin (cookie-based JWT — see ../auth.ts).
|
||||
const adminGuard = requireRole("admin");
|
||||
// Device setup is site administration — it changes which hardware the site runs
|
||||
// and how readers bind to relays. Gated on site:update. See ../auth.ts.
|
||||
const adminGuard = requirePermission("site:update");
|
||||
|
||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||
// `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 () => {
|
||||
const catalog = registry.catalog();
|
||||
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).
|
||||
@@ -108,7 +229,7 @@ export async function setupRoutes(
|
||||
{ preHandler: adminGuard },
|
||||
async () => {
|
||||
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) }));
|
||||
return { completedAt: state?.completedAt ?? null, assignments };
|
||||
},
|
||||
@@ -152,117 +273,84 @@ export async function setupRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// Assign a device to a lane. Validates the chosen driver + config, configures
|
||||
// the device (fix preconditions + set up Digest-authenticated input push — no
|
||||
// manual device-web-UI step by the admin), then persists. Fails the save if
|
||||
// the device can't be configured. See wiki/concepts/device-input-flow.md.
|
||||
// Assign a device. Validates the chosen driver + config, configures the device
|
||||
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
||||
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||
// configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md.
|
||||
app.post<{ Body: AssignBody }>(
|
||||
"/api/setup/assign",
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const { lane, category, driverId, config, backendIp } = req.body;
|
||||
const { category, driverId, config, backendIp } = req.body;
|
||||
const driver = registry.get(driverId);
|
||||
if (!driver || driver.category !== category) {
|
||||
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const fullConfig: Record<string, unknown> = { ...config };
|
||||
// The web password the admin typed is a DESIRED value, not a stored fact:
|
||||
// it's passed to the driver (via create(config) below) as the rotation
|
||||
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
|
||||
// secrets.webPassword gets saved — otherwise a failed rotation would leave
|
||||
// the DB claiming a password the device never accepted (login stays old).
|
||||
delete fullConfig.webPassword;
|
||||
// webPasswordCurrent is an input-only credential (the OLD password used to
|
||||
// authorize the change) — never persist it as typed.
|
||||
delete fullConfig.webPasswordCurrent;
|
||||
// Residual-risk warnings from device hardening (shown to the admin; the
|
||||
// save still succeeds — these are "configured, but note X" advisories).
|
||||
const hardenWarnings: string[] = [];
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = registry.create(driverId, config); // validates required fields
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: (err as Error).message });
|
||||
}
|
||||
|
||||
// Configure the device on save (before persisting, so we don't store a row
|
||||
// for a device we couldn't configure):
|
||||
// 1. fix preconditions (e.g. disable input_link_relay so a button press
|
||||
// doesn't auto-fire its relay — host must decide first),
|
||||
// 2. harden (relay password + disable unused protocol channels), and
|
||||
// 3. set up input push (Digest creds + push URLs).
|
||||
// Each step is a device config write (the device reboots on apply).
|
||||
try {
|
||||
if (hasPreconditions(device)) {
|
||||
const fixed = await device.fixPreconditions();
|
||||
if (!fixed.ok) {
|
||||
const unfixable = fixed.issues.find((i) => !i.fixable);
|
||||
return reply.code(502).send({
|
||||
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isHardenable(device)) {
|
||||
const { secrets, warnings } = await device.harden();
|
||||
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
||||
// Surface residual-risk warnings (e.g. firmware that won't disable the
|
||||
// password-less string protocol) so the admin can act (web-UI step).
|
||||
for (const w of warnings ?? []) {
|
||||
app.log.warn(`harden(${driverId} ${id}): ${w}`);
|
||||
hardenWarnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPushConfig(device)) {
|
||||
const host = String(config.host ?? "");
|
||||
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
|
||||
const pushHost = backendIp ?? backendIpForDevice(host);
|
||||
if (!pushHost) {
|
||||
return reply.code(400).send({
|
||||
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
|
||||
});
|
||||
}
|
||||
const pushUser = "dingtian";
|
||||
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
||||
// (longer is silently truncated → auth mismatch), so keep it short.
|
||||
const pushPassword = randomBytes(12).toString("hex");
|
||||
await device.configureInputPush({
|
||||
host: pushHost,
|
||||
port: backendPort(),
|
||||
pathBase: `/api/devices/${driverId}/${id}/input`,
|
||||
auth: { user: pushUser, password: pushPassword },
|
||||
});
|
||||
fullConfig.pushUser = pushUser;
|
||||
fullConfig.pushPassword = pushPassword;
|
||||
// Record the backend IP the device was told to push to — lets us detect
|
||||
// a later mismatch if the host's IP changes.
|
||||
fullConfig.backendIp = pushHost;
|
||||
}
|
||||
} catch (err) {
|
||||
return reply
|
||||
.code(502)
|
||||
.send({ error: `device configuration failed: ${(err as Error).message}` });
|
||||
const outcome = await configureDevice(app, { id, driverId, config, backendIp });
|
||||
if ("error" in outcome) {
|
||||
return reply.code(outcome.error.code).send({ error: outcome.error.message });
|
||||
}
|
||||
|
||||
const row = {
|
||||
id,
|
||||
lane,
|
||||
category,
|
||||
driverId,
|
||||
config: fullConfig,
|
||||
config: outcome.config,
|
||||
enabled: true,
|
||||
};
|
||||
await db.insert(laneDevices).values(row);
|
||||
onAssignmentsChanged(); // refresh derived state (device->lane map)
|
||||
await db.insert(devices).values(row);
|
||||
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
||||
return reply.code(201).send({
|
||||
...row,
|
||||
config: redactSecrets(fullConfig),
|
||||
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
|
||||
config: redactSecrets(outcome.config),
|
||||
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Edit an assigned device in place. Same configure pipeline as assign, but it
|
||||
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
|
||||
// since the id is baked into the device's input-push URL
|
||||
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
|
||||
// break push until reconfigured; PATCH re-runs harden/push against the same id.
|
||||
// The category and driver are fixed at create time (an edit can't change what
|
||||
// KIND of device a slot is); only config changes. Admin-only.
|
||||
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
|
||||
"/api/setup/assign/:id",
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(eq(devices.id, req.params.id))
|
||||
.get();
|
||||
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
||||
|
||||
const { config, backendIp } = req.body;
|
||||
const outcome = await configureDevice(app, {
|
||||
id: existing.id,
|
||||
driverId: existing.driverId,
|
||||
config,
|
||||
backendIp,
|
||||
// Carry forward machine-only secrets the client never received, so an
|
||||
// edit that omits them doesn't blank out push/relay passwords.
|
||||
existingConfig: existing.config,
|
||||
});
|
||||
if ("error" in outcome) {
|
||||
return reply.code(outcome.error.code).send({ error: outcome.error.message });
|
||||
}
|
||||
|
||||
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
|
||||
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
|
||||
return reply.code(200).send({
|
||||
id: existing.id,
|
||||
category: existing.category,
|
||||
driverId: existing.driverId,
|
||||
config: redactSecrets(outcome.config),
|
||||
enabled: existing.enabled,
|
||||
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -283,13 +371,12 @@ export async function setupRoutes(
|
||||
async (req, reply) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(eq(laneDevices.id, req.params.id))
|
||||
.from(devices)
|
||||
.where(eq(devices.id, req.params.id))
|
||||
.get();
|
||||
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
||||
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id));
|
||||
onAssignmentsChanged(); // refresh derived state (device->lane map)
|
||||
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
|
||||
await db.delete(devices).where(eq(devices.id, req.params.id));
|
||||
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission, roleHasPermissions } 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;
|
||||
}
|
||||
|
||||
interface ShiftsQuery {
|
||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||
operator?: string;
|
||||
/** ISO window over shift START time. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||
// 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> {
|
||||
// Reading the shift state vs. opening/closing one's own shift.
|
||||
const readGuard = requirePermission("shift:read");
|
||||
const guard = requirePermission("shift:create");
|
||||
|
||||
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
|
||||
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
|
||||
// someone else's shift → disabled. Also returns the live drawer balance.
|
||||
// - open: the open shift { startedAt, operator } or null (site-wide)
|
||||
// - isMine: true iff the open shift belongs to the requesting operator
|
||||
// - operator: the requesting user (for the UI's own identity)
|
||||
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
|
||||
const me = req.user.username;
|
||||
const open = shift.currentOpenShift();
|
||||
const heldBy = open?.identity ?? null;
|
||||
const drawer = shift.drawerBalance();
|
||||
return {
|
||||
operator: me,
|
||||
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
||||
isMine: open != null && heldBy === me,
|
||||
drawerMinor: drawer.balanceMinor,
|
||||
currency: drawer.currency,
|
||||
};
|
||||
});
|
||||
|
||||
// Completed shift history. SCOPED by permission:
|
||||
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||
// `operator` and a `from`/`to` time window over each shift's START.
|
||||
// This keeps one operator from reading another's takings while letting admins
|
||||
// reconcile across the site. The data is the signed shift_z_report chain.
|
||||
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
|
||||
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
||||
const q = req.query ?? {};
|
||||
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
||||
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||
const shifts = shift.listShifts({ operator, from, to });
|
||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||
});
|
||||
|
||||
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
|
||||
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
|
||||
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
|
||||
app.post<{ Body: CashMovementBody }>(
|
||||
"/api/cash-movement",
|
||||
{ preHandler: requirePermission("shift:cash") },
|
||||
async (req, reply) => {
|
||||
const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody);
|
||||
try {
|
||||
return await shift.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency);
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||
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,112 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import { requirePermission } 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",
|
||||
// IANA timezone for tariff wall-clock windows (copied into each published version).
|
||||
"timezone",
|
||||
// Default vehicle/customer category frozen onto each transient entry.
|
||||
"defaultVehicleCategory",
|
||||
] as const;
|
||||
type TextField = (typeof TEXT_FIELDS)[number];
|
||||
|
||||
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||
/** Nominal capacity; null = no limit. */
|
||||
capacity?: number | null;
|
||||
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||||
exitVoucherDefault?: boolean;
|
||||
/** Site default monthly subscription price in minor units (pre-fills the form). */
|
||||
subscriptionMonthlyPriceMinor?: number | null;
|
||||
}
|
||||
|
||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||
* + every metadata field. */
|
||||
type SiteConfig = {
|
||||
capacity: number | null;
|
||||
exitVoucherDefault: boolean;
|
||||
subscriptionMonthlyPriceMinor: number | null;
|
||||
} & Record<TextField, string | null>;
|
||||
|
||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||
const out = {
|
||||
capacity: row?.capacity ?? null,
|
||||
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||
} as SiteConfig;
|
||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Trim a text field; empty string becomes null so blank input clears it. */
|
||||
function normText(v: unknown): string | null {
|
||||
if (v == null) return null;
|
||||
const s = String(v).trim();
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
|
||||
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const readGuard = requirePermission("site:read");
|
||||
const writeGuard = requirePermission("site:update");
|
||||
|
||||
// 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;
|
||||
}
|
||||
if ("subscriptionMonthlyPriceMinor" in body) {
|
||||
const p = body.subscriptionMonthlyPriceMinor;
|
||||
if (p != null && (!Number.isInteger(p) || p < 0)) {
|
||||
return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" });
|
||||
}
|
||||
patch.subscriptionMonthlyPriceMinor = p ?? null;
|
||||
}
|
||||
for (const f of TEXT_FIELDS) {
|
||||
if (f in body) patch[f] = normText(body[f]);
|
||||
}
|
||||
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const updatedAt = new Date().toISOString();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ ...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,87 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||
import { requirePermission } 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 = requirePermission("session:read");
|
||||
|
||||
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
|
||||
// first — lets the UI show "entry/exit image" links beside an event. We also return
|
||||
// FAILED capture attempts (from snapshot telemetry) so the operator can tell a
|
||||
// camera that was offline from a direction that simply has no camera — otherwise a
|
||||
// missing shot is a silent gap. See snapshot.ts (recordFailure).
|
||||
app.get<{ Params: { identity: string } }>(
|
||||
"/api/snapshots/by-identity/:identity",
|
||||
{ preHandler: guard },
|
||||
async (req) => {
|
||||
const identity = req.params.identity;
|
||||
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, identity))
|
||||
.orderBy(desc(snapshots.capturedAt))
|
||||
.all();
|
||||
|
||||
// Failed attempts: kind="snapshot" telemetry whose detail.identity matches and
|
||||
// detail.ok === false. There may be both a failure and (on a retry) a success
|
||||
// for the same direction; we keep only failures with NO successful shot in the
|
||||
// same direction, so a recovered capture doesn't show a stale warning.
|
||||
const haveDir = new Set<string | null>(rows.map((r) => r.direction));
|
||||
const telemetry = db
|
||||
.select({ detail: deviceEvents.detail, deviceId: deviceEvents.deviceId, occurredAt: deviceEvents.occurredAt })
|
||||
.from(deviceEvents)
|
||||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "snapshot")))
|
||||
.orderBy(desc(deviceEvents.occurredAt))
|
||||
.all();
|
||||
const failures: {
|
||||
direction: "entry" | "exit" | null;
|
||||
deviceId: string;
|
||||
error: string;
|
||||
occurredAt: string;
|
||||
}[] = [];
|
||||
const seenFailDir = new Set<string>();
|
||||
for (const row of telemetry) {
|
||||
const d = (row.detail ?? {}) as { identity?: string; ok?: boolean; error?: string; direction?: string };
|
||||
if (d.identity !== identity || d.ok !== false) continue;
|
||||
const dir = d.direction === "entry" || d.direction === "exit" ? d.direction : null;
|
||||
const dirKey = dir ?? "both";
|
||||
if (haveDir.has(dir) || seenFailDir.has(dirKey)) continue; // a success exists, or already shown
|
||||
seenFailDir.add(dirKey);
|
||||
failures.push({
|
||||
direction: dir,
|
||||
deviceId: row.deviceId ?? "",
|
||||
error: d.error ?? "capture failed",
|
||||
occurredAt: row.occurredAt ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
return { snapshots: rows, failures };
|
||||
},
|
||||
);
|
||||
|
||||
// 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,372 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { NoPrinterAvailableError } from "@parking/devices";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { invalidateHolder } from "../event-enrich.js";
|
||||
import { printSubscriptionCard } from "../booth-print.js";
|
||||
import type { CredentialCapture } from "../credential-capture.js";
|
||||
import { directionOf } from "../device-resolve.js";
|
||||
|
||||
// Subscription admin CRUD. A subscription is mutable master data — admins
|
||||
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
|
||||
// trail stays append-only (see wiki/entities/subscription.md). A subscription is an
|
||||
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
|
||||
// them as one unit (create/update replace the child sets; delete removes all).
|
||||
//
|
||||
// Pricing: priceMinor + period ("monthly") + currency record the recurring plan
|
||||
// (e.g. 10,000 ALL / month). Collecting the fee into the ledger/shift is deferred —
|
||||
// here we just store the agreed price and the coverage window.
|
||||
|
||||
interface Credential {
|
||||
kind: "rf" | "qr";
|
||||
/** For RF: the physical card/tag id (required). For QR: optional — left blank, the
|
||||
* server AUTO-GENERATES an unguessable code (the customer never picks it). */
|
||||
value?: string;
|
||||
}
|
||||
interface SubscriptionBody {
|
||||
holderName?: string;
|
||||
contact?: string;
|
||||
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = no price set. */
|
||||
priceMinor?: number | null;
|
||||
period?: "monthly";
|
||||
/** ISO-4217 currency of priceMinor (e.g. "ALL"). */
|
||||
currency?: string | null;
|
||||
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
|
||||
maxConcurrent?: number | null;
|
||||
validFrom?: string | null;
|
||||
validTo?: string | null;
|
||||
/** Months paid for. When set (with validFrom), validTo = validFrom + months — the
|
||||
* multi-month case (e.g. 3 months). Takes precedence over an explicit validTo. */
|
||||
months?: number | null;
|
||||
status?: "active" | "suspended" | "revoked";
|
||||
credentials?: Credential[];
|
||||
/** Plate binding (optional): bound plates that also serve as identity. */
|
||||
plates?: string[];
|
||||
}
|
||||
|
||||
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
|
||||
* delivers the full string over TCP/IP (the host-in-the-loop path), so length is
|
||||
* free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */
|
||||
function newQrCode(): string {
|
||||
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
const bytes = randomBytes(15);
|
||||
let out = "";
|
||||
for (const b of bytes) out += alphabet[b % 32];
|
||||
return `SUB-${out}`;
|
||||
}
|
||||
|
||||
/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo →
|
||||
* Feb 28/29). Returns ISO. */
|
||||
function addMonths(iso: string, months: number): string {
|
||||
const d = new Date(iso);
|
||||
const day = d.getUTCDate();
|
||||
d.setUTCMonth(d.getUTCMonth() + months);
|
||||
// If the month rolled past (e.g. day 31 → next month had fewer days), clamp back.
|
||||
if (d.getUTCDate() < day) d.setUTCDate(0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export async function subscriptionRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
capture: CredentialCapture,
|
||||
): Promise<void> {
|
||||
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
|
||||
const readGuard = requirePermission("subscription:read");
|
||||
const createGuard = requirePermission("subscription:create");
|
||||
const updateGuard = requirePermission("subscription:update");
|
||||
const deleteGuard = requirePermission("subscription:delete");
|
||||
|
||||
// Validate the body; returns problems (empty = ok). Shared by create + update.
|
||||
function validate(b: SubscriptionBody): string[] {
|
||||
const errs: string[] = [];
|
||||
if (b.maxConcurrent != null) {
|
||||
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
|
||||
errs.push("maxConcurrent must be a positive integer, or null for unbound");
|
||||
}
|
||||
}
|
||||
if (b.priceMinor != null) {
|
||||
if (!Number.isInteger(b.priceMinor) || b.priceMinor < 0) {
|
||||
errs.push("priceMinor must be a non-negative integer (minor units), or null");
|
||||
}
|
||||
if (!b.currency?.trim()) {
|
||||
errs.push("currency is required when a price is set");
|
||||
}
|
||||
}
|
||||
if (b.period != null && b.period !== "monthly") {
|
||||
errs.push("period must be 'monthly' (the only period supported today)");
|
||||
}
|
||||
if (b.months != null) {
|
||||
if (!Number.isInteger(b.months) || b.months < 1) {
|
||||
errs.push("months must be a positive integer");
|
||||
}
|
||||
if (!b.validFrom?.trim()) {
|
||||
errs.push("validFrom is required when months is set (validTo = validFrom + months)");
|
||||
}
|
||||
}
|
||||
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||
errs.push("status must be active|suspended|revoked");
|
||||
}
|
||||
for (const c of b.credentials ?? []) {
|
||||
if (c.kind !== "rf" && c.kind !== "qr") {
|
||||
errs.push("each credential needs kind (rf|qr)");
|
||||
break;
|
||||
}
|
||||
// RF must carry the physical card id; QR may be blank (server auto-generates).
|
||||
if (c.kind === "rf" && !c.value?.trim()) {
|
||||
errs.push("an RF credential needs a non-empty value (the card/tag id)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
|
||||
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
function loadAggregate(id: string) {
|
||||
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
|
||||
if (!sub) return null;
|
||||
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
|
||||
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
|
||||
return {
|
||||
...sub,
|
||||
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
|
||||
plates: plates.map((p) => p.plate),
|
||||
};
|
||||
}
|
||||
|
||||
/** Is this credential value already used by ANY subscription? (Global uniqueness —
|
||||
* a value is the lane identity, so it must resolve to one subscription.) */
|
||||
function valueTaken(value: string): boolean {
|
||||
return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null;
|
||||
}
|
||||
|
||||
/** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */
|
||||
function mintQrCode(): string {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const code = newQrCode();
|
||||
if (!valueTaken(code)) return code;
|
||||
}
|
||||
throw new Error("could not mint a unique QR code");
|
||||
}
|
||||
|
||||
// Replace a subscription's child rows (credentials + plates) from the body. QR
|
||||
// credentials with no value are SERVER-GENERATED here (the customer never picks the
|
||||
// code). The generated value is returned via loadAggregate so the UI can print it.
|
||||
function writeChildren(id: string, b: SubscriptionBody) {
|
||||
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
|
||||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
|
||||
for (const c of b.credentials ?? []) {
|
||||
const supplied = c.value?.trim();
|
||||
// QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a
|
||||
// QR being preserved on edit).
|
||||
const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : "";
|
||||
if (!value) continue; // guarded by validate(); defensive
|
||||
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run();
|
||||
}
|
||||
for (const p of b.plates ?? []) {
|
||||
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the coverage end: months (validFrom + months) wins over an explicit validTo. */
|
||||
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
|
||||
if (b.months != null && b.validFrom?.trim()) return addMonths(b.validFrom.trim(), b.months);
|
||||
if (b.validTo !== undefined) return b.validTo ?? null;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// List all subscriptions (with their credentials + plates).
|
||||
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
||||
const rows = db.select().from(subscriptions).all();
|
||||
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
|
||||
});
|
||||
|
||||
// --- Credential capture ("enroll a card") -------------------------------
|
||||
// The operator picks a reader and presents an RFID card to it; the next read on
|
||||
// that reader is captured for the form instead of opening a barrier. The OTHER
|
||||
// reader keeps serving the live flow. Single-shot + TTL. See credential-capture.ts.
|
||||
|
||||
// The readers the operator can capture on (entry/exit by their bound relay).
|
||||
app.get("/api/subscriptions/readers", { preHandler: readGuard }, async () => {
|
||||
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
|
||||
return {
|
||||
readers: rows
|
||||
.filter((r) => r.enabled)
|
||||
.map((r) => ({ id: r.id, driverId: r.driverId, direction: directionOf(db, r) })),
|
||||
};
|
||||
});
|
||||
|
||||
// Arm capture on a reader (by devices.id). Operator-or-admin (booth action).
|
||||
app.post<{ Body: { deviceId?: string } }>(
|
||||
"/api/subscriptions/capture/arm",
|
||||
{ preHandler: readGuard },
|
||||
async (req, reply) => {
|
||||
const deviceId = (req.body?.deviceId ?? "").trim();
|
||||
if (!deviceId) return reply.code(400).send({ error: "deviceId required" });
|
||||
const reader = db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||
if (!reader || reader.category !== "reader" || !reader.enabled) {
|
||||
return reply.code(404).send({ error: "no such enabled reader" });
|
||||
}
|
||||
return capture.arm(deviceId);
|
||||
},
|
||||
);
|
||||
|
||||
// Poll the capture state (idle | armed | captured | expired). The form polls this
|
||||
// and, on "captured", reads `value` into the credential field then clears it.
|
||||
app.get("/api/subscriptions/capture", { preHandler: readGuard }, async () => capture.state());
|
||||
|
||||
// Operator cancelled / closed the form — disarm and clear any result.
|
||||
app.post("/api/subscriptions/capture/cancel", { preHandler: readGuard }, async () => {
|
||||
capture.cancel();
|
||||
capture.clear();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Create a subscription.
|
||||
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||||
const id = randomUUID();
|
||||
db.insert(subscriptions)
|
||||
.values({
|
||||
id,
|
||||
holderName: b.holderName ?? null,
|
||||
contact: b.contact ?? null,
|
||||
priceMinor: b.priceMinor ?? null,
|
||||
period: b.period ?? "monthly",
|
||||
currency: b.priceMinor != null ? (b.currency ?? null) : null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: resolveValidTo(b, null),
|
||||
status: b.status ?? "active",
|
||||
})
|
||||
.run();
|
||||
writeChildren(id, b);
|
||||
const sub = loadAggregate(id);
|
||||
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
|
||||
// a print failure NEVER fails the create (the subscription + its code are saved);
|
||||
// the response carries { printed, printError } so the UI can warn + offer reprint.
|
||||
const printResult = await tryPrintCard(sub);
|
||||
return reply.code(201).send({ ...sub, ...printResult });
|
||||
});
|
||||
|
||||
/** The first QR credential's code for a subscription aggregate, or null. */
|
||||
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
|
||||
const cred = sub?.credentials.find((c) => c.kind === "qr");
|
||||
return cred?.value ?? null;
|
||||
}
|
||||
|
||||
/** Best-effort print of a subscription's QR card. Returns a flag + optional error
|
||||
* (never throws). No QR credential → nothing to print (printed:false, no error). */
|
||||
async function tryPrintCard(
|
||||
sub: ReturnType<typeof loadAggregate>,
|
||||
): Promise<{ printed: boolean; printedBy?: string; printError?: string }> {
|
||||
const code = qrCodeOf(sub);
|
||||
if (!sub || !code) return { printed: false };
|
||||
try {
|
||||
const printedBy = await printSubscriptionCard(
|
||||
db,
|
||||
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
|
||||
app.log,
|
||||
);
|
||||
return { printed: true, printedBy };
|
||||
} catch (err) {
|
||||
const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
||||
app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`);
|
||||
return { printed: false, printError };
|
||||
}
|
||||
}
|
||||
|
||||
// Update a subscription (replaces fields + child sets).
|
||||
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
|
||||
"/api/subscriptions/:id",
|
||||
{ preHandler: updateGuard },
|
||||
async (req, reply) => {
|
||||
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
|
||||
if (!existing) return reply.code(404).send({ error: "subscription not found" });
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||||
db.update(subscriptions)
|
||||
.set({
|
||||
holderName: b.holderName ?? null,
|
||||
contact: b.contact ?? null,
|
||||
priceMinor: b.priceMinor === undefined ? existing.priceMinor : b.priceMinor,
|
||||
period: b.period ?? existing.period,
|
||||
currency:
|
||||
b.priceMinor === undefined
|
||||
? existing.currency
|
||||
: b.priceMinor != null
|
||||
? (b.currency ?? null)
|
||||
: null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: resolveValidTo(b, existing.validTo),
|
||||
status: b.status ?? existing.status,
|
||||
})
|
||||
.where(eq(subscriptions.id, req.params.id))
|
||||
.run();
|
||||
writeChildren(req.params.id, b);
|
||||
// The holder name may have changed — drop the feed-label cache for this sub.
|
||||
invalidateHolder(req.params.id);
|
||||
return loadAggregate(req.params.id);
|
||||
},
|
||||
);
|
||||
|
||||
// Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the
|
||||
// customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if
|
||||
// the subscription is gone; 409 if it has no QR credential; 503 if no printer.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/api/subscriptions/:id/print",
|
||||
{ preHandler: readGuard },
|
||||
async (req, reply) => {
|
||||
const sub = loadAggregate(req.params.id);
|
||||
if (!sub) return reply.code(404).send({ error: "subscription not found" });
|
||||
const code = qrCodeOf(sub);
|
||||
if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" });
|
||||
try {
|
||||
const printedBy = await printSubscriptionCard(
|
||||
db,
|
||||
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
|
||||
app.log,
|
||||
);
|
||||
return reply.code(200).send({ ok: true, printedBy });
|
||||
} catch (err) {
|
||||
if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Revoke (soft): the common case — keeps the subscription + its history, just bars
|
||||
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
|
||||
// DELETE only to fully remove one created in error.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/api/subscriptions/:id/revoke",
|
||||
{ preHandler: updateGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
|
||||
return loadAggregate(req.params.id);
|
||||
},
|
||||
);
|
||||
|
||||
// Hard delete a subscription + its child rows. (Past ledger events that reference it
|
||||
// are untouched — the audit trail is append-only and independent of this row.)
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/subscriptions/:id",
|
||||
{ preHandler: deleteGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
|
||||
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
|
||||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
|
||||
invalidateHolder(req.params.id);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
|
||||
/** Default site timezone for wall-clock tariff windows when none is configured. */
|
||||
const DEFAULT_TZ = "Europe/Tirane";
|
||||
|
||||
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
|
||||
// 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> {
|
||||
// Reading the rate card (pay station / operator UI needs it).
|
||||
const readGuard = requirePermission("tariff:read");
|
||||
// Publishing a new version changes what customers are charged.
|
||||
const writeGuard = requirePermission("tariff:update");
|
||||
|
||||
// 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" });
|
||||
}
|
||||
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
||||
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
||||
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
||||
let toStore: TariffStructure = structure;
|
||||
if (structure && isTariffV2(structure)) {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||
toStore = { ...structure, tz };
|
||||
}
|
||||
|
||||
const problems = validateTariffStructure(toStore);
|
||||
if (problems.length) {
|
||||
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||
}
|
||||
|
||||
// effectiveFrom must NOT be in the past. A version is selected by
|
||||
// "latest effectiveFrom <= entry time", so a backdated effectiveFrom would
|
||||
// retroactively reprice already-entered sessions — exactly the immutability
|
||||
// the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid
|
||||
// backdating: a new version applies only from publish (now) forward; a future
|
||||
// effectiveFrom (scheduling a price change) is allowed. A small skew tolerance
|
||||
// absorbs client/server clock drift + request round-trip. Once a car has
|
||||
// entered, no later publish can reprice it (no effectiveFrom can predate it).
|
||||
const now = Date.now();
|
||||
const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack
|
||||
let effective = new Date().toISOString();
|
||||
if (effectiveFrom != null) {
|
||||
const t = Date.parse(effectiveFrom);
|
||||
if (Number.isNaN(t)) {
|
||||
return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" });
|
||||
}
|
||||
if (t < now - SKEW_MS) {
|
||||
return reply.code(400).send({
|
||||
error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions",
|
||||
});
|
||||
}
|
||||
effective = new Date(t).toISOString();
|
||||
}
|
||||
|
||||
const tariffId = ensureSiteTariff();
|
||||
const id = randomUUID();
|
||||
const row = {
|
||||
id,
|
||||
tariffId,
|
||||
effectiveFrom: effective,
|
||||
currency,
|
||||
structure: toStore 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,245 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import bcrypt from "bcrypt";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID } from "@parking/shared";
|
||||
import { permissionsFor, requirePermission } from "../auth.js";
|
||||
|
||||
// User management (admin). Users are created/edited at runtime here — the
|
||||
// install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one
|
||||
// role (RBAC); the role resolves to a permission set at request time. Passwords
|
||||
// are bcrypt-hashed (cost 12) and never returned. See @parking/shared PERMISSIONS.
|
||||
//
|
||||
// NO-LOCKOUT INVARIANT: the app refuses to delete, or move off the `admin` role,
|
||||
// the LAST user still holding `admin`. Administration can therefore never be
|
||||
// locked out of the appliance. See wiki/entities/local-jwt-auth.md.
|
||||
//
|
||||
// PRIVILEGE-ESCALATION GUARD: a non-admin caller with `user:*` must NOT be able to
|
||||
// (a) ASSIGN a role whose permissions exceed their own (e.g. hand themselves or a
|
||||
// peer the admin role, or any role broader than theirs), nor (b) MODIFY a user who
|
||||
// already holds a role broader than the caller's (resetting an admin's password is
|
||||
// account takeover; deleting an admin is sabotage). Both are blocked below by
|
||||
// comparing permission SETS. An admin holds the full set, so it is unrestricted.
|
||||
|
||||
// Optional profile metadata accepted on create/update. All nullable; "" is treated
|
||||
// as "clear" (→ null). Trimmed before persisting.
|
||||
interface ProfileBody {
|
||||
fullName?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
address?: string | null;
|
||||
}
|
||||
interface CreateBody extends ProfileBody {
|
||||
username: string;
|
||||
password: string;
|
||||
roleId: string;
|
||||
}
|
||||
interface UpdateBody extends ProfileBody {
|
||||
username?: string;
|
||||
roleId?: string;
|
||||
}
|
||||
interface PasswordBody {
|
||||
password: string;
|
||||
}
|
||||
|
||||
const MIN_PASSWORD = 8;
|
||||
const PROFILE_FIELDS = ["fullName", "phone", "email", "address"] as const;
|
||||
|
||||
/** Pull the optional profile fields out of a body → a patch of trimmed values
|
||||
* ("" → null). Absent keys are omitted (so an update only touches what's sent). */
|
||||
function profilePatch(body: ProfileBody): Record<string, string | null> {
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const k of PROFILE_FIELDS) {
|
||||
const v = body[k];
|
||||
if (v === undefined) continue;
|
||||
const trimmed = typeof v === "string" ? v.trim() : "";
|
||||
out[k] = trimmed === "" ? null : trimmed;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const readGuard = requirePermission("user:read");
|
||||
const createGuard = requirePermission("user:create");
|
||||
const updateGuard = requirePermission("user:update");
|
||||
const deleteGuard = requirePermission("user:delete");
|
||||
|
||||
/** Count users currently holding the protected admin role. */
|
||||
function adminCount(): number {
|
||||
return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length;
|
||||
}
|
||||
|
||||
/** True if removing/relocating `userId` from admin would leave zero admins. */
|
||||
function isLastAdmin(userId: string): boolean {
|
||||
const u = db.select().from(users).where(eq(users.id, userId)).get();
|
||||
return u?.roleId === ADMIN_ROLE_ID && adminCount() <= 1;
|
||||
}
|
||||
|
||||
/** A user row safe to return — never the password hash. */
|
||||
function publicUser(u: {
|
||||
id: string;
|
||||
username: string;
|
||||
roleId: string;
|
||||
language: string;
|
||||
createdAt: string;
|
||||
fullName?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
address?: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
roleId: u.roleId,
|
||||
language: u.language,
|
||||
createdAt: u.createdAt,
|
||||
fullName: u.fullName ?? null,
|
||||
phone: u.phone ?? null,
|
||||
email: u.email ?? null,
|
||||
address: u.address ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** True if `targetRoleId` grants any permission the caller's role does NOT hold,
|
||||
* i.e. assigning or touching it would let the caller act beyond their own
|
||||
* privileges. (Admin holds the full set, so it never trips.) */
|
||||
function exceedsCaller(callerRoleId: string, targetRoleId: string): boolean {
|
||||
if (callerRoleId === targetRoleId) return false;
|
||||
const held = permissionsFor(callerRoleId);
|
||||
for (const p of permissionsFor(targetRoleId)) {
|
||||
if (!held.has(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// List all users (no password hashes) + their role names for display.
|
||||
app.get("/api/users", { preHandler: readGuard }, async () => {
|
||||
const rows = db.select().from(users).all();
|
||||
const roleRows = db.select().from(roles).all();
|
||||
const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
|
||||
return {
|
||||
users: rows.map((u) => ({ ...publicUser(u), roleName: roleName.get(u.roleId) ?? u.roleId })),
|
||||
};
|
||||
});
|
||||
|
||||
// Create a user. Username unique; password >= 8 chars; roleId must exist.
|
||||
app.post<{ Body: CreateBody }>("/api/users", { preHandler: createGuard }, async (req, reply) => {
|
||||
const username = (req.body?.username ?? "").trim();
|
||||
const password = req.body?.password ?? "";
|
||||
const roleId = (req.body?.roleId ?? "").trim();
|
||||
if (!username || !roleId) {
|
||||
return reply.code(400).send({ error: "username and roleId required" });
|
||||
}
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
|
||||
}
|
||||
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
|
||||
return reply.code(400).send({ error: "unknown roleId" });
|
||||
}
|
||||
// No-escalation: can't create a user with a role broader than your own.
|
||||
if (exceedsCaller(req.user.roleId, roleId)) {
|
||||
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
|
||||
}
|
||||
if (db.select().from(users).where(eq(users.username, username)).get()) {
|
||||
return reply.code(409).send({ error: "username already exists" });
|
||||
}
|
||||
const id = randomUUID();
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
db.insert(users).values({ id, username, passwordHash, roleId, ...profilePatch(req.body) }).run();
|
||||
const created = db.select().from(users).where(eq(users.id, id)).get()!;
|
||||
return reply.code(201).send(publicUser(created));
|
||||
});
|
||||
|
||||
// Update a user's username and/or role. Guarded against orphaning admin.
|
||||
app.put<{ Params: { id: string }; Body: UpdateBody }>(
|
||||
"/api/users/:id",
|
||||
{ preHandler: updateGuard },
|
||||
async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const existing = db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!existing) return reply.code(404).send({ error: "user not found" });
|
||||
// No-escalation: can't modify a user who already outranks you.
|
||||
if (exceedsCaller(req.user.roleId, existing.roleId)) {
|
||||
return reply.code(403).send({ error: "cannot modify a user whose role exceeds your own" });
|
||||
}
|
||||
|
||||
const next: { username?: string; roleId?: string } & Record<string, string | null> = {
|
||||
...profilePatch(req.body ?? {}),
|
||||
};
|
||||
if (req.body?.username != null) {
|
||||
const username = req.body.username.trim();
|
||||
if (!username) return reply.code(400).send({ error: "username cannot be empty" });
|
||||
const clash = db.select().from(users).where(eq(users.username, username)).get();
|
||||
if (clash && clash.id !== id) return reply.code(409).send({ error: "username already exists" });
|
||||
next.username = username;
|
||||
}
|
||||
if (req.body?.roleId != null) {
|
||||
const roleId = req.body.roleId.trim();
|
||||
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
|
||||
return reply.code(400).send({ error: "unknown roleId" });
|
||||
}
|
||||
// No-escalation: can't promote a user into a role broader than your own.
|
||||
if (exceedsCaller(req.user.roleId, roleId)) {
|
||||
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
|
||||
}
|
||||
// No-lockout: don't move the last admin off the admin role.
|
||||
if (roleId !== ADMIN_ROLE_ID && isLastAdmin(id)) {
|
||||
return reply.code(409).send({ error: "cannot change the role of the last admin" });
|
||||
}
|
||||
next.roleId = roleId;
|
||||
}
|
||||
if (Object.keys(next).length === 0) {
|
||||
return reply.code(400).send({ error: "nothing to update" });
|
||||
}
|
||||
db.update(users).set(next).where(eq(users.id, id)).run();
|
||||
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
|
||||
},
|
||||
);
|
||||
|
||||
// Reset a user's password (admin sets a new one; >= 8 chars).
|
||||
app.put<{ Params: { id: string }; Body: PasswordBody }>(
|
||||
"/api/users/:id/password",
|
||||
{ preHandler: updateGuard },
|
||||
async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const target = db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!target) {
|
||||
return reply.code(404).send({ error: "user not found" });
|
||||
}
|
||||
// No-escalation: can't reset the password of a user who outranks you
|
||||
// (that would be account takeover of a more-privileged account).
|
||||
if (exceedsCaller(req.user.roleId, target.roleId)) {
|
||||
return reply.code(403).send({ error: "cannot reset the password of a user whose role exceeds your own" });
|
||||
}
|
||||
const password = req.body?.password ?? "";
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
db.update(users).set({ passwordHash }).where(eq(users.id, id)).run();
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a user. Refused if it's the last admin (no-lockout).
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/users/:id",
|
||||
{ preHandler: deleteGuard },
|
||||
async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const target = db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!target) {
|
||||
return reply.code(404).send({ error: "user not found" });
|
||||
}
|
||||
// No-escalation: can't delete a user who outranks you.
|
||||
if (exceedsCaller(req.user.roleId, target.roleId)) {
|
||||
return reply.code(403).send({ error: "cannot delete a user whose role exceeds your own" });
|
||||
}
|
||||
if (isLastAdmin(id)) {
|
||||
return reply.code(409).send({ error: "cannot delete the last admin" });
|
||||
}
|
||||
db.delete(users).where(eq(users.id, id)).run();
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
import { roleHasPermissions } from "../auth.js";
|
||||
import { deviceEvents } from "../device-events.js";
|
||||
import { enrichEvent } from "../event-enrich.js";
|
||||
import type { DeviceMonitor } from "../device-monitor.js";
|
||||
import { getOccupancy } from "../occupancy.js";
|
||||
|
||||
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||
// server-pushed updates instead of polling: each signed ledger append (entry,
|
||||
// exit, payment, void) is fanned out, and the recomputed occupancy rides along
|
||||
// so the screen's count stays exact (occupancy is a fold over the same ledger,
|
||||
// never a counter). Printer-status changes are forwarded too.
|
||||
//
|
||||
// Auth: the handshake is a normal GET through Fastify's lifecycle, so the same
|
||||
// HttpOnly JWT cookie that guards the REST API guards this. We verify the JWT and
|
||||
// role here. A browser's WebSocket constructor cannot set custom headers, so the
|
||||
// CSRF double-submit header the REST mutations use is unavailable — which would
|
||||
// leave the socket open to Cross-Site WebSocket Hijacking: a malicious page in the
|
||||
// operator's browser could open ws://<booth>/api/ws, the browser would auto-attach
|
||||
// the HttpOnly cookie, and the attacker would receive the live entry/exit/payment
|
||||
// stream. The cookie alone is NOT a control here. So we replace the CSRF check with
|
||||
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
|
||||
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
|
||||
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
|
||||
|
||||
/** Permission required to watch the live feed (a read-only stream of ledger +
|
||||
* device status). Any role granted `report:read` may watch. */
|
||||
const WATCH_PERMISSION = "report:read" as const;
|
||||
|
||||
/**
|
||||
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
|
||||
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
|
||||
* (comma-separated) for a booth UI served from a different origin. A missing or
|
||||
* mismatched Origin is rejected — that is the anti-CSWSH control.
|
||||
*/
|
||||
function isAllowedOrigin(origin: string | undefined, host: string | undefined): boolean {
|
||||
if (!origin) return false; // no Origin → not a same-origin browser request
|
||||
let originHost: string;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
return false; // malformed Origin
|
||||
}
|
||||
if (host && originHost === host) return true; // same-origin (any scheme/port match via host)
|
||||
const allow = (process.env.WS_ALLOWED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return allow.includes(origin);
|
||||
}
|
||||
|
||||
type OutMsg =
|
||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown }
|
||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||
| { kind: "printer-status"; event: unknown }
|
||||
| { kind: "device-status"; event: unknown };
|
||||
|
||||
export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> {
|
||||
app.get(
|
||||
"/api/ws",
|
||||
{
|
||||
websocket: true,
|
||||
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
|
||||
// role. Reject a cross/absent origin before touching the token, so a hijack
|
||||
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
|
||||
preHandler: async (req) => {
|
||||
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
||||
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
||||
}
|
||||
await req.jwtVerify();
|
||||
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
},
|
||||
},
|
||||
(socket) => {
|
||||
const send = (msg: OutMsg) => {
|
||||
// readyState 1 = OPEN; never throw out of an event-bus callback.
|
||||
if (socket.readyState === 1) {
|
||||
try {
|
||||
socket.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
/* drop on a broken socket */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial snapshot so the client renders immediately, before any event:
|
||||
// occupancy AND the current device-status set (for the footer).
|
||||
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() });
|
||||
|
||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||
const offLedger = deviceEvents.onLedger((event) => {
|
||||
// Enrich with read-time display fields (subscriber name) before fan-out.
|
||||
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
|
||||
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) });
|
||||
});
|
||||
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
||||
send({ kind: "printer-status", event });
|
||||
});
|
||||
// Unified device status (all categories) for the booth footer — pushed on
|
||||
// change; the initial set rode the hello above.
|
||||
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
||||
send({ kind: "device-status", event });
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
offLedger();
|
||||
offPrinter();
|
||||
offDevice();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
+173
-44
@@ -1,18 +1,40 @@
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import websocket from "@fastify/websocket";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { createDb, type Db } from "@parking/db";
|
||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import { EntryFlow } from "./entry-flow.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 { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import { ShiftService } from "./shift-service.js";
|
||||
import { ReadDispatcher } from "./read-dispatch.js";
|
||||
import { CredentialCapture } from "./credential-capture.js";
|
||||
import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { buildSigner } from "./signer.js";
|
||||
import { DeviceMonitor } from "./device-monitor.js";
|
||||
import { buildSigner, buildVerifier } from "./signer.js";
|
||||
import { LogService, pinoDbStream } from "./log-service.js";
|
||||
import { logRoutes } from "./routes/logs.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { userRoutes } from "./routes/users.js";
|
||||
import { roleRoutes } from "./routes/roles.js";
|
||||
import { deviceRoutes } from "./routes/devices.js";
|
||||
import { eventRoutes } from "./routes/events.js";
|
||||
import { payRoutes } from "./routes/pay.js";
|
||||
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||
import { shiftRoutes } from "./routes/shift.js";
|
||||
import { siteRoutes } from "./routes/site.js";
|
||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
import { printerRoutes } from "./routes/printers.js";
|
||||
import { setupRoutes } from "./routes/setup.js";
|
||||
import { deviceStatusRoutes } from "./routes/device-status.js";
|
||||
import { wsRoutes } from "./routes/ws.js";
|
||||
|
||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
||||
// plugins emitting onto a shared internal event bus; auth is fully local
|
||||
@@ -23,14 +45,30 @@ export interface BuildOptions {
|
||||
}
|
||||
|
||||
export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> {
|
||||
const app = Fastify({
|
||||
logger: { level: process.env.LOG_LEVEL ?? "info" },
|
||||
});
|
||||
|
||||
// DB first — the logger's DB sink needs it before Fastify is constructed.
|
||||
const db = opts.db ?? createDb();
|
||||
|
||||
// Application-log store: a pino stream tees warn+ lines into app_logs (and still
|
||||
// writes them to stdout), so backend warnings/errors are queryable from the booth
|
||||
// alongside frontend errors. See log-service.ts + wiki/concepts/app-logs.md.
|
||||
const logService = new LogService(db);
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
stream: pinoDbStream(logService, process.stdout),
|
||||
},
|
||||
});
|
||||
|
||||
// Wire the RBAC permission resolver to this DB (route guards resolve a user's
|
||||
// role → permission set through it). See auth.ts.
|
||||
initAuth(db);
|
||||
|
||||
await app.register(cookie);
|
||||
|
||||
// WebSocket support for the live booth feed (/api/ws). Registered before the
|
||||
// routes so the `{ websocket: true }` route option is available.
|
||||
await app.register(websocket);
|
||||
|
||||
// Local JWT signing with a local secret — no external identity provider.
|
||||
// Fail fast rather than fall back to a known default: a booth machine started
|
||||
// without a real secret would sign tokens anyone could forge (incl. an admin
|
||||
@@ -38,7 +76,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
||||
await app.register(jwt, {
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -47,15 +86,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||
await authRoutes(app, db);
|
||||
|
||||
// device id -> lane resolver. Built from lane_devices at startup and refreshed
|
||||
// by setupRoutes on assign/unassign, so device events can be stamped with the
|
||||
// lane the device belongs to (events carry the device id, not a lane).
|
||||
const laneMap = new LaneMap(db);
|
||||
laneMap.refresh();
|
||||
// RBAC administration: compose roles (role:*) + manage users (user:*). The
|
||||
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
|
||||
await userRoutes(app, db);
|
||||
await roleRoutes(app, db);
|
||||
|
||||
// Device-agnostic setup: the admin selects devices per lane from the driver
|
||||
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
||||
await setupRoutes(app, db, () => laneMap.refresh());
|
||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||
await setupRoutes(app, db);
|
||||
|
||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||
@@ -70,39 +110,128 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
app.addHook("onReady", async () => printerMonitor.start());
|
||||
app.addHook("onClose", async () => printerMonitor.stop());
|
||||
|
||||
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button
|
||||
// presses) into the hash-chained, signed `events` table — the anti-fraud audit
|
||||
// trail. The device is NOT trusted; the host record is the source of truth, and
|
||||
// a relay open with no matching signed event is itself the anomaly. We record
|
||||
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that
|
||||
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md.
|
||||
const eventLog = new EventLog(db, buildSigner(app.log));
|
||||
// Unified device-status monitor: polls EVERY configured device (relays/readers/
|
||||
// cameras via healthCheck, printers via rich readStatus) and feeds the booth's
|
||||
// device-status footer over the WS. Read-only — never drives a relay.
|
||||
// See wiki/concepts/device-status-monitoring.md.
|
||||
const deviceMonitor = new DeviceMonitor(db, app.log);
|
||||
await deviceStatusRoutes(app, deviceMonitor);
|
||||
app.addHook("onReady", async () => deviceMonitor.start());
|
||||
app.addHook("onClose", async () => deviceMonitor.stop());
|
||||
|
||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||
// See wiki/decisions/event-streams-split.md.
|
||||
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||
deviceEvents.emitLedger(row),
|
||||
);
|
||||
await eventRoutes(app, db, eventLog);
|
||||
|
||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||
await wsRoutes(app, db, deviceMonitor);
|
||||
|
||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||
await snapshotRoutes(app, db);
|
||||
|
||||
// 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 SUBSCRIPTION flow (if it matches a subscription) or the
|
||||
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
||||
// parking-session.md.
|
||||
const exitFlow = new ExitFlow(db, eventLog, app.log);
|
||||
const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log);
|
||||
const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
|
||||
const unsubscribeRead = deviceEvents.onRead((e) => {
|
||||
void readDispatcher.dispatch(e);
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeRead());
|
||||
|
||||
// Credential capture ("enroll a card"): lets the operator present an RFID card to a
|
||||
// CHOSEN reader to populate a subscription credential, without blocking the other
|
||||
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
||||
const credentialCapture = new CredentialCapture();
|
||||
|
||||
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
||||
// on an armed reader for enrollment; otherwise the read routes through the
|
||||
// dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
|
||||
|
||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||
// (sum payments by tender, print the Z-report). Constructed before the pay routes
|
||||
// because the booth money path is GATED on an open shift. See wiki/concepts/shift.md.
|
||||
const shiftService = new ShiftService(db, eventLog, app.log);
|
||||
|
||||
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
|
||||
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
||||
const payStation = new PayStation(db, eventLog, app.log);
|
||||
await payRoutes(app, db, payStation, exitFlow, shiftService);
|
||||
|
||||
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||
await tariffRoutes(app, db);
|
||||
|
||||
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
||||
// wiki/entities/subscription.md.
|
||||
await subscriptionRoutes(app, db, credentialCapture);
|
||||
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
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);
|
||||
|
||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||
await logRoutes(app, logService);
|
||||
|
||||
// Periodic retention prune (age + row cap) so the log table stays bounded on the
|
||||
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
||||
const pruneTimer = setInterval(() => {
|
||||
const n = logService.prune();
|
||||
if (n > 0) app.log.debug(`pruned ${n} app_log rows`);
|
||||
}, 60 * 60 * 1000);
|
||||
pruneTimer.unref();
|
||||
logService.prune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
||||
|
||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
||||
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
||||
// faithfully (the chain is append-only) rather than silently dropped or
|
||||
// mis-stamped as lane 0, which is a real lane.
|
||||
const lane = laneMap.laneFor(e.deviceId) ?? -1;
|
||||
if (lane === -1) {
|
||||
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
|
||||
}
|
||||
eventLog
|
||||
.append({
|
||||
type: "input_received",
|
||||
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}`,
|
||||
// Record every input edge as unsigned telemetry, keyed to the device that fired
|
||||
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
|
||||
// (above) independently decides whether this edge is an entry button.
|
||||
try {
|
||||
db.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId: e.deviceId,
|
||||
category: "access",
|
||||
kind: "input",
|
||||
detail: { driverId: e.driverId, input: e.input, edge: e.edge },
|
||||
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());
|
||||
|
||||
// TODO: entry flow (input event → signed event → print → relay).
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
||||
import { registry, formatStampSq as zStamp, 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 {
|
||||
/** The operator who currently holds the open shift (may be someone else). */
|
||||
readonly heldBy: string;
|
||||
constructor(operator: string, heldBy: string) {
|
||||
super(
|
||||
heldBy === operator
|
||||
? `operator ${operator} already has an open shift`
|
||||
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
|
||||
);
|
||||
this.name = "ShiftAlreadyOpenError";
|
||||
this.heldBy = heldBy;
|
||||
}
|
||||
}
|
||||
export class NoOpenShiftError extends Error {
|
||||
constructor(operator: string) {
|
||||
super(`operator ${operator} has no open shift`);
|
||||
this.name = "NoOpenShiftError";
|
||||
}
|
||||
}
|
||||
/** Thrown by the booth money path when NO shift is open site-wide — an operator
|
||||
* must open a shift before any payment/exit can be attributed to a shift. */
|
||||
export class NoShiftOpenError extends Error {
|
||||
constructor() {
|
||||
super("no shift is open — open a shift before processing tickets");
|
||||
this.name = "NoShiftOpenError";
|
||||
}
|
||||
}
|
||||
|
||||
/** A COMPLETED shift, reconstructed from its signed `shift_z_report` (which carries
|
||||
* all the figures in its payload). This is the unit of the shift-history feature.
|
||||
* `id` is the z_report's ledger id (stable, for the UI list key / future deep-link). */
|
||||
export interface ShiftSummary {
|
||||
readonly id: string;
|
||||
readonly index: number;
|
||||
readonly operator: string;
|
||||
readonly startedAt: string;
|
||||
readonly endedAt: string;
|
||||
readonly cashTotalMinor: number;
|
||||
readonly cardTotalMinor: number;
|
||||
readonly currency: string | null;
|
||||
readonly paymentCount: number;
|
||||
readonly openingFloatMinor: number;
|
||||
readonly cashAddedMinor: number;
|
||||
readonly cashRemovedMinor: number;
|
||||
readonly expectedDrawerMinor: number;
|
||||
}
|
||||
|
||||
export interface ShiftReport {
|
||||
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 SINGLE site-wide open shift, or null. A shift is a site-wide accountability
|
||||
* period: at most ONE may be open at a time (so booth takings are unambiguously
|
||||
* attributed to one operator). It's open iff the most recent shift event on the
|
||||
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
|
||||
* appended yet). Returns that row so callers can read its operator/startedAt.
|
||||
*/
|
||||
currentOpenShift() {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all()
|
||||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||||
const last = rows[rows.length - 1];
|
||||
return last && last.type === "shift_open" ? last : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPLETED shift history, newest first. Each closed shift is one signed
|
||||
* `shift_z_report` whose payload already holds every figure, so this is a simple
|
||||
* read of those rows (no re-summing). Optional filters:
|
||||
* - operator: only this operator's shifts (the `identity` on the z_report).
|
||||
* - from/to: ISO timestamps; keep shifts whose START falls in [from, to].
|
||||
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
||||
* completed accountability period. Use `currentOpenShift()` for the live one.
|
||||
*/
|
||||
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "shift_z_report"))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
|
||||
const out: ShiftSummary[] = [];
|
||||
for (const r of rows) {
|
||||
const pl = (r.payload ?? {}) as LedgerPayload & {
|
||||
operator?: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
cashTotalMinor?: number;
|
||||
cardTotalMinor?: number;
|
||||
paymentCount?: number;
|
||||
openingFloatMinor?: number;
|
||||
cashAddedMinor?: number;
|
||||
cashRemovedMinor?: number;
|
||||
expectedDrawerMinor?: number;
|
||||
};
|
||||
const operator = pl.operator ?? r.identity ?? "?";
|
||||
const startedAt = pl.startedAt ?? r.occurredAt;
|
||||
if (opts.operator && operator !== opts.operator) continue;
|
||||
if (opts.from && startedAt < opts.from) continue;
|
||||
if (opts.to && startedAt > opts.to) continue;
|
||||
out.push({
|
||||
id: r.id,
|
||||
index: r.index,
|
||||
operator,
|
||||
startedAt,
|
||||
endedAt: pl.endedAt ?? r.occurredAt,
|
||||
cashTotalMinor: pl.cashTotalMinor ?? 0,
|
||||
cardTotalMinor: pl.cardTotalMinor ?? 0,
|
||||
currency: pl.currency ?? null,
|
||||
paymentCount: pl.paymentCount ?? 0,
|
||||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||
expectedDrawerMinor: pl.expectedDrawerMinor ?? 0,
|
||||
});
|
||||
}
|
||||
// Newest first for the history list.
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
/** Require an open shift for the booth money path; returns it or throws. */
|
||||
requireOpenShift() {
|
||||
const open = this.currentOpenShift();
|
||||
if (!open) throw new NoShiftOpenError();
|
||||
return open;
|
||||
}
|
||||
|
||||
/**
|
||||
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
|
||||
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
|
||||
* payments add to the drawer; card payments never touch it; cash_movement amounts
|
||||
* (signed: + load, − removal) adjust it. This is what carries across shifts.
|
||||
*/
|
||||
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all()
|
||||
.filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement"));
|
||||
let balanceMinor = 0;
|
||||
let currency: string | null = null;
|
||||
for (const r of rows) {
|
||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||
if (r.type === "payment") {
|
||||
// Only CASH enters the till; card settles to the bank.
|
||||
if (pl.tender !== "card") balanceMinor += amt;
|
||||
} else {
|
||||
// cash_movement amount is signed (+ load, − removal).
|
||||
balanceMinor += amt;
|
||||
}
|
||||
if (pl.currency) currency = pl.currency;
|
||||
}
|
||||
return { balanceMinor, currency };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an admin cash movement (load/remove drawer float). `amountMinor` is
|
||||
* signed: positive = cash loaded IN, negative = cash taken OUT. Signed +
|
||||
* attributed. Admin-only is enforced at the route. Returns the new drawer balance.
|
||||
*/
|
||||
async recordCashMovement(
|
||||
operator: string,
|
||||
amountMinor: number,
|
||||
reason: string,
|
||||
currency?: string,
|
||||
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||||
if (!Number.isInteger(amountMinor) || amountMinor === 0) {
|
||||
throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)");
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await this.#log.append({
|
||||
type: "cash_movement",
|
||||
source: "manual",
|
||||
identity: operator, // who moved the cash (admin)
|
||||
payload: {
|
||||
amountMinor,
|
||||
...(reason ? { reason } : {}),
|
||||
...(currency ? { currency } : {}),
|
||||
operator,
|
||||
},
|
||||
occurredAt: now,
|
||||
});
|
||||
const { balanceMinor } = this.#drawerBalanceAt(now);
|
||||
this.#logger.info(
|
||||
`cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
);
|
||||
return { amountMinor, balanceMinor };
|
||||
}
|
||||
|
||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||
* inherited from the chain = the drawer balance at the start instant. */
|
||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
|
||||
// operator's own (double-open) or another operator's (handover not done). Only
|
||||
// one accountability period at a time.
|
||||
const current = this.currentOpenShift();
|
||||
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
|
||||
const startedAt = new Date().toISOString();
|
||||
const { 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);
|
||||
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
||||
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
|
||||
const lines = [
|
||||
`Operatori: ${r.operator}`,
|
||||
`Nga: ${zStamp(r.startedAt)}`,
|
||||
`Deri: ${zStamp(r.endedAt)}`,
|
||||
"",
|
||||
`Pagesa: ${r.paymentCount}`,
|
||||
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
||||
"",
|
||||
"-- Arka --",
|
||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title: "RAPORT TURNI", 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 #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.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.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, subscription 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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
eq,
|
||||
ledgerEvents,
|
||||
sessions,
|
||||
subscriptionCredentials,
|
||||
subscriptionPlates,
|
||||
subscriptions,
|
||||
type Db,
|
||||
type DeviceRow,
|
||||
} from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import { reasonPayload, type ReasonCode } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
|
||||
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
|
||||
// paying per stay (they're on a recurring plan). Reached from the read dispatcher
|
||||
// when a read matches a subscription (not an open ticket). See
|
||||
// wiki/entities/subscription.md.
|
||||
//
|
||||
// Two optional, independent bindings:
|
||||
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
|
||||
// subscription's cars may be inside at once; enforced over the session projection.
|
||||
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
|
||||
// too (card/QR OR plate). When unset, any car may use the subscription's card/QR.
|
||||
//
|
||||
// Direction is inferred from the SUBSCRIPTION's open-session state, NOT the specific
|
||||
// credential read — so ANY of a subscription's credentials (QR / RFID / NFC / plate)
|
||||
// may open or close a session. Entry mints a fresh per-occurrence session id (the
|
||||
// ledger `identity`); a read with no open occurrence → ENTRY; with ≥1 open → EXIT the
|
||||
// OLDEST open occurrence (FIFO). A fleet (maxConcurrent > 1) thus has several open
|
||||
// occurrences at once; each read closes one. This decouples exit from the entry
|
||||
// credential (you can enter with QR and leave with the card).
|
||||
//
|
||||
// NB: the SIGNED ledger payload still carries `permitId` (immutable history — see the
|
||||
// schema note); the per-occurrence `identity` is the session key. The mutable master
|
||||
// data / code is "subscription"; the on-chain field name is left as-is so historical
|
||||
// events keep verifying.
|
||||
|
||||
export interface SubscriptionMatch {
|
||||
readonly subscriptionId: string;
|
||||
/** The specific credential/plate value read (for logging/anomalies). NOT the
|
||||
* session key — sessions are keyed by subscription occurrence, so a different
|
||||
* credential of the same subscription can close the session it opened. */
|
||||
readonly carKey: string;
|
||||
readonly via: "card" | "qr" | "plate";
|
||||
}
|
||||
|
||||
export class SubscriptionFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #inFlight = new Set<string>();
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Resolve a read to a subscription (by card/QR credential, or a bound plate), or null. */
|
||||
match(e: DeviceReadEvent): SubscriptionMatch | null {
|
||||
// Card / QR / generic credential value.
|
||||
const cred = this.#db
|
||||
.select()
|
||||
.from(subscriptionCredentials)
|
||||
.where(eq(subscriptionCredentials.value, e.value))
|
||||
.get();
|
||||
if (cred) {
|
||||
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
||||
}
|
||||
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
|
||||
if (e.kind === "plate") {
|
||||
const plate = this.#db.select().from(subscriptionPlates).where(eq(subscriptionPlates.plate, e.value)).get();
|
||||
if (plate) return { subscriptionId: plate.subscriptionId, carKey: e.value, via: "plate" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Run the subscription entry/exit for a matched read at a barrier. `resolved` is the
|
||||
* reader's bound relay; its direction constrains, "both" defers to session state. */
|
||||
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
|
||||
const key = `${m.subscriptionId}:${m.carKey}`;
|
||||
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
return await this.#run(resolved, e, m);
|
||||
} catch (err) {
|
||||
this.#logger.error(`subscription-flow failed: ${(err as Error).message}`);
|
||||
return { accepted: false, reason: (err as Error).message };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
|
||||
// The physical side the reader sits at — used to fire the right camera on a refusal
|
||||
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
|
||||
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
|
||||
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
|
||||
if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
|
||||
|
||||
// Validity: active + within the coverage window.
|
||||
const now = new Date().toISOString();
|
||||
const invalid =
|
||||
sub.status !== "active" ||
|
||||
(sub.validFrom != null && now < sub.validFrom) ||
|
||||
(sub.validTo != null && now > sub.validTo);
|
||||
if (invalid) {
|
||||
const reason = await this.#reject(m, lane, "sub.refused.outOfWindow", { status: sub.status });
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
|
||||
// Direction: the BARRIER the reader sits at decides the verb — an entry-lane read
|
||||
// is an ENTRY, an exit-lane read is an EXIT. (The credential is decoupled from the
|
||||
// session, so we can't and needn't infer from "which credential".) A "both" barrier
|
||||
// has no physical side, so there we infer from state: open occurrence → exit, else
|
||||
// entry. This is what lets a FLEET admit several cars (each entry-lane read is an
|
||||
// entry) yet exit any of them with ANY credential (FIFO).
|
||||
const open = this.#openOccurrences(m.subscriptionId);
|
||||
const verb: FlowDirection =
|
||||
resolved.direction === "entry"
|
||||
? "entry"
|
||||
: resolved.direction === "exit"
|
||||
? "exit"
|
||||
: open.length > 0
|
||||
? "exit"
|
||||
: "entry";
|
||||
|
||||
const source = m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand";
|
||||
|
||||
if (verb === "exit") {
|
||||
// EXIT: close the OLDEST open occurrence (FIFO). Its occurrence id is the session
|
||||
// key; the credential just read may differ from the one that opened it. If the
|
||||
// subscription has NOTHING open, an exit read is a no-op anti-passback signal.
|
||||
const oldest = open[0];
|
||||
if (!oldest) {
|
||||
const reason = await this.#reject(m, "exit", "sub.refused.noSession");
|
||||
return { accepted: false, direction: "exit", reason };
|
||||
}
|
||||
const occurrenceId = oldest.identity;
|
||||
await this.#log.append({
|
||||
type: "vehicle_exit",
|
||||
direction: "exit",
|
||||
source,
|
||||
identity: occurrenceId,
|
||||
// `permitId` carries the subscription id; `via` records which credential left.
|
||||
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, via: m.via },
|
||||
});
|
||||
await this.#open(resolved, "exit", occurrenceId, "subscription exit");
|
||||
this.#closeCache(occurrenceId);
|
||||
return { accepted: true, direction: "exit" };
|
||||
}
|
||||
|
||||
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a
|
||||
// fresh per-occurrence id so a fleet can have several open at once.
|
||||
if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) {
|
||||
const reason = await this.#reject(m, "entry", "sub.refused.atCapacity", {
|
||||
inUse: open.length,
|
||||
max: sub.maxConcurrent,
|
||||
});
|
||||
return { accepted: false, direction: "entry", reason };
|
||||
}
|
||||
|
||||
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
|
||||
// the payload's `permitId` (which every fold matches on), so the key stays compact.
|
||||
const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
direction: "entry",
|
||||
source,
|
||||
identity: occurrenceId,
|
||||
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
|
||||
// `permitId`/`permit` are the on-chain field names (immutable).
|
||||
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via },
|
||||
occurredAt: now,
|
||||
});
|
||||
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({
|
||||
id: occurrenceId,
|
||||
identity: occurrenceId,
|
||||
source: m.via === "plate" ? "lpr" : "wiegand",
|
||||
subscriptionId: m.subscriptionId,
|
||||
enteredAt: now,
|
||||
state: "open",
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
|
||||
}
|
||||
return { accepted: true, direction: "entry" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
|
||||
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose
|
||||
* `payload.permitId` is this subscription) with no later `vehicle_exit` on the same
|
||||
* `identity`. Used to (a) infer entry vs. exit for ANY credential of the
|
||||
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
||||
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
||||
*/
|
||||
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
||||
const net = new Map<string, number>();
|
||||
const firstIndex = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
const id = r.identity;
|
||||
if (!id) continue;
|
||||
const pl = (r.payload ?? {}) as { permitId?: string };
|
||||
if (r.type === "vehicle_entry") {
|
||||
if (pl.permitId !== subscriptionId) continue;
|
||||
net.set(id, (net.get(id) ?? 0) + 1);
|
||||
if (!firstIndex.has(id)) firstIndex.set(id, r.index);
|
||||
} else if (r.type === "vehicle_exit") {
|
||||
if (!net.has(id)) continue; // not one of this subscription's occurrences
|
||||
net.set(id, (net.get(id) ?? 0) - 1);
|
||||
}
|
||||
}
|
||||
const open: { identity: string; index: number }[] = [];
|
||||
for (const [id, n] of net) if (n > 0) open.push({ identity: id, index: firstIndex.get(id) ?? 0 });
|
||||
open.sort((a, b) => a.index - b.index); // oldest first → FIFO
|
||||
return open;
|
||||
}
|
||||
|
||||
/** Sign a refused-subscription anomaly with a localizable reason code, fire the
|
||||
* directional evidence camera, and return the rendered English reason for the
|
||||
* caller's ReadOutcome. `dir` is the lane the refusal happened at (entry/exit) so
|
||||
* the right camera captures the turned-away subscriber. `via` records which
|
||||
* credential was presented. */
|
||||
async #reject(
|
||||
m: SubscriptionMatch,
|
||||
dir: FlowDirection,
|
||||
code: ReasonCode,
|
||||
params?: Record<string, string | number>,
|
||||
): Promise<string> {
|
||||
const rp = reasonPayload(code, params);
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: m.carKey,
|
||||
// `permitId`/`permitRefused` are the on-chain field names (immutable).
|
||||
payload: { ...rp, permitId: m.subscriptionId, permitRefused: true, via: m.via },
|
||||
});
|
||||
this.#fireSnapshot(dir, m.carKey);
|
||||
this.#logger.warn(`subscription refused (${m.carKey}): ${rp.reason}`);
|
||||
return rp.reason;
|
||||
}
|
||||
|
||||
/** Fire the directional camera(s) for a refused-subscription event; never awaited
|
||||
* (evidence, not a gate). The accepted entry/exit paths snapshot inside #open. */
|
||||
#fireSnapshot(dir: FlowDirection, identity: string): void {
|
||||
void snapshotAsync({ db: this.#db, direction: dir, identity, logger: this.#logger }).catch((err) =>
|
||||
this.#logger.error(`subscription snapshot error: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
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).
|
||||
this.#fireSnapshot(dir, carKey);
|
||||
}
|
||||
|
||||
#closeCache(carKey: string): void {
|
||||
try {
|
||||
this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a live access adapter from a resolved controller row, or null. */
|
||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(row.config as never) as AccessControlDevice;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -12,13 +12,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@parking/shared": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"i18next": "^26.3.1",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7"
|
||||
"react-dom": "19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.0.16"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
|
||||
// Active Sessions panel. A session is "active" while still inside OR exited-but-
|
||||
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
||||
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
||||
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
||||
// - click a row → the pay/exit modal (pay an unpaid car, or review),
|
||||
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
||||
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
||||
// See wiki/concepts/booth-exit-flow.md.
|
||||
|
||||
function statusBadge(s: ActiveSession): { key: string; cls: string } {
|
||||
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
|
||||
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
||||
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
||||
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
||||
}
|
||||
|
||||
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// The audited barrier re-open is a money-path action (server-gated on an open
|
||||
// shift); disable it unless this operator's shift is open.
|
||||
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
|
||||
const shiftReady = shiftOpen && shiftMine;
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: qk.activeSessions,
|
||||
queryFn: fetchActiveSessions,
|
||||
// Belt-and-braces refresh in case a grace window expires with no ledger event
|
||||
// to invalidate the cache (the WS only pushes on appends).
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const reopen = useMutation({
|
||||
mutationFn: (identity: string) => reopenBarrier(identity),
|
||||
onSettled: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
},
|
||||
});
|
||||
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
||||
|
||||
const sessions = data?.sessions ?? [];
|
||||
|
||||
async function handleReopen(s: ActiveSession) {
|
||||
setReopenMsg(null);
|
||||
try {
|
||||
const r = await reopen.mutateAsync(s.identity);
|
||||
setReopenMsg({
|
||||
id: s.identity,
|
||||
ok: r.opened,
|
||||
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
|
||||
});
|
||||
} catch (e) {
|
||||
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={t("booth.activeSessions")}
|
||||
right={
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||
{sessions.length} {t("booth.insideCount")}
|
||||
</span>
|
||||
}
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
|
||||
) : (
|
||||
sessions.map((s) => {
|
||||
const badge = statusBadge(s);
|
||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||
return (
|
||||
<div
|
||||
key={s.identity}
|
||||
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(s.identity)}
|
||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||
title={t("booth.openPayExit")}
|
||||
>
|
||||
<span className="text-term-text">
|
||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||
</span>
|
||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
||||
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
|
||||
</button>
|
||||
|
||||
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An
|
||||
unpaid transient has no button (no-unpaid-bypass). */}
|
||||
{s.paidAt || s.subscription ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
onClick={() => handleReopen(s)}
|
||||
className="btn btn-pay btn-sm shrink-0"
|
||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||
>
|
||||
{t("booth.openBarrier")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-[88px] shrink-0" />
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
+36
-29
@@ -1,11 +1,17 @@
|
||||
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 { SetupWizard } from "./SetupWizard.js";
|
||||
import { queryClient } from "./lib/query.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { applyTheme } from "./lib/theme.js";
|
||||
import { router } from "./router.js";
|
||||
|
||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
||||
// simple enough that a framework's abstractions cost more than they save.
|
||||
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me.
|
||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||
// off to TanStack Router inside the QueryClient provider. The router renders the
|
||||
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
||||
// the signed-in user flows into the router context for role-based route guards.
|
||||
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
||||
|
||||
export function App() {
|
||||
@@ -18,31 +24,32 @@ export function App() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>;
|
||||
if (!user) return <Login onLoggedIn={setUser} />;
|
||||
// Apply the signed-in user's preferred language + theme whenever they resolve/
|
||||
// change (login, bootstrap, or a toggle). Albanian + dark are the defaults before
|
||||
// auth resolves; on logout, fall back to dark so the Login screen is consistent.
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setLanguage(user.language);
|
||||
applyTheme(user.theme);
|
||||
} else {
|
||||
applyTheme("dark");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
||||
}
|
||||
if (!user) {
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
|
||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<h1 style={{ margin: 0 }}>Parking System</h1>
|
||||
<span style={{ color: "#555" }}>
|
||||
{user.username} ({user.role}){" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
setUser(null);
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
{user.role === "admin" ? (
|
||||
<SetupWizard />
|
||||
) : (
|
||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
||||
)}
|
||||
</main>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Login onLoggedIn={setUser} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} context={{ user, setUser }} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
boothExit,
|
||||
fetchSiteConfig,
|
||||
lookupSession,
|
||||
openShift,
|
||||
paySession,
|
||||
printReceipt,
|
||||
printVoucher,
|
||||
reopenBarrier,
|
||||
type SessionLookup,
|
||||
} from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||
|
||||
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
||||
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
|
||||
// payment, then EITHER prints an exit voucher (customer self-exits at a distant
|
||||
// exit) OR fires the exit immediately (booth at/near the exit) — controlled by a
|
||||
// checkbox defaulting from site_config.exitVoucherDefault. See booth-exit-flow.md.
|
||||
|
||||
type Phase = "review" | "paying" | "finishing" | "done" | "error";
|
||||
|
||||
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
|
||||
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
|
||||
|
||||
// A shift must be open (and mine) before any pay/exit/voucher action — the booth
|
||||
// money path is gated. The server enforces this too (409 no_shift); the modal
|
||||
// surfaces it up front and offers a one-click open. See wiki/concepts/shift.md.
|
||||
const { isOpen: shiftOpen, isMine: shiftMine, blockedByOther, heldBy } = useShift();
|
||||
const shiftReady = shiftOpen && shiftMine;
|
||||
|
||||
const [tender, setTender] = useState<"cash" | "card">("cash");
|
||||
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("review");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [openingShift, setOpeningShift] = useState(false);
|
||||
const [reprinting, setReprinting] = useState(false);
|
||||
|
||||
const s: SessionLookup | undefined = session.data;
|
||||
// Checkbox default comes from config the first time it loads; operator can toggle.
|
||||
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
|
||||
|
||||
const alreadyPaid = s?.paidAt != null;
|
||||
const isSubscription = s?.subscription === true;
|
||||
// A subscription is prepaid: never charged. The only booth action is an audited
|
||||
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
|
||||
const canPay = shiftReady && s?.found && s.open && !alreadyPaid && !isSubscription;
|
||||
|
||||
async function handleOpenBarrier() {
|
||||
if (!s) return;
|
||||
setError(null);
|
||||
setPhase("finishing");
|
||||
try {
|
||||
const r = await reopenBarrier(identity);
|
||||
setResult(r.opened ? t("pay.subBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }));
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||
setPhase("done");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setPhase("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenShift() {
|
||||
setOpeningShift(true);
|
||||
setError(null);
|
||||
try {
|
||||
await openShift();
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setOpeningShift(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReprintReceipt() {
|
||||
setReprinting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await printReceipt(identity);
|
||||
setResult(t("pay.receiptReprinted", { printer: r.printedBy }));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setReprinting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePayAndExit() {
|
||||
if (!s) return;
|
||||
setError(null);
|
||||
try {
|
||||
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
|
||||
if (!alreadyPaid) {
|
||||
setPhase("paying");
|
||||
await paySession(identity, tender);
|
||||
}
|
||||
// 2. Voucher OR immediate exit.
|
||||
setPhase("finishing");
|
||||
if (voucher) {
|
||||
// The voucher slip carries the payment detail + barcode + grace.
|
||||
const r = await printVoucher(identity);
|
||||
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
||||
} else {
|
||||
const r = await boothExit(identity);
|
||||
// No voucher → auto-print a standalone payment receipt for transparency.
|
||||
// Best-effort: a printer fault must NOT block the exit that already happened;
|
||||
// the operator can reprint from the done screen.
|
||||
let receiptNote = "";
|
||||
try {
|
||||
await printReceipt(identity);
|
||||
} catch {
|
||||
receiptNote = ` ${t("pay.receiptPrintFailed")}`;
|
||||
}
|
||||
setResult(
|
||||
(r.opened
|
||||
? t("pay.paidBarrierOpened")
|
||||
: t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") })) +
|
||||
receiptNote,
|
||||
);
|
||||
}
|
||||
// Refresh the live views.
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
setPhase("done");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setPhase("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
|
||||
<Dialog.Content
|
||||
className="fixed left-1/2 top-1/2 z-50 w-[560px] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{isSubscription
|
||||
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
||||
: `${t("pay.ticket")} ${identity}`}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
|
||||
✕
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{/* Shift gate — block all actions until THIS operator has a shift open.
|
||||
Another operator's open shift can't be operated under (no shared
|
||||
till); only an "open mine" path when no shift is open at all. */}
|
||||
{!shiftReady && (
|
||||
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
||||
{blockedByOther ? (
|
||||
<>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("shift.gateOtherTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">
|
||||
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("shift.gateTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenShift}
|
||||
disabled={openingShift}
|
||||
className="btn btn-go btn-sm mt-2"
|
||||
>
|
||||
{openingShift ? t("shift.opening") : t("shift.openNow")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
|
||||
|
||||
{s && !s.found && (
|
||||
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
|
||||
{t("pay.noSessionFound")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && s.found && !s.open && (
|
||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && s.found && s.open && (
|
||||
<>
|
||||
{/* Session figures */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
|
||||
<Row
|
||||
label={t("pay.duration")}
|
||||
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.statusLabel")}
|
||||
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
||||
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Total — a subscription is prepaid (no amount); show a badge. */}
|
||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{isSubscription ? t("pay.plan") : t("pay.total")}
|
||||
</span>
|
||||
<span className="text-3xl font-bold text-term-cyan">
|
||||
{isSubscription
|
||||
? t("pay.prepaid")
|
||||
: s.amountMinor != null && s.currency
|
||||
? formatMoney(s.amountMinor, s.currency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* For a subscription, explain the only available action. */}
|
||||
{isSubscription && (
|
||||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
|
||||
{t("pay.subAssistHint")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshots */}
|
||||
<SnapshotStrip identity={identity} />
|
||||
|
||||
{phase !== "done" && !isSubscription && (
|
||||
<>
|
||||
{/* Tender */}
|
||||
{canPay && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||
{(["cash", "card"] as const).map((tn) => (
|
||||
<button
|
||||
key={tn}
|
||||
type="button"
|
||||
onClick={() => setTender(tn)}
|
||||
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{t(`pay.${tn}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voucher checkbox (default from site config) */}
|
||||
<label className="flex items-center gap-2 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={voucher}
|
||||
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
|
||||
/>
|
||||
{t("pay.printExitVoucher")}
|
||||
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||
{result && (
|
||||
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
{phase === "done" ? (
|
||||
<>
|
||||
{/* Reprint the payment receipt (slip jammed / customer asks).
|
||||
Only for a charged session — a subscription has no payment. */}
|
||||
{!isSubscription && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReprintReceipt}
|
||||
disabled={reprinting}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{reprinting ? t("pay.reprinting") : t("pay.reprintReceipt")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
{isSubscription ? (
|
||||
// Prepaid — the only action is the audited barrier open (assist
|
||||
// a faulty exit reader / missing card). Gated on an open shift.
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenBarrier}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="btn btn-pay btn-lg"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="btn btn-go btn-lg"
|
||||
>
|
||||
{phase === "paying"
|
||||
? t("pay.takingPayment")
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? t("pay.printingVoucher")
|
||||
: t("pay.opening")
|
||||
: alreadyPaid
|
||||
? voucher
|
||||
? t("pay.printVoucher")
|
||||
: t("pay.openBarrier")
|
||||
: voucher
|
||||
? t("pay.payAndVoucher")
|
||||
: t("pay.payAndOpen")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className={`text-sm ${valueClass}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||
import { formatMoney } from "./lib/format.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useLiveStore } from "./lib/live-store.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
import { StatusDot } from "./ui/StatusDot.js";
|
||||
import { BoothPayModal } from "./BoothPayModal.js";
|
||||
import { ActiveSessions } from "./ActiveSessions.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||
import { renderReason } from "./lib/reason.js";
|
||||
|
||||
// The live operator booth view — the real-time heart of the console. Occupancy
|
||||
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
||||
// the authoritative numbers; the WS-fed live store overlays real-time updates so
|
||||
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
|
||||
|
||||
/** Per-event-type display: i18n label key + accent colour for the ticker. */
|
||||
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
||||
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
|
||||
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
|
||||
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
|
||||
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
|
||||
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
|
||||
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
|
||||
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
||||
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
||||
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||
};
|
||||
|
||||
function hhmmss(iso: string): string {
|
||||
// Local time-of-day, terminal style. Defensive against a bad timestamp.
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
|
||||
}
|
||||
|
||||
function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||
const { t } = useTranslation();
|
||||
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
|
||||
const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green";
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
||||
<div className="pb-1 text-term-muted">
|
||||
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
|
||||
<div className="text-sm tabular-nums">
|
||||
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
||||
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
||||
{occ.free == null ? "∞" : occ.free}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pct != null && (
|
||||
<div className="h-2 w-full overflow-hidden rounded-term bg-term-panel-2">
|
||||
<div className={`h-full ${barColor} transition-[width] duration-300`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{occ.full && (
|
||||
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
|
||||
{t("booth.lotFull")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Translated classification badges derived from a payload's boolean flags. Unlike
|
||||
* `reason` (an immutable English sentence baked into the signed ledger, shown
|
||||
* verbatim), these are computed client-side so they CAN be localized. They give a
|
||||
* glanceable "what kind of anomaly" tag without parsing the free-text reason. */
|
||||
function eventBadges(p: LedgerEvent["payload"]): string[] {
|
||||
if (!p) return [];
|
||||
const keys: string[] = [];
|
||||
if (p.entryRefused) keys.push("booth.badgeEntryRefused");
|
||||
if (p.exitRefused) keys.push("booth.badgeExitRefused");
|
||||
if (p.full) keys.push("booth.badgeLotFull");
|
||||
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
||||
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
||||
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
||||
if (p.source === "manual") keys.push("booth.badgeManualOpen");
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** The i18n key for a subscriber's access medium (`via`), or null. Lets the activity
|
||||
* log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */
|
||||
function viaKey(p: LedgerEvent["payload"]): string | null {
|
||||
if (!p) return null;
|
||||
if (p.via === "qr") return "booth.viaQr";
|
||||
if (p.via === "card") return "booth.viaCard";
|
||||
if (p.via === "plate") return "booth.viaPlate";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A short money summary for payment events (e.g. "350.00 ALL"). */
|
||||
function paymentSummary(p: LedgerEvent["payload"]): string | null {
|
||||
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
|
||||
return formatMoney(p.amountMinor, p.currency);
|
||||
}
|
||||
|
||||
/** What to SHOW for an event's actor. A subscription occurrence has an opaque
|
||||
* `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`,
|
||||
* so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */
|
||||
function displayIdentity(e: LedgerEvent): string {
|
||||
return e.subscriberLabel ?? e.identity ?? "—";
|
||||
}
|
||||
|
||||
function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const style = EVENT_STYLE[e.type];
|
||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
||||
const isAnomaly = e.type === "anomaly";
|
||||
const p = e.payload;
|
||||
// Localize the reason from the signed reasonCode (falls back to the English text on
|
||||
// legacy events). Anomalies ALWAYS get a detail line so a red flag is never silent.
|
||||
const reason = renderReason(p, t);
|
||||
const amount = paymentSummary(p);
|
||||
const badges = eventBadges(p);
|
||||
const via = viaKey(p);
|
||||
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
||||
const showDetail = detail != null || badges.length > 0 || via != null;
|
||||
|
||||
// The whole row is a button → opens the event-detail modal (full payload + the
|
||||
// session's entry/exit snapshots). A grid keeps the time/label/identity/index
|
||||
// columns aligned across rows; the detail line lives in its own row, indented to
|
||||
// start under the identity column so it never collides with the ticket code.
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(e)}
|
||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||
isAnomaly ? "bg-term-red/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||
<span className="text-term-muted">#{e.index}</span>
|
||||
{showDetail && (
|
||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
{via && (
|
||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
||||
{t(via)}
|
||||
</span>
|
||||
)}
|
||||
{detail && (
|
||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** One label/value line in the event-detail modal. */
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className="min-w-0 break-words text-term-text">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full read-only detail for one ledger event: business fields + the human-readable
|
||||
* reason + the session's entry/exit snapshots, then the signed-chain provenance
|
||||
* (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable;
|
||||
* this only DISPLAYS the signed record. */
|
||||
function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const style = EVENT_STYLE[e.type];
|
||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
||||
const p = e.payload;
|
||||
const reason = renderReason(p, t);
|
||||
const amount = paymentSummary(p);
|
||||
const badges = eventBadges(p);
|
||||
const isAnomaly = e.type === "anomaly";
|
||||
|
||||
// Pretty money for any minor-unit amount in the payload.
|
||||
const money =
|
||||
p && typeof p.amountMinor === "number" && typeof p.currency === "string"
|
||||
? formatMoney(p.amountMinor, p.currency)
|
||||
: null;
|
||||
// Pull out the business fields worth a labelled row. Everything else (and the raw
|
||||
// bytes) lives behind the audit disclosure — the operator sees a clean summary.
|
||||
const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null;
|
||||
const plate = typeof p?.plate === "string" ? p.plate : null;
|
||||
const category = typeof p?.category === "string" ? p.category : null;
|
||||
const operator = typeof p?.operator === "string" ? p.operator : null;
|
||||
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
||||
{(reason || money) && (
|
||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||
{reason ?? money}
|
||||
</div>
|
||||
)}
|
||||
{!reason && !money && isAnomaly && (
|
||||
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
||||
)}
|
||||
{badges.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
||||
<div>
|
||||
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
||||
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
||||
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
||||
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
|
||||
{/* When we showed a subscriber NAME above, also expose the raw occurrence id
|
||||
(the SUBSESS-… session key) for traceability against the ledger. */}
|
||||
{e.subscriberLabel && e.identity && (
|
||||
<DetailRow label={t("booth.edOccurrence")}>
|
||||
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{money && (
|
||||
<DetailRow label={t("booth.edAmount")}>
|
||||
<span className="text-term-cyan">{money}</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
||||
{viaKey(p) && (
|
||||
<DetailRow label={t("booth.edVia")}>
|
||||
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||
{sessionRef && sessionRef !== e.identity && (
|
||||
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
||||
)}
|
||||
{tariffVersionId && (
|
||||
<DetailRow label={t("booth.edTariffVersion")}>
|
||||
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The entry/exit evidence images for this session's identity. */}
|
||||
{e.identity && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
||||
<SnapshotStrip identity={e.identity} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit data — collapsed by default. The signed-chain provenance (signature,
|
||||
key, prev-hash) and the raw payload are an auditor's concern, not the
|
||||
operator's; tucking them behind a disclosure keeps the common view clean
|
||||
while preserving the tamper-evidence trail on demand. */}
|
||||
<details className="rounded-term border border-term-border bg-term-panel-2">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||
{t("booth.edAuditData")}
|
||||
</summary>
|
||||
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
||||
<DetailRow label={t("booth.edSignature")}>
|
||||
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label={t("booth.edKeyId")}>
|
||||
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label={t("booth.edPrevHash")}>
|
||||
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
||||
</DetailRow>
|
||||
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{t("booth.edRawPayload")}
|
||||
</div>
|
||||
{p && Object.keys(p).length > 0 ? (
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
||||
{JSON.stringify(p, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
|
||||
* operator types it. Either way, submit opens the pay/exit modal for that id. The
|
||||
* input auto-focuses and re-focuses after a scan so the scanner always lands here. */
|
||||
function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const id = value.trim();
|
||||
if (id) {
|
||||
onSubmit(id);
|
||||
setValue("");
|
||||
ref.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={ref}
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t("booth.scanPlaceholder")}
|
||||
inputMode="numeric"
|
||||
className="input h-11 flex-1 px-3 text-lg tabular-nums"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-lg">
|
||||
{t("booth.open")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoothScreen() {
|
||||
const { t } = useTranslation();
|
||||
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
|
||||
// window (per-shift logs, not all history). When no shift is open, the feed is
|
||||
// empty and the operator is prompted to open one.
|
||||
const { isOpen: shiftOpen, startedAt: shiftStart } = useShift();
|
||||
|
||||
// Initial load via Query (also the fallback if the WS is briefly down). The events
|
||||
// query is scoped to the current shift's start so it never shows prior shifts.
|
||||
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: [...qk.events, shiftStart ?? "none"],
|
||||
queryFn: () => fetchEvents(100, shiftStart ?? undefined),
|
||||
enabled: shiftOpen,
|
||||
});
|
||||
|
||||
// The ticket currently open in the pay/exit modal (null = no modal).
|
||||
const [activeTicket, setActiveTicket] = useState<string | null>(null);
|
||||
// The ledger event open in the read-only detail modal (null = closed).
|
||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||
|
||||
// Live overlays from the WS store.
|
||||
const liveOcc = useLiveStore((s) => s.occupancy);
|
||||
const liveFeed = useLiveStore((s) => s.feed);
|
||||
|
||||
// Prefer the live-pushed occupancy; fall back to the query.
|
||||
const occ = liveOcc ?? occQuery.data ?? null;
|
||||
|
||||
// Merge: live events first (newest), then the queried history, de-duped by id —
|
||||
// then clip to the current shift window (the live store spans shifts; the feed
|
||||
// must not show events from before this shift's start). No shift → no feed.
|
||||
const seen = new Set(liveFeed.map((e) => e.id));
|
||||
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||
const merged = [...liveFeed, ...history].slice(0, 200);
|
||||
const events =
|
||||
shiftOpen && shiftStart
|
||||
? merged.filter((e) => e.occurredAt >= shiftStart)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
||||
<div className="lg:col-span-2">
|
||||
<Panel title={t("booth.processTicket")}>
|
||||
<TicketInput onSubmit={setActiveTicket} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* Left column: occupancy gauge above the active-sessions list. */}
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<Panel title={t("booth.occupancy")} right={<StatusDot />}>
|
||||
{occ ? (
|
||||
<OccupancyGauge occ={occ} />
|
||||
) : (
|
||||
<div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
|
||||
)}
|
||||
</Panel>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ActiveSessions onPick={setActiveTicket} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title={t("booth.liveFeed")}
|
||||
right={
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||
{events.length} {t("booth.events")}
|
||||
</span>
|
||||
}
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
{!shiftOpen ? (
|
||||
<div className="text-term-amber">{t("shift.gateTitle")}</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
|
||||
) : (
|
||||
events.map((e) => <EventRow key={e.id} e={e} onOpen={setDetailEvent} />)
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
|
||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
-18
@@ -1,7 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { login, type SessionUser } from "./api.js";
|
||||
|
||||
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -21,38 +23,32 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
|
||||
<h1>Parking System</h1>
|
||||
<form onSubmit={submit}>
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<label>
|
||||
Username
|
||||
<br />
|
||||
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
|
||||
<form onSubmit={submit} className="card w-full max-w-sm p-6">
|
||||
<h1 className="mb-5 text-h5 font-semibold uppercase tracking-widest text-term-amber">{t("auth.title")}</h1>
|
||||
<div className="field mb-3">
|
||||
<label className="label">{t("auth.username")}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<label>
|
||||
Password
|
||||
<br />
|
||||
<div className="field mb-3">
|
||||
<label className="label">{t("auth.password")}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<button type="submit" disabled={busy || !username || !password}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
{error && <p className="mb-3 text-[12px] text-term-red">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
|
||||
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchLogs, type AppLogRecord, type LogLevel } from "./api.js";
|
||||
import { formatRelativeDateTime } from "./lib/format.js";
|
||||
|
||||
// Diagnostic log viewer (app_logs) — backend warn+ and frontend errors in one place.
|
||||
// Gated by log:read server-side. Filter by level / source / since; each row expands to
|
||||
// the structured context + stack. Read-only — logs are an evidence/diagnostic stream,
|
||||
// never edited. See wiki/concepts/app-logs.md.
|
||||
|
||||
const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||
|
||||
/** Terminal-theme colour per level. */
|
||||
const LEVEL_COLOR: Record<LogLevel, string> = {
|
||||
trace: "text-term-muted",
|
||||
debug: "text-term-muted",
|
||||
info: "text-term-cyan",
|
||||
warn: "text-term-amber",
|
||||
error: "text-term-red",
|
||||
fatal: "text-term-red",
|
||||
};
|
||||
|
||||
function LogRow({ log }: { log: AppLogRecord }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
|
||||
|
||||
return (
|
||||
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasDetail && setOpen((v) => !v)}
|
||||
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[12px] ${
|
||||
hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
|
||||
}`}
|
||||
>
|
||||
<span className="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
|
||||
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</span>
|
||||
<span className="text-term-muted">{t(log.source === "frontend" ? "logs.frontend" : "logs.backend")}</span>
|
||||
<span className="truncate text-term-text">{log.message}</span>
|
||||
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
|
||||
</button>
|
||||
{open && hasDetail && (
|
||||
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
|
||||
{log.path && (
|
||||
<div className="mb-1 text-[11px] text-term-muted">
|
||||
{t("logs.path")}: <code className="text-term-text">{log.path}</code>
|
||||
</div>
|
||||
)}
|
||||
{log.context && Object.keys(log.context).length > 0 && (
|
||||
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-text">
|
||||
{JSON.stringify(log.context, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{log.stack && (
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-red/90">
|
||||
{log.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsViewer() {
|
||||
const { t } = useTranslation();
|
||||
const [level, setLevel] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [since, setSince] = useState("");
|
||||
const [applied, setApplied] = useState<{ level?: string; source?: string; since?: string }>({});
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ["logs", applied],
|
||||
queryFn: () => fetchLogs({ ...applied, limit: 500 }),
|
||||
refetchInterval: 15_000, // keep the booth view roughly live without a WS
|
||||
});
|
||||
|
||||
const logs = q.data?.logs ?? [];
|
||||
|
||||
function apply() {
|
||||
setApplied({
|
||||
level: level || undefined,
|
||||
source: source || undefined,
|
||||
since: since ? new Date(`${since}T00:00:00`).toISOString() : undefined,
|
||||
});
|
||||
}
|
||||
function clear() {
|
||||
setLevel("");
|
||||
setSource("");
|
||||
setSince("");
|
||||
setApplied({});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("logs.title")}</h1>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => q.refetch()}>
|
||||
{t("logs.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card mb-3 flex flex-wrap items-end gap-3 p-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("logs.level")}</span>
|
||||
<select className="select w-32" value={level} onChange={(e) => setLevel(e.target.value)}>
|
||||
<option value="">{t("logs.allLevels")}</option>
|
||||
{LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("logs.source")}</span>
|
||||
<select className="select w-36" value={source} onChange={(e) => setSource(e.target.value)}>
|
||||
<option value="">{t("logs.allSources")}</option>
|
||||
<option value="frontend">{t("logs.frontend")}</option>
|
||||
<option value="backend">{t("logs.backend")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("logs.since")}</span>
|
||||
<input type="date" className="input w-40" value={since} onChange={(e) => setSince(e.target.value)} />
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={apply}>
|
||||
{t("logs.apply")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={clear}>
|
||||
{t("logs.clear")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-2">
|
||||
{q.isLoading ? (
|
||||
<div className="p-3 text-[12px] text-term-muted">{t("common.loading")}</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-3 text-[12px] text-term-muted">{t("logs.empty")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span>{t("logs.time")}</span>
|
||||
<span>{t("logs.level")}</span>
|
||||
<span>{t("logs.source")}</span>
|
||||
<span>{t("logs.message")}</span>
|
||||
<span>{t("logs.status")}</span>
|
||||
</div>
|
||||
{logs.map((log) => (
|
||||
<LogRow key={log.id} log={log} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiError,
|
||||
can,
|
||||
createRole,
|
||||
deleteRole,
|
||||
fetchRoles,
|
||||
updateRole,
|
||||
type ManagedRole,
|
||||
type Permission,
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// Role management (admin). Compose a role from the permission grid (a checkbox
|
||||
// matrix of resource × action) and name it; users are then assigned a role. The
|
||||
// built-in `admin` role is shown read-only/locked (it always has every permission
|
||||
// and can't be edited or deleted). The server enforces the same. See
|
||||
// @parking/shared PERMISSIONS.
|
||||
|
||||
/** Group "resource:action" permissions by resource for the grid rows. */
|
||||
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
||||
const out: Record<string, Permission[]> = {};
|
||||
for (const p of perms) {
|
||||
const resource = p.split(":")[0]!;
|
||||
(out[resource] ??= []).push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function RolesManager({ user }: { user: SessionUser | null }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const rolesQ = useQuery({ queryKey: ["roles"], queryFn: fetchRoles });
|
||||
|
||||
const canCreate = can(user, "role:create");
|
||||
const canUpdate = can(user, "role:update");
|
||||
const canDelete = can(user, "role:delete");
|
||||
|
||||
const catalog = rolesQ.data?.catalog ?? [];
|
||||
const roles = rolesQ.data?.roles ?? [];
|
||||
const grouped = useMemo(() => groupByResource(catalog), [catalog]);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<ManagedRole | "new" | null>(null);
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ["roles"] });
|
||||
void qc.invalidateQueries({ queryKey: ["users"] });
|
||||
};
|
||||
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("roles.title")}</h1>
|
||||
{canCreate && (
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => { setEditing("new"); setError(null); }}>
|
||||
{t("roles.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
||||
|
||||
<Modal
|
||||
open={editing != null}
|
||||
onClose={() => setEditing(null)}
|
||||
title={editing && editing !== "new" ? t("roles.editTitle") : t("roles.new")}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
{editing && (
|
||||
<RoleEditor
|
||||
role={editing === "new" ? null : editing}
|
||||
grouped={grouped}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
if (editing === "new") await createRole(v);
|
||||
else await updateRole(editing.id, v);
|
||||
setEditing(null);
|
||||
invalidate();
|
||||
} catch (e) { onError(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{roles.map((r) => (
|
||||
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-term-text">{r.name}</span>
|
||||
{r.builtin && (
|
||||
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-term-amber">
|
||||
{t("roles.builtin")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-term-muted">
|
||||
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canUpdate && !r.builtin && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
|
||||
)}
|
||||
{canDelete && !r.builtin && (
|
||||
<button type="button" className="btn btn-danger btn-sm"
|
||||
onClick={() => { if (confirm(t("roles.confirmDelete", { name: r.name }))) deleteRoleSafe(r.id, invalidate, onError); }}>{t("roles.delete")}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown) => void) {
|
||||
try { await deleteRole(id); ok(); } catch (e) { onError(e); }
|
||||
}
|
||||
|
||||
function RoleEditor({
|
||||
role, grouped, onCancel, onSubmit,
|
||||
}: {
|
||||
role: ManagedRole | null;
|
||||
grouped: Record<string, Permission[]>;
|
||||
onCancel: () => void;
|
||||
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(role?.name ?? "");
|
||||
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? []));
|
||||
const toggle = (p: Permission) =>
|
||||
setPerms((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(p) ? next.delete(p) : next.add(p);
|
||||
return next;
|
||||
});
|
||||
|
||||
const valid = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="field mb-3 w-64">
|
||||
<span className="label">{t("roles.name")}</span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="label">{t("roles.permissions")}</div>
|
||||
<div className="mt-1 grid grid-cols-1 gap-1">
|
||||
{Object.entries(grouped).map(([resource, list]) => (
|
||||
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
|
||||
<span className="w-28 shrink-0 text-[12px] font-semibold text-term-text">{resource}</span>
|
||||
{list.map((p) => {
|
||||
const action = p.split(":")[1]!;
|
||||
return (
|
||||
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
|
||||
{action}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}>{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+487
-170
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
assignDevice,
|
||||
editDevice,
|
||||
discoverDevices,
|
||||
fetchBackendIps,
|
||||
fetchCatalog,
|
||||
@@ -12,28 +14,47 @@ import {
|
||||
type Catalog,
|
||||
type CatalogEntry,
|
||||
type DeviceCategory,
|
||||
type DeviceConfig,
|
||||
type Direction,
|
||||
type DiscoveredDevice,
|
||||
type RelaySpec,
|
||||
type TestResult,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// First-run setup wizard (scaffold). The admin assigns devices per lane from the
|
||||
// driver catalog. The data model is multi-instance — one lane_devices row per
|
||||
// instance — so EVERY category supports more than one device: each section lists
|
||||
// the already-assigned instances (with Remove) and an "Add" form. Drivers that
|
||||
// support LAN discovery get a "Scan" button. Auth is via the admin's session
|
||||
// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md.
|
||||
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
|
||||
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
|
||||
// declares its relays = entry/exit/both + which input terminal the entry button is
|
||||
// on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at).
|
||||
// Direction is a property of the relay, inherited by bound devices. The data model
|
||||
// is multi-instance — one `devices` row per instance. See entry-exit-points.md.
|
||||
|
||||
const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [
|
||||
{ key: "access", title: "Access controllers", noun: "access controller" },
|
||||
{ key: "reader", title: "Readers", noun: "reader" },
|
||||
{ key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" },
|
||||
{ key: "printer", title: "Printers", noun: "printer" },
|
||||
// Categories carry i18n KEYS (resolved at render via t()), not literal copy.
|
||||
// `titleKey` is the section heading; `nounKey` resolves to the singular noun used in
|
||||
// the add/edit buttons, modal titles and confirm prompts.
|
||||
const CONTROLLER: { key: DeviceCategory; titleKey: string; nounKey: string } = {
|
||||
key: "access",
|
||||
titleKey: "setup.catControllers",
|
||||
nounKey: "setup.nounController",
|
||||
};
|
||||
// Categories that BIND to a controller relay (direction inherited from the relay).
|
||||
const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
|
||||
{ key: "reader", titleKey: "setup.catReaders", nounKey: "setup.nounReader" },
|
||||
{ key: "camera", titleKey: "setup.catCameras", nounKey: "setup.nounCamera" },
|
||||
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
|
||||
];
|
||||
|
||||
// Translated direction label (relay direction / inherited binding).
|
||||
const DIRECTION_KEYS: Record<Direction, string> = {
|
||||
entry: "setup.dirEntry",
|
||||
exit: "setup.dirExit",
|
||||
both: "setup.dirBoth",
|
||||
};
|
||||
|
||||
export function SetupWizard() {
|
||||
const { t } = useTranslation();
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
||||
const [lane, setLane] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reloadState = useCallback(() => {
|
||||
@@ -47,38 +68,40 @@ export function SetupWizard() {
|
||||
reloadState();
|
||||
}, [reloadState]);
|
||||
|
||||
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
||||
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
||||
if (error) return <p className="px-4 py-6 text-term-red">{t("setup.failedToLoad", { error })}</p>;
|
||||
if (!catalog || !assignments) return <p className="px-4 py-6 text-term-muted">{t("setup.loadingCatalog")}</p>;
|
||||
|
||||
// Controllers are needed before binding readers/cameras (they pick a controller relay).
|
||||
const controllers = assignments.filter((a) => a.category === "access");
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>First-run setup</h2>
|
||||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<label>
|
||||
Lane{" "}
|
||||
<input
|
||||
type="number"
|
||||
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>
|
||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
||||
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
|
||||
|
||||
{CATEGORIES.map(({ key, title, noun }) => (
|
||||
<CategorySection
|
||||
category={CONTROLLER.key}
|
||||
title={t(CONTROLLER.titleKey)}
|
||||
noun={t(CONTROLLER.nounKey)}
|
||||
entries={catalog[CONTROLLER.key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
pushCapableIds={catalog.pushCapable}
|
||||
controllers={controllers}
|
||||
assignments={controllers}
|
||||
onChanged={reloadState}
|
||||
/>
|
||||
|
||||
{BOUND.map(({ key, titleKey, nounKey }) => (
|
||||
<CategorySection
|
||||
key={key}
|
||||
lane={lane}
|
||||
category={key}
|
||||
title={title}
|
||||
noun={noun}
|
||||
title={t(titleKey)}
|
||||
noun={t(nounKey)}
|
||||
entries={catalog[key]}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
@@ -87,107 +110,128 @@ export function SetupWizard() {
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
lane,
|
||||
category,
|
||||
title,
|
||||
noun,
|
||||
entries,
|
||||
discoverableIds,
|
||||
pushCapableIds,
|
||||
controllers,
|
||||
assignments,
|
||||
onChanged,
|
||||
}: {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
title: string;
|
||||
noun: string;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
pushCapableIds: string[];
|
||||
controllers: Assignment[];
|
||||
assignments: Assignment[];
|
||||
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);
|
||||
// Warnings from the most recent save (e.g. "string protocol could not be
|
||||
// disabled — finish in the device web UI"). Persist after the form closes.
|
||||
const { t } = useTranslation();
|
||||
// The form is popped out in a Modal. `formFor` selects what it edits:
|
||||
// - "new" → the add form
|
||||
// - an Assignment → edit that device in place
|
||||
// - null → closed.
|
||||
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const showForm = adding || assignments.length === 0;
|
||||
|
||||
// Binding categories need a controller to point at first.
|
||||
const isBound = category !== "access";
|
||||
const blockedNoController = isBound && controllers.length === 0;
|
||||
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
||||
|
||||
return (
|
||||
<fieldset style={{ marginTop: "1rem" }}>
|
||||
<legend>
|
||||
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
|
||||
</legend>
|
||||
<fieldset className="card mt-4 p-4">
|
||||
<legend className="px-1 text-h6 font-semibold uppercase tracking-wider text-term-text">{title}</legend>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
margin: "0 0 0.75rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: "#fef3c7",
|
||||
border: "1px solid #f59e0b",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: "#92400e" }}>⚠ Saved, but action needed:</strong>
|
||||
<ul style={{ margin: "0.25rem 0 0", paddingLeft: "1.25rem", color: "#92400e" }}>
|
||||
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
|
||||
<strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
|
||||
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={() => setWarnings([])} style={{ marginTop: "0.5rem" }}>
|
||||
Dismiss
|
||||
<button type="button" className="btn btn-sm mt-2" onClick={() => setWarnings([])}>
|
||||
{t("setup.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assignments.length > 0 && (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||
<ul className="mb-3 list-none p-0">
|
||||
{assignments.map((a) => (
|
||||
<AssignmentRow key={a.id} assignment={a} onChanged={onChanged} />
|
||||
<AssignmentRow
|
||||
key={a.id}
|
||||
assignment={a}
|
||||
controllers={controllers}
|
||||
onChanged={onChanged}
|
||||
onEdit={() => setFormFor(a)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{showForm ? (
|
||||
{blockedNoController ? (
|
||||
<p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
||||
) : (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
|
||||
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add/edit form — popped out. One modal per category; the device list stays
|
||||
in the page behind it. */}
|
||||
<Modal
|
||||
open={formFor != null}
|
||||
onClose={() => setFormFor(null)}
|
||||
title={editing ? t("setup.editTitle", { noun }) : t("setup.addTitle", { noun })}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
{formFor != null && (
|
||||
<DeviceForm
|
||||
lane={lane}
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
editing={editing}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
setAdding(false);
|
||||
setFormFor(null);
|
||||
}}
|
||||
onCancel={assignments.length > 0 ? () => setAdding(false) : undefined}
|
||||
onCancel={() => setFormFor(null)}
|
||||
/>
|
||||
) : (
|
||||
<button type="button" onClick={() => setAdding(true)}>
|
||||
+ Add another {noun}
|
||||
</button>
|
||||
)}
|
||||
</Modal>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentRow({
|
||||
assignment,
|
||||
controllers,
|
||||
onChanged,
|
||||
onEdit,
|
||||
}: {
|
||||
assignment: Assignment;
|
||||
controllers: Assignment[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// A short, human summary of the instance: role (if any) + host.
|
||||
const cfg = assignment.config;
|
||||
const role = typeof cfg.role === "string" ? cfg.role : null;
|
||||
const cfg = assignment.config as Record<string, unknown>;
|
||||
const host = typeof cfg.host === "string" ? cfg.host : null;
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Remove this ${assignment.driverId} device?`)) return;
|
||||
if (!confirm(t("setup.confirmRemove", { driver: assignment.driverId }))) return;
|
||||
setRemoving(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -200,49 +244,107 @@ function AssignmentRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.4rem 0.5rem",
|
||||
borderBottom: "1px solid #eee",
|
||||
}}
|
||||
>
|
||||
<strong>{assignment.driverId}</strong>
|
||||
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
|
||||
{host && <span style={{ color: "#666" }}>{host}</span>}
|
||||
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
||||
<button type="button" onClick={remove} disabled={removing}>
|
||||
{removing ? "Removing…" : "Remove"}
|
||||
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
|
||||
<strong className="text-term-text">{assignment.driverId}</strong>
|
||||
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
|
||||
<span className="flex-1" />
|
||||
{error && <span className="text-term-red">{error}</span>}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onEdit} disabled={removing}>
|
||||
{t("setup.edit")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={remove} disabled={removing}>
|
||||
{removing ? t("setup.removing") : t("setup.remove")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline summary of an assignment's direction/binding for the list. */
|
||||
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
||||
const { t } = useTranslation();
|
||||
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 className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
||||
return (
|
||||
<span className="flex gap-1.5">
|
||||
{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 className="text-term-amber">{t("setup.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({
|
||||
lane,
|
||||
category,
|
||||
entries,
|
||||
discoverableIds,
|
||||
pushCapableIds,
|
||||
controllers,
|
||||
editing,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
pushCapableIds: string[];
|
||||
controllers: Assignment[];
|
||||
/** When set, the form edits this assignment in place (driver locked, config
|
||||
* pre-filled) instead of adding a new device. */
|
||||
editing?: Assignment;
|
||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const { t } = useTranslation();
|
||||
// On edit the driver is fixed (you can't change what KIND of device a slot is —
|
||||
// that's a remove + re-add); pre-select it and lock the picker.
|
||||
const editCfg = editing?.config as Record<string, unknown> | undefined;
|
||||
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
|
||||
const selected = entries.find((e) => e.id === selectedId);
|
||||
const 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 [testing, setTesting] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
@@ -252,18 +354,12 @@ function DeviceForm({
|
||||
const [scanning, setScanning] = useState(false);
|
||||
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 [backendIp, setBackendIp] = useState<string>("");
|
||||
|
||||
// (Re)load backend-IP candidates whenever the device host changes after a
|
||||
// successful test (the test confirms the host is real + reachable).
|
||||
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
|
||||
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
||||
useEffect(() => {
|
||||
if (!testedHost) {
|
||||
if (!testedHost || !pushesToBackend) {
|
||||
setBackendIps(null);
|
||||
return;
|
||||
}
|
||||
@@ -281,7 +377,7 @@ function DeviceForm({
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [testedHost]);
|
||||
}, [testedHost, pushesToBackend]);
|
||||
|
||||
function selectDriver(id: string) {
|
||||
setSelectedId(id);
|
||||
@@ -308,8 +404,8 @@ function DeviceForm({
|
||||
resetStatus();
|
||||
}
|
||||
|
||||
// Config the user actually entered, merged over driver defaults.
|
||||
function mergedConfig(): Record<string, string | number> {
|
||||
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
|
||||
function mergedScalarConfig(): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const f of selected?.configFields ?? []) {
|
||||
const v = config[f.key] ?? (f.default as string | number | undefined);
|
||||
@@ -318,7 +414,24 @@ function DeviceForm({
|
||||
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 } : {}),
|
||||
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
|
||||
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
|
||||
}));
|
||||
} else if (controllerId && boundRelay !== "") {
|
||||
out.controllerId = controllerId;
|
||||
out.relay = boundRelay;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resetStatus() {
|
||||
setTested(null);
|
||||
setTestError(null);
|
||||
@@ -331,7 +444,7 @@ function DeviceForm({
|
||||
setTestError(null);
|
||||
setTested(null);
|
||||
try {
|
||||
setTested(await testDevice(selected.id, mergedConfig()));
|
||||
setTested(await testDevice(selected.id, mergedScalarConfig()));
|
||||
} catch (e) {
|
||||
setTestError((e as Error).message);
|
||||
} finally {
|
||||
@@ -341,17 +454,26 @@ function DeviceForm({
|
||||
|
||||
async function save() {
|
||||
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);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await assignDevice({
|
||||
lane,
|
||||
const result = editing
|
||||
? await editDevice(editing.id, {
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
})
|
||||
: await assignDevice({
|
||||
category,
|
||||
driverId: selected.id,
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
});
|
||||
// Hand warnings to the parent so they persist after this form unmounts.
|
||||
await onSaved(result.warnings ?? []);
|
||||
} catch (e) {
|
||||
setSaveError((e as Error).message);
|
||||
@@ -361,13 +483,15 @@ function DeviceForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0.5rem", background: "#fafafa", borderRadius: 6 }}>
|
||||
<div>
|
||||
{entries.length === 0 ? (
|
||||
<em>No drivers registered.</em>
|
||||
<em className="text-term-muted">{t("setup.noDrivers")}</em>
|
||||
) : (
|
||||
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}>
|
||||
// Driver is locked when editing — changing the kind of device is a
|
||||
// remove + re-add, not an in-place edit.
|
||||
<select className="select w-auto min-w-64" value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
|
||||
<option value="" disabled>
|
||||
Choose a device…
|
||||
{t("setup.chooseDevice")}
|
||||
</option>
|
||||
{entries.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
@@ -378,26 +502,26 @@ function DeviceForm({
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p>
|
||||
<div className="mt-3">
|
||||
<p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
|
||||
|
||||
{canDiscover && (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<button type="button" onClick={scan} disabled={scanning}>
|
||||
{scanning ? "Scanning…" : "Scan for controllers"}
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
|
||||
{scanning ? t("setup.scanning") : t("setup.scan")}
|
||||
</button>
|
||||
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
||||
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
||||
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
|
||||
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
|
||||
{found && found.length > 0 && (
|
||||
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1rem" }}>
|
||||
<ul className="mt-2 list-none p-0">
|
||||
{found.map((d) => (
|
||||
<li key={d.id} style={{ margin: "0.25rem 0" }}>
|
||||
<button type="button" onClick={() => applyDiscovered(d)}>
|
||||
Use
|
||||
</button>{" "}
|
||||
<strong>{d.label}</strong>{" "}
|
||||
<li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
|
||||
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
|
||||
{t("setup.use")}
|
||||
</button>
|
||||
<strong className="text-term-text">{d.label}</strong>
|
||||
<HealthBadge status={d.health.status} />
|
||||
{d.info?.firmware && <span style={{ color: "#666" }}> · fw {d.info.firmware}</span>}
|
||||
{d.info?.firmware && <span className="text-term-muted"> · fw {d.info.firmware}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -406,12 +530,14 @@ function DeviceForm({
|
||||
)}
|
||||
|
||||
{selected.configFields.map((f) => (
|
||||
<div key={f.key} style={{ margin: "0.25rem 0" }}>
|
||||
<label>
|
||||
<div key={f.key} className="field my-2 max-w-sm">
|
||||
<label className="label">
|
||||
{f.label}
|
||||
{f.required ? " *" : ""}{" "}
|
||||
{f.required ? " *" : ""}
|
||||
</label>
|
||||
{f.type === "select" ? (
|
||||
<select
|
||||
className="select"
|
||||
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
@@ -427,6 +553,7 @@ function DeviceForm({
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
||||
placeholder={f.help}
|
||||
@@ -437,82 +564,272 @@ function DeviceForm({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</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). */}
|
||||
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<button type="button" onClick={test} disabled={testing}>
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
||||
{testing ? t("setup.testing") : t("setup.test")}
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save & configure"}
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save} disabled={saving}>
|
||||
{saving ? t("setup.saving") : editing ? t("setup.saveChanges") : t("setup.saveConfigure")}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onCancel} disabled={saving}>
|
||||
{t("setup.cancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>}
|
||||
{testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
||||
{tested && (
|
||||
<div style={{ margin: "0.5rem 0 0" }}>
|
||||
<div>
|
||||
Device: <HealthBadge status={tested.health.status} />
|
||||
{tested.health.detail && <span style={{ color: "#666" }}> — {tested.health.detail}</span>}
|
||||
<div className="mt-2 text-[12px]">
|
||||
<div className="text-term-text">
|
||||
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
|
||||
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
|
||||
</div>
|
||||
{tested.preconditions.ok ? (
|
||||
<div style={{ color: "#16a34a" }}>● preconditions OK</div>
|
||||
<div className="text-term-green">{t("setup.preconditionsOk")}</div>
|
||||
) : (
|
||||
tested.preconditions.issues.map((i) => (
|
||||
<div key={i.key} style={{ color: "#d97706" }}>
|
||||
<div key={i.key} className="text-term-amber">
|
||||
⚠ {i.message}
|
||||
{i.fixable && <span style={{ color: "#666" }}> (auto-fixed on save)</span>}
|
||||
{i.fixable && <span className="text-term-muted"> {t("setup.autoFixedOnSave")}</span>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div style={{ margin: "0.5rem 0 0" }}>
|
||||
<label>
|
||||
Backend push IP{" "}
|
||||
<select value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
|
||||
<div className="mt-3">
|
||||
<div className="field max-w-md">
|
||||
<label className="label">{t("setup.backendPushIp")}</label>
|
||||
<select className="select" value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
|
||||
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||
<option value="" disabled>
|
||||
Choose an address…
|
||||
{t("setup.chooseAddress")}
|
||||
</option>
|
||||
)}
|
||||
{backendIps.map((c) => (
|
||||
<option key={c.ip} value={c.ip}>
|
||||
{c.ip} ({c.iface}){c.onDeviceSubnet ? " — on device subnet" : ""}
|
||||
{c.ip} ({c.iface}){c.onDeviceSubnet ? ` ${t("setup.onDeviceSubnet")}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||
<span style={{ marginLeft: 8, color: "#d97706" }}>
|
||||
⚠ no NIC on the device's subnet — the device may not reach the backend
|
||||
</span>
|
||||
<span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
||||
)}
|
||||
<p style={{ margin: "0.25rem 0 0", color: "#666", fontSize: "0.85em" }}>
|
||||
The address this device will POST input events to.
|
||||
</p>
|
||||
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
|
||||
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthBadge({ status }: { status: string }) {
|
||||
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
||||
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
||||
/** 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 }) {
|
||||
const { t } = useTranslation();
|
||||
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 className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
|
||||
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
|
||||
{relays.map((r, i) => (
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.relay}
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
||||
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{t(DIRECTION_KEYS[d])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(r.direction === "entry" || r.direction === "both") && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.entryButtonTerminal")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.button ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{(r.direction === "entry" || r.direction === "both") && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
|
||||
{t("setup.presenceInput")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.presenceInput ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) =>
|
||||
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||
{t("setup.entryCooldown")}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={r.entryCooldownSec ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) =>
|
||||
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{relays.length > 1 && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
||||
{t("setup.addRelay")}
|
||||
</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 { t } = useTranslation();
|
||||
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 className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.controller")}
|
||||
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||
<option value="" disabled>
|
||||
{t("setup.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 className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={relay === "" ? "" : String(relay)}
|
||||
disabled={!controller}
|
||||
onChange={(e) => onRelayChange(Number(e.target.value))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("setup.choose")}
|
||||
</option>
|
||||
{relays.map((r) => (
|
||||
<option key={r.relay} value={r.relay}>
|
||||
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
||||
</div>
|
||||
{controller && relays.length === 0 && (
|
||||
<p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
||||
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette.
|
||||
const cls =
|
||||
direction === "entry"
|
||||
? "border-term-green text-term-green"
|
||||
: direction === "exit"
|
||||
? "border-term-amber text-term-amber"
|
||||
: "border-term-muted text-term-muted";
|
||||
return (
|
||||
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
|
||||
{label ?? direction}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthBadge({ status }: { status: string }) {
|
||||
const cls = status === "ready" ? "text-term-green" : status === "degraded" ? "text-term-amber" : "text-term-red";
|
||||
return <span className={`font-semibold ${cls}`}>● {status}</span>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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 className="card mt-6 max-w-md p-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[13px]">
|
||||
<strong className="uppercase tracking-wider text-term-muted">{t("shift.label")}</strong>
|
||||
{startedAt ? (
|
||||
<>
|
||||
<span className="font-semibold text-term-green">{t("shift.open")}</span>
|
||||
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
|
||||
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-term-muted">{t("shift.notStarted")}</span>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Live drawer balance (what's in the till right now / inherited). */}
|
||||
{drawerMinor != null && (
|
||||
<div className="mt-2 text-[12px] text-term-text">
|
||||
{t("shift.drawer")} <strong className="tabular-nums">{money(drawerMinor, currency)}</strong>
|
||||
{startedAt && <span className="text-term-muted"> {t("shift.openingFloatInherited")}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
||||
|
||||
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
|
||||
{isAdmin && (
|
||||
<div className="mt-4 border-t border-term-border pt-3">
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{t("shift.drawerCashAdmin")}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-28"
|
||||
value={moveAmount}
|
||||
onChange={(e) => setMoveAmount(e.target.value)}
|
||||
placeholder={t("shift.amount")}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<input
|
||||
className="input min-w-36 flex-1"
|
||||
value={moveReason}
|
||||
onChange={(e) => setMoveReason(e.target.value)}
|
||||
placeholder={t("shift.reasonPlaceholder")}
|
||||
/>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => move(1)}>{t("shift.load")}</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => move(-1)}>{t("shift.remove")}</button>
|
||||
</div>
|
||||
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
|
||||
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
||||
<div className="text-term-text">{t("shift.payments")} {report.paymentCount}</div>
|
||||
<div className="text-term-text">{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
|
||||
<div className="mt-2 text-[11px] uppercase tracking-wider text-term-muted">{t("shift.drawerSection")}</div>
|
||||
<div className="text-term-text">{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
|
||||
<div className="font-semibold text-term-text">
|
||||
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
|
||||
</div>
|
||||
<div className={report.printed ? "mt-1 text-term-green" : "mt-1 text-term-amber"}>
|
||||
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchShifts, type ShiftSummary, type SessionUser } from "./api.js";
|
||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
|
||||
// Completed shift history. Scope is enforced SERVER-SIDE by permission: an operator
|
||||
// gets only their own shifts; an admin (shift:cash) gets all + a date/operator
|
||||
// filter. The screen mirrors that — it shows the filter only when the server
|
||||
// reports scope:"all". Each row is one signed shift_z_report; expanding it shows the
|
||||
// drawer reconciliation. See wiki/concepts/shift.md.
|
||||
|
||||
function money(minor: number, currency: string | null): string {
|
||||
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
||||
}
|
||||
|
||||
export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
||||
const { t } = useTranslation();
|
||||
// Admin filter inputs (only sent when the server grants the "all" scope; for an
|
||||
// operator the server ignores them anyway).
|
||||
const [operator, setOperator] = useState("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
// The applied filter (separate from the inputs, so typing doesn't refetch).
|
||||
const [applied, setApplied] = useState<{ operator?: string; from?: string; to?: string }>({});
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ["shifts", applied],
|
||||
queryFn: () => fetchShifts(applied),
|
||||
});
|
||||
|
||||
const isAdmin = q.data?.scope === "all";
|
||||
const shifts = q.data?.shifts ?? [];
|
||||
|
||||
function apply() {
|
||||
setApplied({
|
||||
operator: operator.trim() || undefined,
|
||||
// A date input gives yyyy-mm-dd; widen `to` to the end of that day.
|
||||
from: from ? new Date(`${from}T00:00:00`).toISOString() : undefined,
|
||||
to: to ? new Date(`${to}T23:59:59`).toISOString() : undefined,
|
||||
});
|
||||
}
|
||||
function clear() {
|
||||
setOperator("");
|
||||
setFrom("");
|
||||
setTo("");
|
||||
setApplied({});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
||||
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Admin-only filter: by operator + a date window over the shift start. */}
|
||||
{isAdmin && (
|
||||
<div className="card mb-3 flex flex-wrap items-end gap-3 p-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("shifts.operator")}</span>
|
||||
<input
|
||||
className="input w-44"
|
||||
value={operator}
|
||||
onChange={(e) => setOperator(e.target.value)}
|
||||
placeholder={t("shifts.allOperators")}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("shifts.filterFrom")}</span>
|
||||
<input type="date" className="input w-44" value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("shifts.filterTo")}</span>
|
||||
<input type="date" className="input w-44" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={apply}>
|
||||
{t("shifts.apply")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={clear}>
|
||||
{t("shifts.clear")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.isError && (
|
||||
<div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">
|
||||
{t("shifts.loadFailed")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-term border border-term-border">
|
||||
<table className="w-full text-[12px] tabular-nums">
|
||||
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
{isAdmin && <th className="px-3 py-1.5 text-left">{t("shifts.operator")}</th>}
|
||||
<th className="px-3 py-1.5 text-left">{t("shifts.started")}</th>
|
||||
<th className="px-3 py-1.5 text-left">{t("shifts.ended")}</th>
|
||||
<th className="px-3 py-1.5 text-right">{t("shifts.payments")}</th>
|
||||
<th className="px-3 py-1.5 text-right">{t("shifts.cash")}</th>
|
||||
<th className="px-3 py-1.5 text-right">{t("shifts.card")}</th>
|
||||
<th className="px-3 py-1.5 text-right">{t("shifts.expectedDrawer")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shifts.map((s) => (
|
||||
<ShiftRow key={s.id} s={s} showOperator={isAdmin} colSpan={isAdmin ? 7 : 6} />
|
||||
))}
|
||||
{!q.isLoading && shifts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={isAdmin ? 7 : 6} className="px-3 py-3 text-term-muted">
|
||||
{t("shifts.none")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator: boolean; colSpan: number }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const cur = s.currency;
|
||||
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="cursor-pointer border-t border-term-border hover:bg-term-panel-2"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{showOperator && <td className="px-3 py-1.5 text-term-text">{s.operator}</td>}
|
||||
<td className="px-3 py-1.5">{when(s.startedAt)}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{when(s.endedAt)}
|
||||
<span className="ml-2 text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{s.paymentCount}</td>
|
||||
<td className="px-3 py-1.5 text-right text-term-green">{money(s.cashTotalMinor, cur)}</td>
|
||||
<td className="px-3 py-1.5 text-right text-term-cyan">{money(s.cardTotalMinor, cur)}</td>
|
||||
<td className="px-3 py-1.5 text-right font-semibold">{money(s.expectedDrawerMinor, cur)}</td>
|
||||
</tr>
|
||||
{open && (
|
||||
<tr className="border-t border-term-border/50 bg-term-bg">
|
||||
<td colSpan={colSpan} className="px-3 py-2">
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("shifts.drawerSection")}</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-8 gap-y-0.5 sm:grid-cols-4">
|
||||
<Figure label={t("shifts.openingFloat")} value={money(s.openingFloatMinor, cur)} />
|
||||
<Figure label={t("shifts.cashTaken")} value={money(s.cashTotalMinor, cur)} />
|
||||
<Figure label={t("shifts.cashAdded")} value={money(s.cashAddedMinor, cur)} />
|
||||
<Figure label={t("shifts.cashRemoved")} value={money(s.cashRemovedMinor, cur)} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-term-muted">{label}</span>
|
||||
<span className="text-term-text">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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 className="card mt-6 max-w-md p-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[13px]">
|
||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||
{occ == null ? (
|
||||
<span className="text-term-muted">…</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-h5 font-semibold tabular-nums text-term-text">{occ.count}</span>
|
||||
<span className="tabular-nums text-term-muted">
|
||||
{occ.capacity != null ? `/ ${occ.capacity}` : t("site.noCapacitySet")}
|
||||
</span>
|
||||
{occ.capacity != null && (
|
||||
<span className="tabular-nums text-term-muted">· {occ.free} {t("site.free")}</span>
|
||||
)}
|
||||
{occ.full && <span className="font-semibold text-term-red">{t("site.full")}</span>}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={reload}>↻</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="mt-4 grid gap-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("site.capacityLabel")}</span>
|
||||
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={exitVoucherDefault}
|
||||
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
||||
/>
|
||||
{t("site.printExitDefault")}
|
||||
<span className="hint">{t("site.printExitHint")}</span>
|
||||
</label>
|
||||
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{t("site.parkDetails")}
|
||||
</div>
|
||||
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||
<div key={key} className="field">
|
||||
<span className="label">{t(labelKey)}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={meta[key] ?? ""}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder={phKey ? t(phKey) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
value={meta[key] ?? ""}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||
placeholder={phKey ? t(phKey) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
||||
{msg && <span className="text-[12px] text-term-muted">{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
armCapture,
|
||||
cancelCapture,
|
||||
createSubscription,
|
||||
deleteSubscription,
|
||||
fetchReaders,
|
||||
fetchSiteConfig,
|
||||
fetchSubscriptions,
|
||||
pollCapture,
|
||||
printSubscription,
|
||||
revokeSubscription,
|
||||
updateSubscription,
|
||||
type ReaderInfo,
|
||||
type Subscription,
|
||||
type SubscriptionCredential,
|
||||
type SubscriptionInput,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
|
||||
// subscription is mutable master data; every USE of it is a signed ledger event
|
||||
// elsewhere. See wiki/entities/subscription.md.
|
||||
|
||||
const DEFAULT_CURRENCY = "ALL";
|
||||
|
||||
interface FormState {
|
||||
holderName: string;
|
||||
contact: string;
|
||||
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
|
||||
currency: string;
|
||||
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||
maxConcurrent: string;
|
||||
validFrom: string;
|
||||
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
|
||||
validTo: string;
|
||||
credentials: SubscriptionCredential[];
|
||||
platesText: string; // comma/space separated
|
||||
}
|
||||
|
||||
/** Today (UTC date, yyyy-mm-dd) for a sensible default validFrom on new subs. */
|
||||
function todayISODate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
|
||||
return {
|
||||
holderName: "",
|
||||
contact: "",
|
||||
priceMajor: defaultPriceMajor,
|
||||
currency,
|
||||
carBound: true,
|
||||
maxConcurrent: "1",
|
||||
validFrom: todayISODate(),
|
||||
months: "1",
|
||||
validTo: "",
|
||||
credentials: [{ kind: "qr", value: "" }],
|
||||
platesText: "",
|
||||
};
|
||||
}
|
||||
function formFrom(s: Subscription): FormState {
|
||||
return {
|
||||
holderName: s.holderName ?? "",
|
||||
contact: s.contact ?? "",
|
||||
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
|
||||
currency: s.currency ?? DEFAULT_CURRENCY,
|
||||
carBound: s.maxConcurrent != null,
|
||||
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
|
||||
validFrom: s.validFrom ?? "",
|
||||
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
|
||||
validTo: s.validTo ?? "",
|
||||
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
|
||||
platesText: s.plates.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
|
||||
* server's addMonths so the form can preview the coverage end. */
|
||||
function addMonthsDate(date: string, months: number): string | null {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const day = d.getUTCDate();
|
||||
d.setUTCMonth(d.getUTCMonth() + months);
|
||||
if (d.getUTCDate() < day) d.setUTCDate(0);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
const STATUS_KEY: Record<Subscription["status"], string> = {
|
||||
active: "subs.statusActive",
|
||||
suspended: "subs.statusSuspended",
|
||||
revoked: "subs.statusRevoked",
|
||||
};
|
||||
|
||||
function toInput(f: FormState): SubscriptionInput {
|
||||
const major = Number(f.priceMajor);
|
||||
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
|
||||
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
|
||||
return {
|
||||
holderName: f.holderName.trim() || null,
|
||||
contact: f.contact.trim() || null,
|
||||
priceMinor: priceSet ? Math.round(major * 100) : null,
|
||||
period: "monthly",
|
||||
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
|
||||
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
||||
validFrom: f.validFrom.trim() || null,
|
||||
// months (with validFrom) drives validTo server-side; else send the explicit end.
|
||||
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
|
||||
validTo: f.validTo.trim() || null,
|
||||
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
|
||||
// server auto-generates the code. RF (and pre-existing QR) keep their value.
|
||||
credentials: f.credentials
|
||||
.filter((c) => c.kind === "qr" || c.value.trim())
|
||||
.map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })),
|
||||
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function priceLabel(s: Subscription, t: (k: string) => string): string {
|
||||
if (s.priceMinor == null) return t("subs.noPrice");
|
||||
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
|
||||
}
|
||||
|
||||
export function SubscriptionManager() {
|
||||
const { t } = useTranslation();
|
||||
const [subs, setSubs] = useState<Subscription[] | null>(null);
|
||||
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
const [form, setForm] = useState<FormState>(() => emptyForm());
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
// Credential capture ("Read card"): which credential index is being captured, the
|
||||
// reader picker list, and a live status line. null = no capture in progress.
|
||||
const [capture, setCapture] = useState<{ credIndex: number; phase: "pick" | "waiting"; status?: string } | null>(null);
|
||||
const [readers, setReaders] = useState<ReaderInfo[]>([]);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
function reload() {
|
||||
fetchSubscriptions()
|
||||
.then((r) => setSubs(r.subscriptions))
|
||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
}
|
||||
useEffect(() => {
|
||||
reload();
|
||||
// Pull the site default monthly price to pre-fill new subscriptions.
|
||||
fetchSiteConfig()
|
||||
.then((c) => {
|
||||
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
|
||||
})
|
||||
.catch(() => {
|
||||
/* non-fatal — the form just won't pre-fill */
|
||||
});
|
||||
}, []);
|
||||
|
||||
function startNew() {
|
||||
setForm(emptyForm(defaultPriceMajor));
|
||||
setEditing("new");
|
||||
setMsg(null);
|
||||
}
|
||||
function startEdit(s: Subscription) {
|
||||
setForm(formFrom(s));
|
||||
setEditing(s.id);
|
||||
setMsg(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
if (editing === "new") {
|
||||
const created = await createSubscription(toInput(form));
|
||||
setEditing(null);
|
||||
reload();
|
||||
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
|
||||
// operator can use "Print code" to retry).
|
||||
if (created.printed) {
|
||||
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
|
||||
} else if (created.printError) {
|
||||
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
|
||||
} else {
|
||||
setMsg({ kind: "ok", text: t("subs.saved") });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (editing) await updateSubscription(editing, toInput(form));
|
||||
setEditing(null);
|
||||
reload();
|
||||
setMsg({ kind: "ok", text: t("subs.saved") });
|
||||
} catch (e) {
|
||||
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||||
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||
}
|
||||
}
|
||||
async function doPrint(s: Subscription) {
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await printSubscription(s.id);
|
||||
setMsg({ kind: "ok", text: t("subs.printedOn", { printer: r.printedBy }) });
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
async function doRevoke(s: Subscription) {
|
||||
if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return;
|
||||
await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
async function doDelete(s: Subscription) {
|
||||
if (!confirm(t("subs.confirmDelete", { name: s.holderName ?? s.id }))) return;
|
||||
await deleteSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
|
||||
function setCred(i: number, patch: Partial<SubscriptionCredential>) {
|
||||
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
||||
}
|
||||
|
||||
function clearPoll() {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
// Stop a capture in progress (cancel on the server + clear local state).
|
||||
function stopCapture() {
|
||||
clearPoll();
|
||||
void cancelCapture().catch(() => {});
|
||||
setCapture(null);
|
||||
}
|
||||
// "Read card" on credential i → load readers + show the picker.
|
||||
async function startCapture(i: number) {
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await fetchReaders();
|
||||
setReaders(r.readers);
|
||||
setCapture({ credIndex: i, phase: "pick" });
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
// Operator picked a reader → arm it and poll until captured / expired.
|
||||
async function pickReader(deviceId: string) {
|
||||
const cap = capture;
|
||||
if (!cap) return;
|
||||
try {
|
||||
await armCapture(deviceId);
|
||||
setCapture({ ...cap, phase: "waiting", status: t("subs.captureWaiting") });
|
||||
clearPoll();
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const st = await pollCapture();
|
||||
if (st.status === "captured") {
|
||||
clearPoll();
|
||||
setCred(cap.credIndex, { value: st.value });
|
||||
void cancelCapture().catch(() => {}); // clear the server-side result
|
||||
setCapture(null);
|
||||
setMsg({ kind: "ok", text: t("subs.captured", { value: st.value }) });
|
||||
} else if (st.status === "expired" || st.status === "idle") {
|
||||
clearPoll();
|
||||
setCapture({ ...cap, phase: "waiting", status: t("subs.captureTimeout") });
|
||||
}
|
||||
} catch {
|
||||
/* transient poll error — keep polling */
|
||||
}
|
||||
}, 700);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
setCapture(null);
|
||||
}
|
||||
}
|
||||
// Stop polling if the form closes or the component unmounts.
|
||||
useEffect(() => clearPoll, []);
|
||||
|
||||
// Live coverage preview: when months + validFrom are set, show the end date and
|
||||
// (if priced) the N×monthly total the operator should collect.
|
||||
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
|
||||
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
|
||||
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
|
||||
const totalDue =
|
||||
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
|
||||
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
|
||||
: null;
|
||||
const coverageHint = coverageEnd
|
||||
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
|
||||
: null;
|
||||
|
||||
if (!subs) return null;
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
||||
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
||||
<ul className="mb-3 list-none p-0">
|
||||
{subs.map((s) => (
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]">
|
||||
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
|
||||
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
|
||||
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
|
||||
<span className="text-term-muted">
|
||||
{s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "}
|
||||
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{/* Print code — only when the subscription has a QR credential to encode. */}
|
||||
{s.credentials.some((c) => c.kind === "qr") && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
|
||||
{s.status !== "revoked" && <button type="button" className="btn btn-ghost btn-sm" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
|
||||
</li>
|
||||
))}
|
||||
{subs.length === 0 && <li className="py-2 text-term-muted">{t("subs.noneYet")}</li>}
|
||||
</ul>
|
||||
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={startNew}>{t("subs.add")}</button>
|
||||
|
||||
<Modal
|
||||
open={editing != null}
|
||||
onClose={() => setEditing(null)}
|
||||
title={editing === "new" ? t("subs.new") : t("subs.editTitle")}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("subs.holderName")}</label>
|
||||
<input className="input" value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||
<label className="label">{t("subs.contact")}</label>
|
||||
<input className="input" value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||
<label className="label">{t("subs.monthlyPrice")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
className="input w-28"
|
||||
value={form.priceMajor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
|
||||
inputMode="decimal"
|
||||
placeholder={t("subs.pricePlaceholder")}
|
||||
/>
|
||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} />
|
||||
<span className="text-[12px] text-term-muted">/ {t("subs.perMonth")}</span>
|
||||
</span>
|
||||
<label className="label">{t("subs.carLimit")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
||||
</label>
|
||||
{form.carBound && (
|
||||
<input className="input w-16" value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} />
|
||||
)}
|
||||
</span>
|
||||
<label className="label">{t("subs.validFrom")}</label>
|
||||
<input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
|
||||
<label className="label">{t("subs.months")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-16"
|
||||
value={form.months}
|
||||
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
placeholder="1"
|
||||
/>
|
||||
<span className="text-[12px] text-term-muted">{t("subs.monthsHint")}</span>
|
||||
{/* Live preview of the coverage end + the N×price total. */}
|
||||
{coverageHint && <span className="text-[12px] text-term-cyan">{coverageHint}</span>}
|
||||
</span>
|
||||
<label className="label">{t("subs.validToOverride")}</label>
|
||||
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
|
||||
<label className="label">{t("subs.boundPlates")}</label>
|
||||
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||
</div>
|
||||
|
||||
<h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} className="mb-1.5 flex items-center gap-2">
|
||||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||
(read off a card via "Read card"). */}
|
||||
<select className="select input-sm w-auto" value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||
<option value="qr">{t("subs.qr")}</option>
|
||||
<option value="rf">{t("subs.rfCardTag")}</option>
|
||||
</select>
|
||||
{c.kind === "qr" ? (
|
||||
// QR codes are server-generated. Blank → "will be generated"; an
|
||||
// existing code is shown read-only (it can be printed; never typed).
|
||||
c.value.trim() ? (
|
||||
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
|
||||
) : (
|
||||
<span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span>
|
||||
)
|
||||
) : (
|
||||
// RFID: the value is read off a physical card (or typed). "Read card"
|
||||
// arms a chosen reader and fills the captured value.
|
||||
<input className="input input-sm flex-1" value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} />
|
||||
)}
|
||||
{c.kind === "rf" && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
|
||||
|
||||
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||||
the credential. The OTHER reader keeps serving the live flow. */}
|
||||
{capture && (
|
||||
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]">
|
||||
{capture.phase === "pick" ? (
|
||||
<>
|
||||
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{readers.length === 0 && <span className="text-term-red">{t("subs.captureNoReaders")}</span>}
|
||||
{readers.map((r) => (
|
||||
<button key={r.id} type="button" className="btn btn-pay btn-sm" onClick={() => pickReader(r.id)}>
|
||||
{t(`devices.role.${r.direction}`)} ({r.driverId})
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-term-text">{capture.status ?? t("subs.captureWaiting")}</span>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="hint mt-3">{t("subs.needCredentialOrPlate")}</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("subs.save")}</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
fetchTariff,
|
||||
isTariffV2,
|
||||
publishTariffVersion,
|
||||
type TariffBlock,
|
||||
type TariffCard,
|
||||
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.
|
||||
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
||||
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
||||
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
||||
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
||||
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
||||
interface BlockForm {
|
||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||
price: string; // major units, e.g. "2.00"
|
||||
}
|
||||
// A pricing body the form edits: either a flat rate or a block ladder.
|
||||
interface PricingForm {
|
||||
mode: "ladder" | "flat";
|
||||
flat: string; // major units (used when mode==="flat")
|
||||
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
||||
dailyCap: string; // "" = no cap (ladder only)
|
||||
}
|
||||
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
||||
interface TierForm {
|
||||
name: string;
|
||||
priority: string;
|
||||
category: string; // "" = applies to all categories
|
||||
dow: number[]; // selected days 0..6; empty = every day
|
||||
fromHour: string; // "" = all day
|
||||
toHour: string;
|
||||
dateFrom: string; // "" = unbounded
|
||||
dateTo: string;
|
||||
pricing: PricingForm;
|
||||
}
|
||||
interface FormState {
|
||||
currency: string;
|
||||
gracePeriodEntryMin: string;
|
||||
incrementMin: string;
|
||||
lostTicket: string;
|
||||
gracePeriodExitMin: string;
|
||||
// The default (always-active) card — its own flat/ladder body + daily cap.
|
||||
base: PricingForm;
|
||||
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
||||
tiers: TierForm[];
|
||||
}
|
||||
|
||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||
|
||||
function emptyLadder(): PricingForm {
|
||||
return { mode: "ladder", flat: "0.00", dailyCap: "", blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }] };
|
||||
}
|
||||
function emptyTier(): TierForm {
|
||||
return { name: "", priority: "10", category: "", dow: [], fromHour: "", toHour: "", dateFrom: "", dateTo: "", pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] } };
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
currency: "EUR",
|
||||
gracePeriodEntryMin: "15",
|
||||
incrementMin: "60",
|
||||
lostTicket: "20.00",
|
||||
gracePeriodExitMin: "15",
|
||||
base: emptyLadder(),
|
||||
tiers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
||||
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
||||
// load (shown as their own band).
|
||||
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
||||
let prev = 0;
|
||||
return blocks.map((b) => {
|
||||
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
||||
const hours = (b.uptoMin - prev) / 60;
|
||||
prev = b.uptoMin;
|
||||
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
||||
});
|
||||
}
|
||||
|
||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat or ladder).
|
||||
function pricingFromCard(c: { flatMinor?: number; blocks?: TariffBlock[]; dailyCapMinor?: number | null }): PricingForm {
|
||||
if (c.flatMinor != null) {
|
||||
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks };
|
||||
}
|
||||
return {
|
||||
mode: "ladder",
|
||||
flat: "0.00",
|
||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||
blocks: blocksToForm(c.blocks ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function tierFromCard(c: TariffCard): TierForm {
|
||||
const w = c.window ?? {};
|
||||
return {
|
||||
name: c.name,
|
||||
priority: String(c.priority),
|
||||
category: c.category ?? "",
|
||||
dow: w.dow ? [...w.dow] : [],
|
||||
fromHour: w.fromHour ?? "",
|
||||
toHour: w.toHour ?? "",
|
||||
dateFrom: w.dateFrom ?? "",
|
||||
dateTo: w.dateTo ?? "",
|
||||
pricing: pricingFromCard(c),
|
||||
};
|
||||
}
|
||||
|
||||
function formFromActive(s: TariffState): FormState {
|
||||
const v = s.active;
|
||||
if (!v) return emptyForm();
|
||||
const st = v.structure;
|
||||
const common = {
|
||||
currency: v.currency,
|
||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||
incrementMin: String(st.incrementMin),
|
||||
lostTicket: toMajor(st.lostTicketMinor),
|
||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||
};
|
||||
if (isTariffV2(st)) {
|
||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
||||
}
|
||||
// V1: the bare ladder becomes the default card body; no tiers.
|
||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
||||
}
|
||||
|
||||
// Build a tariff card's pricing body (flat XOR ladder) from a PricingForm.
|
||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "dailyCapMinor"> {
|
||||
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
||||
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
||||
const last = p.blocks.length - 1;
|
||||
let cum = 0;
|
||||
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||
cum += Math.round(Number(b.hours || "0") * 60);
|
||||
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
||||
});
|
||||
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
||||
}
|
||||
|
||||
function tierToCard(tr: TierForm): TariffCard {
|
||||
const window: TariffCard["window"] = {};
|
||||
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
||||
if (tr.fromHour && tr.toHour) {
|
||||
window.fromHour = tr.fromHour;
|
||||
window.toHour = tr.toHour;
|
||||
}
|
||||
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
||||
if (tr.dateTo) window.dateTo = tr.dateTo;
|
||||
const card: TariffCard = {
|
||||
name: tr.name.trim() || "tier",
|
||||
priority: Math.round(Number(tr.priority || "0")),
|
||||
...pricingToCardBody(tr.pricing),
|
||||
};
|
||||
if (tr.category.trim()) card.category = tr.category.trim();
|
||||
if (Object.keys(window).length > 0) card.window = window;
|
||||
return card;
|
||||
}
|
||||
|
||||
function toStructure(f: FormState): TariffStructure {
|
||||
const common = {
|
||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||
incrementMin: Math.round(Number(f.incrementMin)),
|
||||
lostTicketMinor: toMinor(f.lostTicket),
|
||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||
overstay: "reprice" as const,
|
||||
};
|
||||
const baseBody = pricingToCardBody(f.base);
|
||||
|
||||
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
||||
// tiers gets exactly today's shape; the server leaves it untouched).
|
||||
if (f.tiers.length === 0) {
|
||||
if (f.base.mode === "flat") {
|
||||
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
||||
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
||||
}
|
||||
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
||||
}
|
||||
|
||||
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
||||
return {
|
||||
...common,
|
||||
version: 2,
|
||||
tz: "",
|
||||
defaultCard: { name: "default", priority: 0, ...baseBody },
|
||||
windowedCards: f.tiers.map(tierToCard),
|
||||
};
|
||||
}
|
||||
|
||||
export function TariffComposer() {
|
||||
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 }));
|
||||
}
|
||||
|
||||
// --- pricing-body editing (used by the default card AND each tier) ---
|
||||
// `update` maps the old PricingForm to a new one; `target` selects which body:
|
||||
// the base card, or tier index N.
|
||||
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
|
||||
setForm((f) => {
|
||||
if (target === "base") return { ...f, base: update(f.base) };
|
||||
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
|
||||
});
|
||||
}
|
||||
function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
|
||||
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
|
||||
function addBlock(target: "base" | number) {
|
||||
updatePricing(target, (p) => {
|
||||
const next = [...p.blocks];
|
||||
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
|
||||
return { ...p, blocks: next };
|
||||
});
|
||||
}
|
||||
function removeBlock(target: "base" | number, i: number) {
|
||||
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
||||
}
|
||||
|
||||
// --- tier editing ---
|
||||
function setTier(i: number, patch: Partial<TierForm>) {
|
||||
setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||
}
|
||||
function addTier() {
|
||||
setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
||||
}
|
||||
function removeTier(i: number) {
|
||||
setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||
}
|
||||
function toggleDow(i: number, d: number) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.map((tr, j) =>
|
||||
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
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 className="mx-auto max-w-3xl px-4 py-6">
|
||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
||||
{!state?.active ? (
|
||||
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
|
||||
{t("tariff.noRateCard")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 text-[12px] text-term-muted">
|
||||
{t("tariff.activeSince", {
|
||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||
count: state.versions.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("tariff.currency")}</label>
|
||||
<input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} />
|
||||
<label className="label">{t("tariff.freeEntryGrace")}</label>
|
||||
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label className="label">{t("tariff.billingIncrement")}</label>
|
||||
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label className="label">{t("tariff.lostTicketFee")}</label>
|
||||
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label className="label">{t("tariff.exitGrace")}</label>
|
||||
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
|
||||
<div className="card card-body">
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={form.base}
|
||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||
onAddBlock={() => addBlock("base")}
|
||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
||||
<details className="mt-6" open={form.tiers.length > 0}>
|
||||
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
||||
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
||||
{form.tiers.map((tr, i) => (
|
||||
<fieldset key={i} className="card mb-3 p-4">
|
||||
<legend className="flex items-center gap-2 px-1">
|
||||
<input
|
||||
className="input w-40"
|
||||
value={tr.name}
|
||||
onChange={(e) => setTier(i, { name: e.target.value })}
|
||||
placeholder={t("tariff.tierName")}
|
||||
/>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
</legend>
|
||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("tariff.tierPriority")}</label>
|
||||
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
|
||||
<label className="label">{t("tariff.tierCategory")}</label>
|
||||
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
|
||||
<label className="label">{t("tariff.tierDays")}</label>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||
{t(`tariff.dow${d}`)}
|
||||
</label>
|
||||
))}
|
||||
</span>
|
||||
<label className="label">{t("tariff.tierHours")}</label>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
|
||||
<span className="text-term-muted">–</span>
|
||||
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||
)}
|
||||
</span>
|
||||
<label className="label">{t("tariff.tierDates")}</label>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
||||
<span className="text-term-muted">–</span>
|
||||
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-term-border pt-3">
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={tr.pricing}
|
||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(bi, patch) => setBlock(i, bi, patch)}
|
||||
onAddBlock={() => addBlock(i)}
|
||||
onRemoveBlock={(bi) => removeBlock(i, bi)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm" onClick={addTier}>
|
||||
{t("tariff.addTier")}
|
||||
</button>
|
||||
</details>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||
</button>
|
||||
{msg && (
|
||||
<span className={msg.kind === "ok" ? "text-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A reusable flat/ladder pricing-body editor — used by the default card and each tier.
|
||||
function PricingEditor(props: {
|
||||
t: (k: string) => string;
|
||||
pricing: PricingForm;
|
||||
onMode: (m: "ladder" | "flat") => void;
|
||||
onFlat: (v: string) => void;
|
||||
onCap: (v: string) => void;
|
||||
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
||||
onAddBlock: () => void;
|
||||
onRemoveBlock: (i: number) => void;
|
||||
}) {
|
||||
const { t, pricing: p } = props;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex gap-4 text-[12px]">
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||
{t("tariff.modeLadder")}
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||
{t("tariff.modeFlat")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{p.mode === "flat" ? (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.blocks.map((b, i) => {
|
||||
const isTail = i === p.blocks.length - 1;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1">
|
||||
{isTail ? (
|
||||
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{!isTail && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.dailyCap")}</span>
|
||||
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiError,
|
||||
can,
|
||||
createUser,
|
||||
deleteUser,
|
||||
fetchRoles,
|
||||
fetchUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
type ManagedRole,
|
||||
type ManagedUser,
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// User management (admin). List users, create one (username + password + role),
|
||||
// change a user's role, reset a password, delete. The server enforces the same
|
||||
// permissions and the no-lockout rule (the last admin can't be removed). See
|
||||
// wiki/entities/local-jwt-auth.md.
|
||||
|
||||
export function UsersManager({ user }: { user: SessionUser | null }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const usersQ = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
|
||||
const rolesQ = useQuery({ queryKey: ["roles"], queryFn: fetchRoles });
|
||||
|
||||
const canCreate = can(user, "user:create");
|
||||
const canUpdate = can(user, "user:update");
|
||||
const canDelete = can(user, "user:delete");
|
||||
|
||||
const roles: ManagedRole[] = rolesQ.data?.roles ?? [];
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||
|
||||
const invalidate = () => void qc.invalidateQueries({ queryKey: ["users"] });
|
||||
const onError = (e: unknown) =>
|
||||
setError(e instanceof ApiError ? e.message : (e as Error).message);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("users.title")}</h1>
|
||||
{canCreate && roles.length > 0 && (
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => { setAdding(true); setError(null); }}>
|
||||
{t("users.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
||||
|
||||
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
|
||||
<UserForm
|
||||
roles={roles}
|
||||
onCancel={() => setAdding(false)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
await createUser({
|
||||
username: v.username,
|
||||
password: v.password!,
|
||||
roleId: v.roleId,
|
||||
fullName: v.fullName,
|
||||
phone: v.phone,
|
||||
email: v.email,
|
||||
address: v.address,
|
||||
});
|
||||
setAdding(false);
|
||||
invalidate();
|
||||
} catch (e) { onError(e); }
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal open={editingUser != null} onClose={() => setEditingUser(null)} title={t("users.editTitle")} width="max-w-2xl">
|
||||
{editingUser && (
|
||||
<UserForm
|
||||
roles={roles}
|
||||
editing={editingUser}
|
||||
onCancel={() => setEditingUser(null)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
await updateUser(editingUser.id, {
|
||||
username: v.username,
|
||||
roleId: v.roleId,
|
||||
fullName: v.fullName,
|
||||
phone: v.phone,
|
||||
email: v.email,
|
||||
address: v.address,
|
||||
});
|
||||
setEditingUser(null);
|
||||
invalidate();
|
||||
} catch (e) { onError(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<div className="overflow-hidden rounded-term border border-term-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-1.5 text-left">{t("users.username")}</th>
|
||||
<th className="px-3 py-1.5 text-left">{t("users.role")}</th>
|
||||
<th className="px-3 py-1.5 text-right">{t("common.none")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(usersQ.data?.users ?? []).map((u) => (
|
||||
<UserRow
|
||||
key={u.id}
|
||||
u={u}
|
||||
roles={roles}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
onEdit={() => { setEditingUser(u); setError(null); }}
|
||||
onChanged={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{usersQ.data?.users.length === 0 && (
|
||||
<tr><td colSpan={3} className="px-3 py-3 text-term-muted">{t("users.none")}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
u, roles, canUpdate, canDelete, onEdit, onChanged, onError,
|
||||
}: {
|
||||
u: ManagedUser;
|
||||
roles: ManagedRole[];
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
onEdit: () => void;
|
||||
onChanged: () => void;
|
||||
onError: (e: unknown) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [pw, setPw] = useState("");
|
||||
|
||||
const roleMut = useMutation({
|
||||
mutationFn: (roleId: string) => updateUser(u.id, { roleId }),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
});
|
||||
const pwMut = useMutation({
|
||||
mutationFn: () => resetUserPassword(u.id, pw),
|
||||
onSuccess: () => { setResetting(false); setPw(""); },
|
||||
onError,
|
||||
});
|
||||
const delMut = useMutation({
|
||||
mutationFn: () => deleteUser(u.id),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
});
|
||||
|
||||
return (
|
||||
<tr className="border-t border-term-border">
|
||||
<td className="px-3 py-1.5">
|
||||
{u.username}
|
||||
{u.fullName && <span className="ml-2 text-term-muted">{u.fullName}</span>}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{canUpdate ? (
|
||||
<select
|
||||
value={u.roleId}
|
||||
onChange={(e) => roleMut.mutate(e.target.value)}
|
||||
className="select input-sm w-auto"
|
||||
>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
u.roleName
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{canUpdate && !resetting && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onEdit}>
|
||||
{t("users.edit")}
|
||||
</button>
|
||||
)}
|
||||
{canUpdate && !resetting && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setResetting(true)}>
|
||||
{t("users.resetPassword")}
|
||||
</button>
|
||||
)}
|
||||
{canUpdate && resetting && (
|
||||
<span className="flex items-center gap-1">
|
||||
<input
|
||||
type="password" value={pw} autoFocus
|
||||
onChange={(e) => setPw(e.target.value)}
|
||||
placeholder={t("users.newPassword")}
|
||||
className="input input-sm w-32"
|
||||
/>
|
||||
<button type="button" className="btn btn-go btn-sm" disabled={pw.length < 8 || pwMut.isPending} onClick={() => pwMut.mutate()}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setResetting(false); setPw(""); }}>✕</button>
|
||||
</span>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => { if (confirm(t("users.confirmDelete", { name: u.username }))) delMut.mutate(); }}
|
||||
>
|
||||
{t("users.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** Submitted form value. `password` is omitted entirely on edit (a blank field must
|
||||
* not blank the password — that's the separate "reset password" flow). */
|
||||
interface UserFormValue {
|
||||
username: string;
|
||||
password?: string;
|
||||
roleId: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
function UserForm({
|
||||
roles, editing, onCancel, onSubmit,
|
||||
}: {
|
||||
roles: ManagedRole[];
|
||||
/** When set, the form edits this user (username/role/details; NOT the password). */
|
||||
editing?: ManagedUser;
|
||||
onCancel: () => void;
|
||||
onSubmit: (v: UserFormValue) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = editing != null;
|
||||
const [username, setUsername] = useState(editing?.username ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [roleId, setRoleId] = useState(editing?.roleId ?? roles[0]?.id ?? "");
|
||||
const [fullName, setFullName] = useState(editing?.fullName ?? "");
|
||||
const [phone, setPhone] = useState(editing?.phone ?? "");
|
||||
const [email, setEmail] = useState(editing?.email ?? "");
|
||||
const [address, setAddress] = useState(editing?.address ?? "");
|
||||
|
||||
// On create, a >=8 char password is required; on edit it's left untouched.
|
||||
const valid = username.trim().length > 0 && roleId && (isEdit || password.length >= 8);
|
||||
|
||||
function submit() {
|
||||
onSubmit({
|
||||
username: username.trim(),
|
||||
...(isEdit ? {} : { password }),
|
||||
roleId,
|
||||
fullName,
|
||||
phone,
|
||||
email,
|
||||
address,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("users.username")}</span>
|
||||
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div className="field">
|
||||
<span className="label">{t("users.password")}</span>
|
||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<span className="label">{t("users.role")}</span>
|
||||
<select className="select" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
|
||||
|
||||
{/* Optional profile metadata. */}
|
||||
<div className="mt-4 mb-2 text-[11px] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("users.fullName")}</span>
|
||||
<input className="input" value={fullName} onChange={(e) => setFullName(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("users.phone")}</span>
|
||||
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("users.email")}</span>
|
||||
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("users.address")}</span>
|
||||
<input className="input" value={address} onChange={(e) => setAddress(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={submit}>{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+622
-6
@@ -5,6 +5,9 @@
|
||||
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
||||
// wiki/entities/local-jwt-auth.md.
|
||||
|
||||
import { logFailedRequest } from "./lib/logger.js";
|
||||
import type { AppLogRecord } from "@parking/shared";
|
||||
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
const CSRF_HEADER = "X-CSRF-Token";
|
||||
|
||||
@@ -27,7 +30,14 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
const res = await fetch(path, { ...init, headers, credentials: "include" });
|
||||
if (!res.ok) {
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new ApiError(msg.error ?? `${path}: ${res.status}`, res.status);
|
||||
const error = msg.error ?? `${path}: ${res.status}`;
|
||||
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
||||
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
||||
// so we don't report them as errors. See lib/logger.ts.
|
||||
if (res.status !== 401) {
|
||||
logFailedRequest({ path, method, status: res.status, error });
|
||||
}
|
||||
throw new ApiError(error, res.status);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
@@ -44,11 +54,29 @@ export class ApiError extends Error {
|
||||
|
||||
// --- Auth -----------------------------------------------------------------
|
||||
|
||||
export type Role = "admin" | "operator" | "cashier" | "readonly";
|
||||
export type Lang = "sq" | "en";
|
||||
export type Theme = "dark" | "light";
|
||||
/** A `resource:action` permission string (the server is the source of truth for
|
||||
* the full grid; the role composer fetches it via /api/roles). */
|
||||
export type Permission = string;
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
roleId: string;
|
||||
roleName: string;
|
||||
/** The permissions this user's role grants — the UI gates nav/routes on these. */
|
||||
permissions: Permission[];
|
||||
/** Preferred UI language (loaded from the server on login). */
|
||||
language: Lang;
|
||||
/** Preferred UI theme (loaded from the server on login). */
|
||||
theme: Theme;
|
||||
/** Optional display name (profile metadata); null if unset. */
|
||||
fullName: string | null;
|
||||
}
|
||||
|
||||
/** Does this session grant the permission? Central authz check for the SPA. */
|
||||
export function can(user: SessionUser | null, perm: Permission): boolean {
|
||||
return !!user && user.permissions.includes(perm);
|
||||
}
|
||||
|
||||
export function login(username: string, password: string): Promise<SessionUser> {
|
||||
@@ -62,6 +90,16 @@ export function logout(): Promise<{ ok: boolean }> {
|
||||
return apiFetch("/api/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
/** Persist the current user's UI language preference (restored on next login). */
|
||||
export function setLanguagePref(language: Lang): Promise<{ language: Lang }> {
|
||||
return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) });
|
||||
}
|
||||
|
||||
/** Persist the current user's UI theme preference (restored on next login). */
|
||||
export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
|
||||
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
|
||||
}
|
||||
|
||||
/** Returns the current user, or null if not authenticated. */
|
||||
export async function fetchMe(): Promise<SessionUser | null> {
|
||||
try {
|
||||
@@ -72,6 +110,83 @@ export async function fetchMe(): Promise<SessionUser | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- User & role management (RBAC) ----------------------------------------
|
||||
|
||||
/** Optional profile metadata on a managed user (all nullable). */
|
||||
export interface UserProfile {
|
||||
fullName: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
address: string | null;
|
||||
}
|
||||
export interface ManagedUser extends UserProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
roleId: string;
|
||||
roleName: string;
|
||||
language: Lang;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface ManagedRole {
|
||||
id: string;
|
||||
name: string;
|
||||
builtin: boolean;
|
||||
permissions: Permission[];
|
||||
userCount: number;
|
||||
}
|
||||
|
||||
export function fetchUsers(): Promise<{ users: ManagedUser[] }> {
|
||||
return apiFetch("/api/users");
|
||||
}
|
||||
export function createUser(
|
||||
body: { username: string; password: string; roleId: string } & Partial<UserProfile>,
|
||||
): Promise<ManagedUser> {
|
||||
return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updateUser(
|
||||
id: string,
|
||||
body: { username?: string; roleId?: string } & Partial<UserProfile>,
|
||||
): Promise<ManagedUser> {
|
||||
return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function resetUserPassword(id: string, password: string): Promise<{ ok: boolean }> {
|
||||
return apiFetch(`/api/users/${id}/password`, { method: "PUT", body: JSON.stringify({ password }) });
|
||||
}
|
||||
export function deleteUser(id: string): Promise<{ ok: boolean }> {
|
||||
return apiFetch(`/api/users/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Roles + the full permission catalog (for the composer checkbox grid). */
|
||||
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
|
||||
return apiFetch("/api/roles");
|
||||
}
|
||||
export function createRole(body: { name: string; permissions: Permission[] }): Promise<ManagedRole> {
|
||||
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> {
|
||||
return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function deleteRole(id: string): Promise<{ ok: boolean }> {
|
||||
return apiFetch(`/api/roles/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- Application logs (app_logs) ------------------------------------------
|
||||
/** Read recent diagnostic logs (gated server-side by log:read). */
|
||||
export function fetchLogs(params: {
|
||||
limit?: number;
|
||||
level?: string;
|
||||
source?: string;
|
||||
since?: string;
|
||||
} = {}): Promise<{ logs: AppLogRecord[] }> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.limit) q.set("limit", String(params.limit));
|
||||
if (params.level) q.set("level", params.level);
|
||||
if (params.source) q.set("source", params.source);
|
||||
if (params.since) q.set("since", params.since);
|
||||
const qs = q.toString();
|
||||
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
// --- Device setup ---------------------------------------------------------
|
||||
|
||||
export interface ConfigField {
|
||||
@@ -96,6 +211,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
||||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||||
/** Driver ids that support LAN discovery. */
|
||||
discoverable: string[];
|
||||
/** Driver ids that push to the backend (need a backend IP at assign time). */
|
||||
pushCapable: string[];
|
||||
};
|
||||
|
||||
export function fetchCatalog(): Promise<Catalog> {
|
||||
@@ -118,7 +235,31 @@ export async function discoverDevices(driverId: string): Promise<DiscoveredDevic
|
||||
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;
|
||||
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
|
||||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
||||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
||||
presenceInput?: number;
|
||||
entryCooldownSec?: number;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
health: { status: string; detail?: string };
|
||||
@@ -151,9 +292,10 @@ export function fetchBackendIps(
|
||||
}
|
||||
|
||||
export interface AssignBody {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
driverId: string;
|
||||
// Direction/binding lives in config: access → config.relays=[{relay,direction,button?}];
|
||||
// reader/camera → config.controllerId + config.relay.
|
||||
config: DeviceConfig;
|
||||
/** Backend IP the device should push to (overrides auto-pick). */
|
||||
backendIp?: string;
|
||||
@@ -164,10 +306,18 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
/** Re-configure an existing device in place, keeping its id (and so its push
|
||||
* URL). Category/driver are fixed at create time, so only config changes. */
|
||||
export function editDevice(
|
||||
id: string,
|
||||
body: Omit<AssignBody, "category" | "driverId">,
|
||||
): Promise<AssignResult> {
|
||||
return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
||||
export interface Assignment {
|
||||
id: string;
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
driverId: string;
|
||||
config: DeviceConfig;
|
||||
@@ -195,3 +345,469 @@ export function fetchState(): Promise<SetupState> {
|
||||
export function unassignDevice(id: string): Promise<void> {
|
||||
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- Tariff composer ------------------------------------------------------
|
||||
|
||||
export interface TariffBlock {
|
||||
uptoMin: number | null;
|
||||
priceMinorPerIncrement: number;
|
||||
}
|
||||
// Mirrors @parking/shared. Two shapes: V1 (bare ladder) and V2 (default + windowed
|
||||
// cards by time-of-day / dow / date / category, flat or laddered). The discriminant
|
||||
// is the presence of `defaultCard`. See wiki/concepts/tariff-time-tiers.md.
|
||||
export interface TariffStructureV1 {
|
||||
gracePeriodEntryMin: number;
|
||||
incrementMin: number;
|
||||
blocks: TariffBlock[];
|
||||
dailyCapMinor: number | null;
|
||||
lostTicketMinor: number;
|
||||
gracePeriodExitMin: number;
|
||||
overstay: "reprice";
|
||||
}
|
||||
export interface TariffWindow {
|
||||
dow?: number[];
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
fromHour?: string;
|
||||
toHour?: string;
|
||||
}
|
||||
export interface TariffCard {
|
||||
name: string;
|
||||
priority: number;
|
||||
category?: string;
|
||||
window?: TariffWindow;
|
||||
flatMinor?: number;
|
||||
blocks?: TariffBlock[];
|
||||
dailyCapMinor?: number | null;
|
||||
}
|
||||
export interface TariffStructureV2 {
|
||||
version: 2;
|
||||
tz: string;
|
||||
gracePeriodEntryMin: number;
|
||||
incrementMin: number;
|
||||
lostTicketMinor: number;
|
||||
gracePeriodExitMin: number;
|
||||
overstay: "reprice";
|
||||
defaultCard: TariffCard;
|
||||
windowedCards?: TariffCard[];
|
||||
}
|
||||
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
|
||||
|
||||
/** True when a structure is the windowed V2 shape (mirrors @parking/shared isTariffV2). */
|
||||
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
|
||||
return (t as TariffStructureV2).defaultCard != null;
|
||||
}
|
||||
export interface TariffVersion {
|
||||
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) });
|
||||
}
|
||||
|
||||
// --- Subscriptions --------------------------------------------------------
|
||||
|
||||
export interface SubscriptionCredential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
holderName: string | null;
|
||||
contact: string | null;
|
||||
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
|
||||
priceMinor: number | null;
|
||||
period: "monthly";
|
||||
currency: string | null;
|
||||
maxConcurrent: number | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
status: "active" | "suspended" | "revoked";
|
||||
credentials: SubscriptionCredential[];
|
||||
plates: string[];
|
||||
}
|
||||
/** A credential as SENT to the server: a QR value may be omitted/blank → the server
|
||||
* auto-generates an unguessable code. RF must carry the card id. */
|
||||
export interface SubscriptionCredentialInput {
|
||||
kind: "rf" | "qr";
|
||||
value?: string;
|
||||
}
|
||||
export type SubscriptionInput = {
|
||||
holderName: string | null;
|
||||
contact: string | null;
|
||||
priceMinor: number | null;
|
||||
period: "monthly";
|
||||
currency: string | null;
|
||||
maxConcurrent: number | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
|
||||
months?: number | null;
|
||||
status?: Subscription["status"];
|
||||
credentials: SubscriptionCredentialInput[];
|
||||
plates: string[];
|
||||
};
|
||||
|
||||
/** The create response = the saved subscription + the auto-print outcome. */
|
||||
export type SubscriptionCreated = Subscription & {
|
||||
printed: boolean;
|
||||
printedBy?: string;
|
||||
printError?: string;
|
||||
};
|
||||
|
||||
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
||||
return apiFetch("/api/subscriptions");
|
||||
}
|
||||
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
|
||||
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
/** Re-print a subscription's QR card (failed auto-print / lost card). */
|
||||
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
|
||||
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
|
||||
}
|
||||
|
||||
// --- Credential capture ("enroll a card" on a chosen reader) ---------------
|
||||
|
||||
export interface ReaderInfo {
|
||||
id: string;
|
||||
driverId: string;
|
||||
direction: "entry" | "exit" | "both";
|
||||
}
|
||||
export type CaptureState =
|
||||
| { status: "idle" }
|
||||
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
|
||||
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
|
||||
| { status: "expired"; deviceId: string };
|
||||
|
||||
export function fetchReaders(): Promise<{ readers: ReaderInfo[] }> {
|
||||
return apiFetch("/api/subscriptions/readers");
|
||||
}
|
||||
export function armCapture(deviceId: string): Promise<{ expiresAt: number }> {
|
||||
return apiFetch("/api/subscriptions/capture/arm", { method: "POST", body: JSON.stringify({ deviceId }) });
|
||||
}
|
||||
export function pollCapture(): Promise<CaptureState> {
|
||||
return apiFetch("/api/subscriptions/capture");
|
||||
}
|
||||
export function cancelCapture(): Promise<{ ok: boolean }> {
|
||||
return apiFetch("/api/subscriptions/capture/cancel", { method: "POST" });
|
||||
}
|
||||
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
|
||||
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function revokeSubscription(id: string): Promise<Subscription> {
|
||||
return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" });
|
||||
}
|
||||
export function deleteSubscription(id: string): Promise<void> {
|
||||
return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- Shifts ---------------------------------------------------------------
|
||||
|
||||
export interface ShiftStatus {
|
||||
/** The requesting (logged-in) operator. */
|
||||
operator: string;
|
||||
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
|
||||
open: { startedAt: string; operator: string | null } | null;
|
||||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||||
isMine: boolean;
|
||||
/** Live physical drawer balance (cash payments + cash movements). */
|
||||
drawerMinor: number;
|
||||
currency: string | null;
|
||||
}
|
||||
export interface ShiftReport {
|
||||
operator: string;
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** A completed shift (reconstructed from its signed Z-report). */
|
||||
export interface ShiftSummary {
|
||||
id: string;
|
||||
index: number;
|
||||
operator: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
cashTotalMinor: number;
|
||||
cardTotalMinor: number;
|
||||
currency: string | null;
|
||||
paymentCount: number;
|
||||
openingFloatMinor: number;
|
||||
cashAddedMinor: number;
|
||||
cashRemovedMinor: number;
|
||||
expectedDrawerMinor: number;
|
||||
}
|
||||
|
||||
/** Completed shift history. The server scopes by permission: operators get their
|
||||
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
|
||||
* filtered by operator + a from/to window over the shift start. `scope` echoes
|
||||
* which the server applied, so the UI can show/hide the filter. */
|
||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
||||
shifts: ShiftSummary[];
|
||||
scope: "all" | "self";
|
||||
}> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.operator) qs.set("operator", params.operator);
|
||||
if (params.from) qs.set("from", params.from);
|
||||
if (params.to) qs.set("to", params.to);
|
||||
const q = qs.toString();
|
||||
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
|
||||
}
|
||||
|
||||
// --- Site config / occupancy ----------------------------------------------
|
||||
|
||||
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;
|
||||
/** Site default monthly subscription price (minor units); pre-fills the form. */
|
||||
subscriptionMonthlyPriceMinor: number | null;
|
||||
parkName: string | null;
|
||||
operatorName: string | null;
|
||||
/** NIUS — Albanian tax/identification number. */
|
||||
nius: string | null;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
/** IANA timezone for tariff wall-clock windows (e.g. "Europe/Tirane"). Copied into
|
||||
* each published tariff version so its windows are frozen. */
|
||||
timezone: string | null;
|
||||
/** Default vehicle/customer category frozen onto each transient entry (V2 pricing). */
|
||||
defaultVehicleCategory: string | null;
|
||||
}
|
||||
|
||||
export function fetchOccupancy(): Promise<Occupancy> {
|
||||
return apiFetch("/api/occupancy");
|
||||
}
|
||||
|
||||
// --- Device status (the booth footer) -------------------------------------
|
||||
|
||||
/** Live status of one configured device — mirrors the server's DeviceStatusEvent.
|
||||
* Every enabled device is polled (printers via rich readStatus, the rest via
|
||||
* healthCheck) and flattened to one traffic-light. Pushed over the WS; the REST
|
||||
* snapshot below is the initial load / fallback. */
|
||||
export interface DeviceStatus {
|
||||
deviceId: string;
|
||||
driverId: string;
|
||||
category: "access" | "reader" | "camera" | "printer";
|
||||
/** Role/direction token for the footer label (NOT the vendor) — the client
|
||||
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
|
||||
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||||
state: "ready" | "degraded" | "offline";
|
||||
detail?: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
|
||||
return apiFetch("/api/devices/status");
|
||||
}
|
||||
|
||||
// --- Ledger events (the signed audit trail; read-only) --------------------
|
||||
|
||||
/** A persisted ledger row. Re-exported from shared so UI code has one source of
|
||||
* truth for the event shape (the same type the WS pushes). */
|
||||
export type { LedgerEvent, LogLevel, LogSource } from "@parking/shared";
|
||||
export type { AppLogRecord };
|
||||
|
||||
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||||
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
||||
* scopes to events at/after that instant — the booth passes the current shift's
|
||||
* start so the feed shows ONLY this shift's activity. */
|
||||
export function fetchEvents(
|
||||
limit = 100,
|
||||
since?: string,
|
||||
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (since) qs.set("since", since);
|
||||
return apiFetch(`/api/events?${qs.toString()}`);
|
||||
}
|
||||
|
||||
// --- Booth: session lookup, payment, exit ---------------------------------
|
||||
|
||||
/** One-read session view for the booth pay/exit modal (mirrors server SessionLookup). */
|
||||
export interface SessionLookup {
|
||||
identity: string;
|
||||
found: boolean;
|
||||
open: boolean;
|
||||
enteredAt: string | null;
|
||||
exitedAt: string | null;
|
||||
paidAt: string | null;
|
||||
amountMinor: number | null;
|
||||
currency: string | null;
|
||||
withinGrace: boolean;
|
||||
graceExpiresAt: string | null;
|
||||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||||
subscription: boolean;
|
||||
subscriptionId: string | null;
|
||||
subscriptionHolder: string | null;
|
||||
}
|
||||
|
||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||
export function lookupSession(identity: string): Promise<SessionLookup> {
|
||||
return apiFetch(`/api/session/${encodeURIComponent(identity)}`);
|
||||
}
|
||||
|
||||
/** One row in the booth Active Sessions list (mirrors server ActiveSession). */
|
||||
export interface ActiveSession {
|
||||
identity: string;
|
||||
source: string | null;
|
||||
enteredAt: string;
|
||||
exitedAt: string | null;
|
||||
open: boolean;
|
||||
paidAt: string | null;
|
||||
amountMinor: number | null;
|
||||
currency: string | null;
|
||||
withinGrace: boolean;
|
||||
graceExpiresAt: string | null;
|
||||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||||
subscription: boolean;
|
||||
subscriptionId: string | null;
|
||||
subscriptionHolder: string | null;
|
||||
}
|
||||
|
||||
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
|
||||
export function fetchActiveSessions(): Promise<{ sessions: ActiveSession[] }> {
|
||||
return apiFetch("/api/sessions/active");
|
||||
}
|
||||
|
||||
/** Human-intervention barrier re-open for a paid active session (damaged ticket /
|
||||
* phantom re-close). Signs an audited anomaly; never a 2nd exit. */
|
||||
export function reopenBarrier(identity: string): Promise<{ ok: true; opened: boolean; reason?: string }> {
|
||||
return apiFetch("/api/barrier/reopen", { method: "POST", body: JSON.stringify({ identity }) });
|
||||
}
|
||||
|
||||
/** Take payment for a session → signed payment event. `overrideMinor` sets an
|
||||
* operator amount (lost ticket / dispute). */
|
||||
export function paySession(
|
||||
identity: string,
|
||||
tender: "cash" | "card",
|
||||
overrideMinor?: number,
|
||||
): Promise<{ amountMinor: number; currency: string }> {
|
||||
return apiFetch("/api/pay", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity, tender, ...(overrideMinor != null ? { overrideMinor } : {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||
* open (payment stands; operator opens manually). */
|
||||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||||
|
||||
/** Validate + open the barrier for a session from the booth (when near the exit). */
|
||||
export function boothExit(identity: string): Promise<BoothExitResult> {
|
||||
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
|
||||
}
|
||||
|
||||
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
|
||||
* for self-exit at a distant exit. Requires the session to be paid. */
|
||||
export function printVoucher(identity: string): Promise<{ ok: boolean; printedBy: string }> {
|
||||
return apiFetch("/api/voucher", { method: "POST", body: JSON.stringify({ identity }) });
|
||||
}
|
||||
|
||||
/** Print a standalone PAYMENT RECEIPT (entry/paid/duration/amount, no barcode).
|
||||
* Auto-printed after a payment when no voucher is issued; also the "reprint"
|
||||
* action. Requires the session to be paid. */
|
||||
export function printReceipt(identity: string): Promise<{ ok: boolean; printedBy: string }> {
|
||||
return apiFetch("/api/receipt", { method: "POST", body: JSON.stringify({ identity }) });
|
||||
}
|
||||
|
||||
// --- Snapshots (entry/exit evidence images) -------------------------------
|
||||
|
||||
export interface SnapshotMeta {
|
||||
id: string;
|
||||
direction: "entry" | "exit" | null;
|
||||
deviceId: string;
|
||||
identity: string;
|
||||
contentType: string;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
/** A capture that was ATTEMPTED but failed (camera offline, config) — surfaced so a
|
||||
* missing image isn't a silent gap. From snapshot telemetry, not the image store. */
|
||||
export interface SnapshotFailure {
|
||||
direction: "entry" | "exit" | null;
|
||||
deviceId: string;
|
||||
error: string;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
/** Snapshot metadata for a session identity (newest first) PLUS failed capture
|
||||
* attempts. Image bytes are at `/api/snapshots/:id` — use that as an <img src>. */
|
||||
export function fetchSnapshots(
|
||||
identity: string,
|
||||
): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[] }> {
|
||||
return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`);
|
||||
}
|
||||
|
||||
/** URL for a snapshot's image bytes (cookie-authed; usable as <img src>). */
|
||||
export function snapshotImageUrl(id: string): string {
|
||||
return `/api/snapshots/${encodeURIComponent(id)}`;
|
||||
}
|
||||
export function fetchSiteConfig(): Promise<SiteConfig> {
|
||||
return apiFetch("/api/site-config");
|
||||
}
|
||||
/** 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,411 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Bloomberg-terminal aesthetic: dense, dark, monospace, keyboard-first.
|
||||
Tailwind v4 — design tokens live here in @theme (no tailwind.config.js).
|
||||
The booth runs on a fixed appliance display; we optimise for a dark room,
|
||||
glanceable status colour, and high information density over whitespace.
|
||||
|
||||
Token vocabulary adopted from the "TRM" design system (Tracking & Race
|
||||
Management) — TOKENS ONLY: colours, type scale, spacing, radii, shadows.
|
||||
None of TRM's race-timing components are used. The existing `term-*` accents
|
||||
are aligned onto TRM's exact values so the whole booth UI inherits the TRM
|
||||
palette without renaming a single class. TRM's full vocabulary is also
|
||||
exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the
|
||||
spacing/type/shadow scales) for new work.
|
||||
|
||||
Offline appliance: NO webfont @import (no network at runtime). Goldplay (the
|
||||
TRM display face) is not self-hosted yet — display/heading text falls back to
|
||||
a clean sans stack; wire local Goldplay @font-face here if it's wanted. */
|
||||
@theme {
|
||||
/* ============================================================
|
||||
TERMINAL ACCENTS — aligned onto TRM's exact values.
|
||||
These keep their `term-*` names (used across every screen),
|
||||
but now resolve to TRM colours so the palette is unified.
|
||||
============================================================ */
|
||||
/* Surfaces — TRM "night" (trackside dark) scale. */
|
||||
--color-term-bg: #0b0d10; /* TRM --night */
|
||||
--color-term-panel: #14171c; /* TRM --night-2 */
|
||||
--color-term-panel-2: #1e222a; /* TRM --night-3 */
|
||||
--color-term-border: #2a2f38; /* TRM --night-line */
|
||||
--color-term-muted: #8a8a82; /* TRM --night-fg-3 / --ink-4 */
|
||||
--color-term-text: #f2f2ee; /* TRM --night-fg */
|
||||
|
||||
/* Status accents — TRM semantic colours. */
|
||||
--color-term-amber: #f2a516; /* TRM --amber (caution / accent / focus) */
|
||||
--color-term-green: #2e8c4a; /* TRM --green (entry / ok / free) */
|
||||
--color-term-red: #e8412b; /* TRM --flag (exit / fault / full) */
|
||||
--color-term-cyan: #2563c8; /* TRM --blue (payment / info / live) */
|
||||
|
||||
/* ============================================================
|
||||
TRM FULL VOCABULARY — exposed as Tailwind utilities for new work.
|
||||
============================================================ */
|
||||
/* Ink & paper (light surfaces — for any light-on-dark inversions). */
|
||||
--color-paper: #fafaf7;
|
||||
--color-paper-2: #f2f2ee;
|
||||
--color-paper-3: #e8e8e2;
|
||||
--color-ink: #0e0e0c;
|
||||
--color-ink-2: #2a2a26;
|
||||
--color-ink-3: #5a5a53;
|
||||
--color-ink-4: #8a8a82;
|
||||
--color-ink-5: #b8b8b0;
|
||||
--color-ink-6: #dcdcd4;
|
||||
|
||||
/* Night scale (the booth's working surfaces). */
|
||||
--color-night: #0b0d10;
|
||||
--color-night-2: #14171c;
|
||||
--color-night-3: #1e222a;
|
||||
--color-night-line: #2a2f38;
|
||||
--color-night-fg: #f2f2ee;
|
||||
--color-night-fg-2: #b8b8b0;
|
||||
--color-night-fg-3: #8a8a82;
|
||||
|
||||
/* Race accents + semantic. */
|
||||
--color-flag: #e8412b;
|
||||
--color-flag-2: #c8331f;
|
||||
--color-flag-tint: #fbe3de;
|
||||
--color-amber: #f2a516;
|
||||
--color-amber-2: #c88500;
|
||||
--color-amber-tint: #fbefd0;
|
||||
--color-green: #2e8c4a;
|
||||
--color-green-2: #1f6a36;
|
||||
--color-green-tint: #ddefe2;
|
||||
--color-blue: #2563c8;
|
||||
--color-blue-2: #1a4fa8;
|
||||
--color-blue-tint: #dce6f8;
|
||||
--color-violet: #6b46c1;
|
||||
--color-magenta: #c9296f;
|
||||
--color-teal: #188c8a;
|
||||
|
||||
--color-ok: #2e8c4a;
|
||||
--color-warn: #f2a516;
|
||||
--color-danger: #e8412b;
|
||||
--color-info: #2563c8;
|
||||
|
||||
/* Data-viz categorical (8). */
|
||||
--color-viz-1: #e8412b;
|
||||
--color-viz-2: #2563c8;
|
||||
--color-viz-3: #2e8c4a;
|
||||
--color-viz-4: #f2a516;
|
||||
--color-viz-5: #6b46c1;
|
||||
--color-viz-6: #188c8a;
|
||||
--color-viz-7: #c9296f;
|
||||
--color-viz-8: #5a5a53;
|
||||
|
||||
/* ---------- TYPE — families ---------- */
|
||||
/* Mono is the booth's primary face (data-dense, tabular). Display/UI fall
|
||||
back to a clean sans (Goldplay not self-hosted — see header note). */
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
|
||||
"Menlo", "Consolas", monospace;
|
||||
--font-display: "Goldplay", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-ui: "Goldplay", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-body: "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
/* ---------- TYPE — scale (TRM, optimised for data density) ---------- */
|
||||
--text-overline: 11px;
|
||||
--text-micro: 12px;
|
||||
--text-small: 13px;
|
||||
--text-body: 15px;
|
||||
--text-lead: 17px;
|
||||
--text-h6: 14px;
|
||||
--text-h5: 16px;
|
||||
--text-h4: 20px;
|
||||
--text-h3: 26px;
|
||||
--text-h2: 34px;
|
||||
--text-h1: 48px;
|
||||
--text-display: 72px;
|
||||
--text-jumbo: 120px;
|
||||
|
||||
/* ---------- SPACING (TRM 4px base) ---------- */
|
||||
--spacing-s0: 0;
|
||||
--spacing-s1: 2px;
|
||||
--spacing-s2: 4px;
|
||||
--spacing-s3: 8px;
|
||||
--spacing-s4: 12px;
|
||||
--spacing-s5: 16px;
|
||||
--spacing-s6: 20px;
|
||||
--spacing-s7: 24px;
|
||||
--spacing-s8: 32px;
|
||||
--spacing-s9: 40px;
|
||||
--spacing-s10: 48px;
|
||||
--spacing-s11: 64px;
|
||||
--spacing-s12: 80px;
|
||||
--spacing-s13: 96px;
|
||||
|
||||
/* ---------- RADIUS — TRM is square-edged ---------- */
|
||||
--radius-term: 2px; /* existing alias, kept */
|
||||
--radius-r0: 0;
|
||||
--radius-r1: 2px;
|
||||
--radius-r2: 4px;
|
||||
--radius-r3: 6px;
|
||||
--radius-r4: 10px;
|
||||
|
||||
/* ---------- ELEVATION — TRM sharp "printed" offset shadows ---------- */
|
||||
--shadow-term-1: 0 1px 0 0 #0e0e0c;
|
||||
--shadow-term-2: 2px 2px 0 0 #0e0e0c;
|
||||
--shadow-term-3: 4px 4px 0 0 #0e0e0c;
|
||||
--shadow-soft: 0 1px 2px rgba(14, 14, 12, 0.06), 0 4px 12px rgba(14, 14, 12, 0.04);
|
||||
--shadow-pop: 0 8px 24px rgba(14, 14, 12, 0.12);
|
||||
|
||||
/* ---------- MOTION (TRM) ---------- */
|
||||
--ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* ---------- COMPONENT TOKENS (TRM control heights, table rows) ---------- */
|
||||
--control-h-sm: 28px;
|
||||
--control-h-md: 36px;
|
||||
--control-h-lg: 44px;
|
||||
--table-row-h: 36px;
|
||||
--table-row-h-dense: 28px;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-term-bg);
|
||||
color: var(--color-term-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Crisp text and no rubber-banding on the fixed appliance display. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Tabular numerics everywhere — counts, money, clocks must not jitter. */
|
||||
.num,
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Terminal scrollbars — thin, dark, unobtrusive. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-term-border) transparent;
|
||||
}
|
||||
|
||||
/* A visible keyboard-focus ring in the amber accent (keyboard-first UI). */
|
||||
:focus-visible {
|
||||
outline: 1px solid var(--color-term-amber);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
COMPONENT LAYER
|
||||
The TRM tokens are good, but every screen hand-rolled inputs and
|
||||
buttons as bare outlines on near-black panels, so a field, a card,
|
||||
and a button were visually indistinguishable. These classes give
|
||||
each control a real identity:
|
||||
- inputs read as RECESSED slots (lighter fill + inset shadow)
|
||||
- the primary button is FILLED (accent body, dark text) — the
|
||||
one obvious action; neutrals are filled grey, not bare outlines
|
||||
- explicit hover / active / focus / disabled states everywhere
|
||||
Use @apply so the classes compose with Tailwind utilities.
|
||||
============================================================ */
|
||||
@layer components {
|
||||
/* ---- Form fields: a recessed slot, clearly an input ---- */
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
@apply w-full rounded-term border bg-term-bg px-2.5 text-term-text
|
||||
placeholder:text-term-muted;
|
||||
border-color: #3a414c; /* lighter than panel borders */
|
||||
height: var(--control-h-md);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45);
|
||||
transition: border-color 120ms var(--ease-snap), box-shadow 120ms var(--ease-snap);
|
||||
}
|
||||
.textarea {
|
||||
height: auto;
|
||||
@apply py-2 leading-snug;
|
||||
}
|
||||
.input::placeholder,
|
||||
.textarea::placeholder {
|
||||
@apply text-term-muted;
|
||||
}
|
||||
.input:hover,
|
||||
.select:hover,
|
||||
.textarea:hover {
|
||||
border-color: #4a525f;
|
||||
}
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-term-amber);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45), 0 0 0 1px var(--color-term-amber);
|
||||
}
|
||||
.input:disabled,
|
||||
.select:disabled,
|
||||
.textarea:disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
/* Small / dense variant for inline table cells */
|
||||
.input-sm {
|
||||
height: var(--control-h-sm);
|
||||
@apply px-2 text-[12px];
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
.label {
|
||||
@apply text-[11px] uppercase tracking-wider text-term-muted;
|
||||
}
|
||||
.hint {
|
||||
@apply text-[11px] leading-snug text-term-muted;
|
||||
}
|
||||
|
||||
/* ---- Buttons: a button must look pressable, never like a field ---- */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-term border
|
||||
px-3 text-[12px] font-semibold uppercase tracking-wider
|
||||
transition-colors select-none;
|
||||
height: var(--control-h-md);
|
||||
/* Neutral default: a filled grey body, not a bare outline. */
|
||||
background: var(--color-term-panel-2);
|
||||
border-color: #3a414c;
|
||||
color: var(--color-term-text);
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #2a313b;
|
||||
border-color: #4a525f;
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.btn:disabled {
|
||||
@apply cursor-not-allowed opacity-40;
|
||||
}
|
||||
.btn-sm {
|
||||
height: var(--control-h-sm);
|
||||
@apply px-2.5 text-[11px];
|
||||
}
|
||||
.btn-lg {
|
||||
height: var(--control-h-lg);
|
||||
@apply px-5 text-[13px];
|
||||
}
|
||||
|
||||
/* Primary: FILLED amber, dark text — the unmistakable main action. */
|
||||
.btn-primary {
|
||||
background: var(--color-term-amber);
|
||||
border-color: var(--color-term-amber);
|
||||
color: #0b0d10;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #ffb733;
|
||||
border-color: #ffb733;
|
||||
}
|
||||
|
||||
/* Semantic filled variants (entry / payment / destructive). */
|
||||
.btn-go {
|
||||
background: var(--color-term-green);
|
||||
border-color: var(--color-term-green);
|
||||
color: #f2f2ee;
|
||||
}
|
||||
.btn-go:hover:not(:disabled) {
|
||||
background: #38a85a;
|
||||
border-color: #38a85a;
|
||||
}
|
||||
.btn-pay {
|
||||
background: var(--color-term-cyan);
|
||||
border-color: var(--color-term-cyan);
|
||||
color: #f2f2ee;
|
||||
}
|
||||
.btn-pay:hover:not(:disabled) {
|
||||
background: #2f74e0;
|
||||
border-color: #2f74e0;
|
||||
}
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
border-color: var(--color-term-red);
|
||||
color: var(--color-term-red);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-term-red) 14%, transparent);
|
||||
}
|
||||
|
||||
/* Ghost: lowest-emphasis (cancel, secondary nav) — text + hover only. */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--color-term-muted);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--color-term-panel-2);
|
||||
color: var(--color-term-text);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ---- Card: a panel that is clearly a container, not a field ---- */
|
||||
.card {
|
||||
@apply rounded-term border border-term-border bg-term-panel;
|
||||
}
|
||||
.card-head {
|
||||
@apply flex items-center justify-between border-b border-term-border
|
||||
bg-term-panel-2 px-4 py-2 text-[12px] uppercase tracking-wider text-term-muted;
|
||||
}
|
||||
.card-body {
|
||||
@apply p-4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LIGHT THEME
|
||||
The booth defaults to dark (dark room), but a user may prefer light; the
|
||||
choice is saved to their profile (users.theme) and applied on <html> as
|
||||
`.theme-light`. Every screen reads colour through the --color-term-* tokens,
|
||||
so re-pointing them here re-skins the whole app. The TRM "paper/ink" scale
|
||||
supplies the surfaces; accents are tuned a shade darker for contrast on white.
|
||||
A few component-layer values are literal hex (input borders, the inset
|
||||
"recessed" shadow, primary-button text) — those are overridden too so fields
|
||||
and buttons keep their affordance on a light background.
|
||||
============================================================ */
|
||||
html.theme-light {
|
||||
/* Surfaces — TRM paper scale (light → slightly darker for layering). */
|
||||
--color-term-bg: #fafaf7; /* paper */
|
||||
--color-term-panel: #f2f2ee; /* paper-2 */
|
||||
--color-term-panel-2: #e8e8e2; /* paper-3 */
|
||||
--color-term-border: #d2d2c8;
|
||||
--color-term-muted: #5a5a53; /* ink-3 — readable secondary text */
|
||||
--color-term-text: #14171c; /* near-black ink */
|
||||
|
||||
/* Accents — a step darker than the dark-theme values for white-bg contrast. */
|
||||
--color-term-amber: #b8740a;
|
||||
--color-term-green: #1f6a36;
|
||||
--color-term-red: #c8331f;
|
||||
--color-term-cyan: #1a4fa8;
|
||||
}
|
||||
|
||||
/* Component-layer literals that must flip for light (the rest read tokens). */
|
||||
html.theme-light .input,
|
||||
html.theme-light .select,
|
||||
html.theme-light .textarea {
|
||||
border-color: #c2c2b8;
|
||||
box-shadow: inset 0 1px 2px rgba(20, 23, 28, 0.08);
|
||||
}
|
||||
html.theme-light .input:hover,
|
||||
html.theme-light .select:hover,
|
||||
html.theme-light .textarea:hover {
|
||||
border-color: #a8a89e;
|
||||
}
|
||||
html.theme-light .input:focus,
|
||||
html.theme-light .select:focus,
|
||||
html.theme-light .textarea:focus {
|
||||
box-shadow: inset 0 1px 2px rgba(20, 23, 28, 0.08), 0 0 0 1px var(--color-term-amber);
|
||||
}
|
||||
html.theme-light .btn {
|
||||
border-color: #c2c2b8;
|
||||
}
|
||||
html.theme-light .btn:hover:not(:disabled) {
|
||||
background: #dcdcd4;
|
||||
border-color: #a8a89e;
|
||||
}
|
||||
/* Filled buttons keep light text; primary uses dark-on-amber, kept legible. */
|
||||
html.theme-light .btn-primary {
|
||||
color: #fafaf7;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { logClient } from "./logger.js";
|
||||
|
||||
// Top-level React error boundary: catches a render/lifecycle crash anywhere in the
|
||||
// tree, reports it to the backend log store (app_logs), and shows a minimal recovery
|
||||
// screen instead of a white page. A booth must never be left staring at a blank
|
||||
// screen with no trace of why. See wiki/concepts/app-logs.md.
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||
override state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(err: Error): State {
|
||||
return { hasError: true, message: err.message };
|
||||
}
|
||||
|
||||
override componentDidCatch(err: Error, info: ErrorInfo): void {
|
||||
logClient({
|
||||
level: "fatal",
|
||||
message: err.message || "React render error",
|
||||
stack: err.stack,
|
||||
path: typeof location !== "undefined" ? location.pathname : undefined,
|
||||
context: { kind: "react_error_boundary", componentStack: info.componentStack },
|
||||
});
|
||||
}
|
||||
|
||||
override render(): ReactNode {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
// Intentionally un-i18n'd + dependency-free: the app tree just crashed, so we can't
|
||||
// assume providers (i18n/router/query) are healthy.
|
||||
return (
|
||||
<div style={{ padding: "2rem", fontFamily: "monospace", color: "#e5e5e5", background: "#0a0a0a", minHeight: "100vh" }}>
|
||||
<h1 style={{ color: "#ef4444" }}>Something went wrong</h1>
|
||||
<p>The screen crashed and has been reported. Try reloading.</p>
|
||||
{this.state.message && <pre style={{ color: "#a3a3a3" }}>{this.state.message}</pre>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => location.reload()}
|
||||
style={{ marginTop: "1rem", padding: "0.5rem 1rem", cursor: "pointer" }}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
||||
* before ref, etc. Compares date parts only (ignores time-of-day). */
|
||||
function dayDiff(d: Date, ref: Date): number {
|
||||
const a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const b = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate());
|
||||
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** HH:MM (local, 24h) for the relative-day labels. */
|
||||
function hhmm(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
||||
* `returnObjects` overload used to fetch the month-name array. */
|
||||
export interface TFn {
|
||||
(key: string): string;
|
||||
(key: string, opts: { returnObjects: true }): unknown;
|
||||
}
|
||||
|
||||
/** Localized month name (index 0 = January) from the i18n catalog. Browser ICU on
|
||||
* the appliance may lack Albanian data, so we DON'T use Intl — the catalog is the
|
||||
* source of truth. Falls back to a numeric month if the array is missing. */
|
||||
function monthName(d: Date, t: TFn): string {
|
||||
const months = t("common.months", { returnObjects: true });
|
||||
if (Array.isArray(months) && typeof months[d.getMonth()] === "string") {
|
||||
return months[d.getMonth()] as string;
|
||||
}
|
||||
return String(d.getMonth() + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human, day-relative date+time for sessions/logs/history. An event from earlier
|
||||
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
|
||||
* "17 Qershor 10:48" (month name from the active catalog). Keeps time-of-day on
|
||||
* every variant — operators care about it within a shift.
|
||||
*
|
||||
* `t` supplies the today/yesterday words AND the month names (the appliance browser
|
||||
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
|
||||
*/
|
||||
export function formatRelativeDateTime(iso: string | null, t: TFn): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const diff = dayDiff(d, new Date());
|
||||
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
|
||||
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
|
||||
// Older (or future): "17 Qershor 10:48", with the year only if it differs.
|
||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||
const month = monthName(d, t);
|
||||
const date = sameYear ? `${d.getDate()} ${month}` : `${d.getDate()} ${month} ${d.getFullYear()}`;
|
||||
return `${date} ${hhmm(d)}`;
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
// English (en). Mirrors the key structure of sq.ts (the default/fallback). Any key
|
||||
// missing here falls back to Albanian. See wiki/concepts/i18n.md.
|
||||
|
||||
import type { Catalog } from "./sq.js";
|
||||
|
||||
export const en: Catalog = {
|
||||
common: {
|
||||
loading: "Loading…",
|
||||
logout: "Log out",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
save: "Save",
|
||||
none: "—",
|
||||
themeDark: "dark",
|
||||
themeLight: "light",
|
||||
theme: "Theme",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
months: [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
title: "Parking System",
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
signIn: "Sign in",
|
||||
signingIn: "Signing in…",
|
||||
},
|
||||
nav: {
|
||||
booth: "Booth",
|
||||
shift: "Shift",
|
||||
setup: "Setup",
|
||||
devices: "Devices",
|
||||
tariff: "Tariff",
|
||||
subscriptions: "Subscriptions",
|
||||
site: "Site",
|
||||
users: "Users",
|
||||
roles: "Roles",
|
||||
shifts: "Shifts",
|
||||
logs: "Logs",
|
||||
},
|
||||
status: {
|
||||
live: "LIVE",
|
||||
connecting: "CONNECTING",
|
||||
offline: "OFFLINE",
|
||||
},
|
||||
devices: {
|
||||
footerTitle: "Devices",
|
||||
none: "No devices configured.",
|
||||
catAccess: "Barrier",
|
||||
catReader: "Reader",
|
||||
catCamera: "Camera",
|
||||
catPrinter: "Printer",
|
||||
// Role/direction suffixes for the chip label (e.g. "Reader entry").
|
||||
role: {
|
||||
entry: "entry",
|
||||
exit: "exit",
|
||||
both: "entry/exit",
|
||||
mixed: "entry/exit",
|
||||
lane: "at lane",
|
||||
booth: "at booth",
|
||||
},
|
||||
state: {
|
||||
ready: "ready",
|
||||
degraded: "degraded",
|
||||
offline: "offline",
|
||||
},
|
||||
allOk: "all ready",
|
||||
issuesCount: "{{count}} with issues",
|
||||
issuesTitle: "Device issues",
|
||||
clickForIssues: "Click for details",
|
||||
checkedAt: "checked {{time}}",
|
||||
},
|
||||
booth: {
|
||||
processTicket: "Process ticket",
|
||||
scanPlaceholder: "Scan or type ticket number…",
|
||||
open: "Open",
|
||||
occupancy: "Occupancy",
|
||||
occUnavailable: "occupancy unavailable",
|
||||
inside: "inside",
|
||||
of: "of",
|
||||
uncapped: "uncapped",
|
||||
free: "free",
|
||||
lotFull: "● lot full",
|
||||
liveFeed: "Live feed",
|
||||
events: "events",
|
||||
noEventsYet: "No events yet — entries and exits will stream here.",
|
||||
activeSessions: "Active sessions",
|
||||
insideCount: "inside",
|
||||
noActiveSessions: "No active sessions.",
|
||||
openPayExit: "Open pay / exit",
|
||||
openBarrier: "Open barrier",
|
||||
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||
barrierOpened: "barrier opened",
|
||||
openManually: "open manually",
|
||||
badgeExiting: "exiting",
|
||||
badgePaid: "paid",
|
||||
badgeUnpaid: "unpaid",
|
||||
badgeSubscription: "subscription",
|
||||
evtEntry: "ENTRY",
|
||||
evtExit: "EXIT",
|
||||
evtPay: "PAY",
|
||||
evtVoid: "VOID",
|
||||
evtOpenCmd: "OPEN→",
|
||||
evtOpenObserved: "OPEN✓",
|
||||
evtShiftOpen: "SHIFT+",
|
||||
evtShiftZ: "SHIFT Z",
|
||||
evtCashMovement: "CASH",
|
||||
evtAnomaly: "ANOMALY",
|
||||
// live-feed event detail line + classification badges (computed from payload)
|
||||
evtNoReason: "no reason recorded",
|
||||
badgeEntryRefused: "entry refused",
|
||||
badgeExitRefused: "exit refused",
|
||||
badgeLotFull: "lot full",
|
||||
badgeBarrierFailed: "barrier did not open",
|
||||
badgeManualOpen: "manual open",
|
||||
badgeSubRefused: "subscription refused",
|
||||
badgeNoTicket: "ticket not printed",
|
||||
feedSourceBooth: "booth",
|
||||
feedSourceReader: "reader",
|
||||
// event-detail modal
|
||||
eventDetail: "Event detail",
|
||||
edType: "Type",
|
||||
edTime: "Time",
|
||||
edIndex: "Ledger index",
|
||||
edDirection: "Direction",
|
||||
edSource: "Source",
|
||||
edIdentity: "Identity",
|
||||
edReason: "Reason",
|
||||
edSnapshots: "Snapshots",
|
||||
edPayload: "Signed payload",
|
||||
edChain: "Chain",
|
||||
edSignature: "Signature",
|
||||
edKeyId: "Key",
|
||||
edPrevHash: "Prev hash",
|
||||
edNoPayload: "No payload on this event.",
|
||||
edCopy: "Copy",
|
||||
edCopied: "Copied",
|
||||
edDetails: "Details",
|
||||
edAuditData: "Audit data (signature & chain)",
|
||||
edAmount: "Amount",
|
||||
edTender: "Tender",
|
||||
edSession: "Session",
|
||||
edPlate: "Plate",
|
||||
edCategory: "Category",
|
||||
edOperator: "Operator",
|
||||
edTariffVersion: "Tariff version",
|
||||
edRawPayload: "Raw signed payload",
|
||||
edOccurrence: "Occurrence id",
|
||||
subscriber: "Subscriber",
|
||||
edVia: "Entry medium",
|
||||
viaQr: "QR code",
|
||||
viaCard: "RFID card/chip",
|
||||
viaPlate: "Plate",
|
||||
},
|
||||
// Localized messages for the signed REASON_CODES (see @parking/shared). Keys MUST
|
||||
// match the codes 1:1; {{param}} placeholders are filled from the event's
|
||||
// reasonParams. Legacy events with no code fall back to the signed English `reason`.
|
||||
reason: {
|
||||
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
|
||||
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
|
||||
"exit.refused.closed": "Exit refused — session already closed",
|
||||
"exit.refused.noSession": "Exit refused — unknown ticket",
|
||||
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
|
||||
"exit.refused.graceExpired": "Exit refused — walk-back grace expired (top-up required)",
|
||||
"exit.open.noBarrier": "Exit recorded, but no exit barrier is configured — open manually",
|
||||
"exit.open.unavailable": "Exit recorded, but the barrier is unavailable — open manually",
|
||||
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
|
||||
"exit.freeGrace": "Free entry-grace (no charge)",
|
||||
"exit.manualOpen": "Manual barrier open (human intervention)",
|
||||
"sub.refused.notFound": "Subscription refused — not found",
|
||||
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
|
||||
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
||||
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tariff",
|
||||
noRateCard: "No tariff published yet — the pay station can't charge until you publish one.",
|
||||
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
|
||||
currency: "Currency",
|
||||
freeEntryGrace: "Free entry grace (min)",
|
||||
billingIncrement: "Billing increment (min)",
|
||||
dailyCap: "Daily cap (blank = none)",
|
||||
dailyCapPh: "e.g. 12.00",
|
||||
lostTicketFee: "Lost-ticket fee",
|
||||
exitGrace: "Exit walk-back grace (min)",
|
||||
rateBlocks: "Rate blocks",
|
||||
rateBlocksHint: "Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.",
|
||||
bandDuration: "Band duration",
|
||||
hoursUnit: "hours",
|
||||
egHours: "e.g. 2",
|
||||
pricePerIncrement: "Price / increment",
|
||||
thereafter: "thereafter (open-ended)",
|
||||
remove: "Remove",
|
||||
addBlock: "+ Add block",
|
||||
publishNewVersion: "Publish new version",
|
||||
publishing: "Publishing…",
|
||||
publishedOk: "New tariff version published — it's now the active rate.",
|
||||
defaultCard: "Base rate (always active)",
|
||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||
modeLadder: "Hourly ladder",
|
||||
modeFlat: "Flat price",
|
||||
tiersAdvanced: "Advanced: time & seasonal tiers",
|
||||
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
|
||||
tierName: "Name",
|
||||
tierPriority: "Priority",
|
||||
tierCategory: "Category",
|
||||
tierCategoryPh: "e.g. bus",
|
||||
tierDays: "Days",
|
||||
tierHours: "Hours",
|
||||
tierDates: "Dates",
|
||||
tierOvernight: "(crosses midnight)",
|
||||
addTier: "+ Add tier",
|
||||
dow1: "Mon",
|
||||
dow2: "Tue",
|
||||
dow3: "Wed",
|
||||
dow4: "Thu",
|
||||
dow5: "Fri",
|
||||
dow6: "Sat",
|
||||
dow0: "Sun",
|
||||
},
|
||||
setup: {
|
||||
title: "Setup",
|
||||
intro:
|
||||
"Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.",
|
||||
catControllers: "Controllers (barriers + entry button)",
|
||||
catReaders: "Readers (QR / RFID)",
|
||||
catCameras: "Cameras (snapshot + plate)",
|
||||
catPrinters: "Printers (tickets / vouchers)",
|
||||
nounController: "controller",
|
||||
nounReader: "reader",
|
||||
nounCamera: "camera",
|
||||
nounPrinter: "printer",
|
||||
add: "+ Add {{noun}}",
|
||||
addAnother: "+ Add another {{noun}}",
|
||||
addTitle: "Add {{noun}}",
|
||||
editTitle: "Edit {{noun}}",
|
||||
needControllerFirst: "Add a controller first — a {{noun}} points at one of its relays.",
|
||||
failedToLoad: "Failed to load setup: {{error}}",
|
||||
loadingCatalog: "Loading device catalog…",
|
||||
dirEntry: "Entry",
|
||||
dirExit: "Exit",
|
||||
dirBoth: "Both (entry + exit)",
|
||||
inherits: "inherits {{direction}}",
|
||||
warnTitle: "⚠ Saved, but action needed:",
|
||||
dismiss: "Dismiss",
|
||||
disabled: "(disabled)",
|
||||
edit: "Edit",
|
||||
remove: "Remove",
|
||||
removing: "Removing…",
|
||||
confirmRemove: "Remove this {{driver}} device?",
|
||||
noRelaysSet: "no relays set",
|
||||
unbound: "unbound",
|
||||
noDrivers: "No drivers registered.",
|
||||
chooseDevice: "Choose a device…",
|
||||
scan: "Scan for controllers",
|
||||
scanning: "Scanning…",
|
||||
noControllersFound: "No controllers found on the LAN.",
|
||||
use: "Use",
|
||||
test: "Test connection",
|
||||
testing: "Testing…",
|
||||
saveConfigure: "Save & configure",
|
||||
saveChanges: "Save changes",
|
||||
saving: "Saving…",
|
||||
cancel: "Cancel",
|
||||
testFailed: "Test failed: {{error}}",
|
||||
saveFailed: "Save failed: {{error}}",
|
||||
deviceLabel: "Device:",
|
||||
preconditionsOk: "● preconditions OK",
|
||||
autoFixedOnSave: "(auto-fixed on save)",
|
||||
backendPushIp: "Backend push IP",
|
||||
chooseAddress: "Choose an address…",
|
||||
onDeviceSubnet: "— on device subnet",
|
||||
noNicOnSubnet: "⚠ no NIC on the device's subnet — the device may not reach the backend",
|
||||
backendIpHint: "The address this device will POST input events to.",
|
||||
relaysTitle: "Relays on this controller",
|
||||
relaysHint:
|
||||
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
||||
relay: "Relay",
|
||||
entryButtonTerminal: "Entry button on terminal",
|
||||
presenceInput: "Presence loop (terminal)",
|
||||
presenceInputHint:
|
||||
"Input terminal the vehicle-presence loop / barrier feedback is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the loop clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
||||
entryCooldown: "Cooldown after ticket (s)",
|
||||
entryCooldownHint:
|
||||
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||
addRelay: "+ Add relay",
|
||||
whichBarrier: "Which barrier does this device serve?",
|
||||
controller: "Controller",
|
||||
choose: "Choose…",
|
||||
relayLabel: "Relay {{relay}} ({{direction}})",
|
||||
noRelaysConfigured: "This controller has no relays configured.",
|
||||
},
|
||||
subs: {
|
||||
title: "Subscriptions",
|
||||
unnamed: "(unnamed)",
|
||||
unbound: "unbound",
|
||||
car_one: "{{count}} car",
|
||||
car_other: "{{count}} cars",
|
||||
cred: "cred",
|
||||
plates: "{{count}} plate(s)",
|
||||
noPrice: "no price",
|
||||
perMonth: "month",
|
||||
monthlyPrice: "Monthly price",
|
||||
pricePlaceholder: "e.g. 10000",
|
||||
edit: "Edit",
|
||||
revoke: "Revoke",
|
||||
delete: "Delete",
|
||||
noneYet: "No subscriptions yet.",
|
||||
add: "+ Add subscription",
|
||||
new: "New subscription",
|
||||
editTitle: "Edit subscription",
|
||||
holderName: "Holder name",
|
||||
contact: "Contact",
|
||||
carLimit: "Car limit",
|
||||
limitCarsInAtOnce: "limit cars in at once",
|
||||
validFrom: "Valid from",
|
||||
validTo: "Valid to",
|
||||
months: "Months",
|
||||
monthsHint: "months paid",
|
||||
coverageHint: "until {{end}}",
|
||||
totalDue: "total {{total}}",
|
||||
validToOverride: "Valid to (manual)",
|
||||
isoDateOptional: "ISO date (optional)",
|
||||
boundPlates: "Bound plates",
|
||||
commaSeparatedOptional: "comma-separated (optional)",
|
||||
credentials: "Credentials",
|
||||
credentialsCardQr: "Credentials (card / QR)",
|
||||
rfCardTag: "RF card/tag",
|
||||
rfCardTagSoon: "RF card/tag (soon)",
|
||||
rfPlaceholder: "card number (or read the card)",
|
||||
readCard: "Read card",
|
||||
captureChooseReader: "Choose a reader, then present the card:",
|
||||
captureNoReaders: "No readers configured.",
|
||||
captureWaiting: "Present the card to the reader…",
|
||||
captureTimeout: "Timed out with no card read. Try again.",
|
||||
captured: "Card read: {{value}}",
|
||||
qr: "QR",
|
||||
qrAutoGen: "QR code is auto-generated on save",
|
||||
credentialValue: "credential value",
|
||||
addCredential: "+ credential",
|
||||
needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.",
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
saved: "Subscription saved.",
|
||||
savedPrinted: "Subscription saved — QR code printed.",
|
||||
savedPrintFailed: "Subscription saved, but printing failed ({{error}}). Use \"Print code\".",
|
||||
printCode: "Print code",
|
||||
printedOn: "Code printed on {{printer}}.",
|
||||
confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
|
||||
confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
|
||||
statusActive: "active",
|
||||
statusSuspended: "suspended",
|
||||
statusRevoked: "revoked",
|
||||
},
|
||||
site: {
|
||||
occupancy: "Occupancy:",
|
||||
noCapacitySet: "(no capacity set)",
|
||||
free: "free",
|
||||
full: "FULL",
|
||||
capacityLabel: "Capacity (blank = no limit):",
|
||||
capacityPlaceholder: "e.g. 120",
|
||||
printExitDefault: "Print exit ticket by default",
|
||||
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
|
||||
parkDetails: "Park details (optional — shown on tickets/receipts)",
|
||||
save: "Save",
|
||||
saved: "Saved.",
|
||||
fieldParkName: "Park name",
|
||||
fieldParkNamePh: "e.g. Acme Parking",
|
||||
fieldOperator: "Operator (legal name)",
|
||||
fieldOperatorPh: "operating company",
|
||||
fieldNius: "NIUS",
|
||||
fieldNiusPh: "e.g. L01234567A",
|
||||
fieldAddress: "Address",
|
||||
fieldPhone: "Phone",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
users: {
|
||||
title: "Users",
|
||||
add: "+ Add user",
|
||||
new: "New user",
|
||||
none: "No users.",
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
passwordHint: "At least 8 characters.",
|
||||
newPassword: "new password",
|
||||
role: "Role",
|
||||
resetPassword: "Reset password",
|
||||
edit: "Edit",
|
||||
editTitle: "Edit user",
|
||||
save: "Save",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Delete user \"{{name}}\"?",
|
||||
detailsSection: "Details (optional)",
|
||||
fullName: "Full name",
|
||||
phone: "Phone",
|
||||
email: "Email",
|
||||
address: "Address",
|
||||
},
|
||||
roles: {
|
||||
title: "Roles",
|
||||
add: "+ Add role",
|
||||
new: "New role",
|
||||
editTitle: "Edit role",
|
||||
name: "Name",
|
||||
permissions: "Permissions",
|
||||
builtin: "built-in",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Delete role \"{{name}}\"?",
|
||||
permCount_one: "{{count}} permission",
|
||||
permCount_other: "{{count}} permissions",
|
||||
userCount_one: "{{count}} user",
|
||||
userCount_other: "{{count}} users",
|
||||
},
|
||||
shift: {
|
||||
label: "Shift:",
|
||||
open: "open",
|
||||
notStarted: "not started",
|
||||
since: "since",
|
||||
startShift: "Start shift",
|
||||
starting: "Starting…",
|
||||
endShift: "End shift",
|
||||
ending: "Ending…",
|
||||
drawer: "Drawer:",
|
||||
openingFloatInherited: "(opening float inherited from the prior shift)",
|
||||
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
|
||||
amount: "amount",
|
||||
reasonPlaceholder: "reason (e.g. opening float)",
|
||||
load: "Load +",
|
||||
remove: "Remove −",
|
||||
enterPositive: "Enter a positive amount.",
|
||||
drawerNow: "Drawer now {{amount}}.",
|
||||
zReport: "Z-REPORT",
|
||||
payments: "Payments:",
|
||||
cash: "Cash:",
|
||||
card: "Card:",
|
||||
drawerSection: "— Drawer —",
|
||||
openingFloat: "Opening float:",
|
||||
cashTaken: "Cash taken:",
|
||||
cashAdded: "Cash added:",
|
||||
cashRemoved: "Cash removed:",
|
||||
expectedDrawer: "Expected drawer:",
|
||||
printedToReceipt: "Printed to booth receipt.",
|
||||
recordedNoPrinter: "Recorded (no printer to print to).",
|
||||
// Header shift control + the booth shift gate.
|
||||
headerNoShift: "No shift",
|
||||
headerOpen: "Open shift",
|
||||
headerClose: "Close shift",
|
||||
headerHeldBy: "Shift open — {{operator}}",
|
||||
headerHeldByShort: "Shift: {{operator}}",
|
||||
gateTitle: "Open a shift to process tickets",
|
||||
gateBody:
|
||||
"No shift is open. Open your shift so payments and exits are recorded against it.",
|
||||
gateOtherTitle: "The open shift belongs to another operator",
|
||||
gateOtherBody:
|
||||
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
|
||||
openNow: "Open shift now",
|
||||
opening: "Opening…",
|
||||
},
|
||||
shifts: {
|
||||
title: "Shift history",
|
||||
myTitle: "My shifts",
|
||||
none: "No closed shifts.",
|
||||
operator: "Operator",
|
||||
started: "Started",
|
||||
ended: "Ended",
|
||||
payments: "Payments",
|
||||
cash: "Cash",
|
||||
card: "Card",
|
||||
expectedDrawer: "Expected drawer",
|
||||
filterFrom: "From",
|
||||
filterTo: "To",
|
||||
allOperators: "All operators",
|
||||
apply: "Apply",
|
||||
clear: "Clear",
|
||||
drawerSection: "Drawer",
|
||||
openingFloat: "Opening float",
|
||||
cashTaken: "Cash taken",
|
||||
cashAdded: "Cash added",
|
||||
cashRemoved: "Cash removed",
|
||||
loadFailed: "Failed to load shifts.",
|
||||
},
|
||||
logs: {
|
||||
title: "System logs",
|
||||
refresh: "Refresh",
|
||||
level: "Level",
|
||||
source: "Source",
|
||||
since: "Since",
|
||||
apply: "Apply",
|
||||
clear: "Clear",
|
||||
allLevels: "All levels",
|
||||
allSources: "All sources",
|
||||
frontend: "Frontend",
|
||||
backend: "Backend",
|
||||
time: "Time",
|
||||
message: "Message",
|
||||
status: "Status",
|
||||
path: "Path",
|
||||
empty: "No logs.",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Ticket",
|
||||
entry: "Entry",
|
||||
now: "Now",
|
||||
duration: "Duration",
|
||||
statusLabel: "Status",
|
||||
paid: "PAID",
|
||||
unpaid: "UNPAID",
|
||||
total: "Total",
|
||||
noTariff: "no tariff",
|
||||
tender: "Tender",
|
||||
cash: "Cash",
|
||||
card: "Card",
|
||||
printExitVoucher: "Print exit ticket",
|
||||
selfExitHint: "(customer self-exits at the exit)",
|
||||
payAndOpen: "Pay + open barrier",
|
||||
payAndVoucher: "Pay + print voucher",
|
||||
openBarrier: "Open barrier",
|
||||
printVoucher: "Print voucher",
|
||||
takingPayment: "taking payment…",
|
||||
printingVoucher: "printing voucher…",
|
||||
opening: "opening…",
|
||||
noSessionFound: "No session found for this ticket.",
|
||||
alreadyClosed: "This session is already closed (exited {{time}}).",
|
||||
lookingUp: "looking up…",
|
||||
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
||||
subscription: "SUBSCRIPTION",
|
||||
plan: "Plan",
|
||||
prepaid: "PREPAID",
|
||||
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
|
||||
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
|
||||
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
|
||||
// payment receipt (transparency slip)
|
||||
receiptPrintFailed: "(receipt didn't print — use \"Reprint receipt\".)",
|
||||
receiptReprinted: "Receipt reprinted on {{printer}}.",
|
||||
reprintReceipt: "Reprint receipt",
|
||||
reprinting: "printing…",
|
||||
noSnapshots: "no snapshots",
|
||||
loadingSnapshots: "loading snapshots…",
|
||||
snapEntry: "entry",
|
||||
snapExit: "exit",
|
||||
snapFailed: "camera unreachable",
|
||||
},
|
||||
};
|
||||
@@ -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,578 @@
|
||||
// Albanian (sq) — the DEFAULT and fallback language. Customer/operator-facing copy.
|
||||
// Keys are dot-namespaced by area (common, nav, booth, …). When adding a string,
|
||||
// add it here first (the fallback), then mirror the key in en.ts.
|
||||
// See wiki/concepts/i18n.md.
|
||||
|
||||
export const sq = {
|
||||
common: {
|
||||
loading: "Duke u ngarkuar…",
|
||||
logout: "Dil",
|
||||
cancel: "Anulo",
|
||||
close: "Mbyll",
|
||||
save: "Ruaj",
|
||||
none: "—",
|
||||
themeDark: "errët",
|
||||
themeLight: "çelët",
|
||||
theme: "Tema",
|
||||
today: "Sot",
|
||||
yesterday: "Dje",
|
||||
// Month names (index 0 = January) — kept in the catalog because the appliance's
|
||||
// browser ICU may lack Albanian locale data (Intl falls back to English).
|
||||
months: [
|
||||
"Janar",
|
||||
"Shkurt",
|
||||
"Mars",
|
||||
"Prill",
|
||||
"Maj",
|
||||
"Qershor",
|
||||
"Korrik",
|
||||
"Gusht",
|
||||
"Shtator",
|
||||
"Tetor",
|
||||
"Nëntor",
|
||||
"Dhjetor",
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
title: "Sistemi i Parkimit",
|
||||
username: "Përdoruesi",
|
||||
password: "Fjalëkalimi",
|
||||
signIn: "Hyr",
|
||||
signingIn: "Duke hyrë…",
|
||||
},
|
||||
nav: {
|
||||
booth: "Kabina",
|
||||
shift: "Turni",
|
||||
setup: "Konfigurimi",
|
||||
devices: "Pajisjet",
|
||||
tariff: "Tarifa",
|
||||
subscriptions: "Abonimet",
|
||||
site: "Park",
|
||||
users: "Përdoruesit",
|
||||
roles: "Rolet",
|
||||
shifts: "Turnet",
|
||||
logs: "Regjistrat",
|
||||
},
|
||||
status: {
|
||||
live: "LIVE",
|
||||
connecting: "DUKE U LIDHUR",
|
||||
offline: "JASHTË LINJE",
|
||||
},
|
||||
devices: {
|
||||
footerTitle: "Pajisjet",
|
||||
none: "Asnjë pajisje e konfiguruar.",
|
||||
catAccess: "Barriera",
|
||||
catReader: "Lexuesi",
|
||||
catCamera: "Kamera",
|
||||
catPrinter: "Printer",
|
||||
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
|
||||
role: {
|
||||
entry: "hyrje",
|
||||
exit: "dalje",
|
||||
both: "hyrje/dalje",
|
||||
mixed: "hyrje/dalje",
|
||||
lane: "në korsi",
|
||||
booth: "në kabinë",
|
||||
},
|
||||
state: {
|
||||
ready: "gati",
|
||||
degraded: "i dëmtuar",
|
||||
offline: "jashtë linje",
|
||||
},
|
||||
allOk: "të gjitha gati",
|
||||
issuesCount: "{{count}} me probleme",
|
||||
issuesTitle: "Problemet e pajisjeve",
|
||||
clickForIssues: "Kliko për detajet",
|
||||
checkedAt: "kontrolluar {{time}}",
|
||||
},
|
||||
booth: {
|
||||
processTicket: "Proceso biletën",
|
||||
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||
open: "Hap",
|
||||
occupancy: "Prania",
|
||||
occUnavailable: "zënia e padisponueshme",
|
||||
inside: "brenda",
|
||||
of: "nga",
|
||||
uncapped: "pa kufi",
|
||||
free: "Vende të lira",
|
||||
lotFull: "● parkimi plot",
|
||||
liveFeed: "Aktiviteti live",
|
||||
events: "Evente",
|
||||
noEventsYet: "Asnjë event ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||
activeSessions: "Sesionet aktive",
|
||||
insideCount: "brenda",
|
||||
noActiveSessions: "Asnjë sesion aktiv.",
|
||||
openPayExit: "Hap pagesën / daljen",
|
||||
openBarrier: "Hap barrierën",
|
||||
openBarrierTitle: "Hap barrierën manualisht",
|
||||
barrierOpened: "barriera u hap",
|
||||
openManually: "hape me dorë",
|
||||
// session row badges
|
||||
badgeExiting: "duke dalë",
|
||||
badgePaid: "paguar",
|
||||
badgeUnpaid: "papaguar",
|
||||
badgeSubscription: "abonim",
|
||||
// event types (live feed labels)
|
||||
evtEntry: "HYRJE",
|
||||
evtExit: "DALJE",
|
||||
evtPay: "PAGESË",
|
||||
evtVoid: "ANULIM",
|
||||
evtOpenCmd: "HAP→",
|
||||
evtOpenObserved: "HAP✓",
|
||||
evtShiftOpen: "TURN+",
|
||||
evtShiftZ: "TURN Z",
|
||||
evtCashMovement: "ARKË",
|
||||
evtAnomaly: "ANOMALI",
|
||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||
evtNoReason: "pa arsye të regjistruar",
|
||||
badgeEntryRefused: "hyrje e refuzuar",
|
||||
badgeExitRefused: "dalje e refuzuar",
|
||||
badgeLotFull: "parkimi plot",
|
||||
badgeBarrierFailed: "barriera nuk u hap",
|
||||
badgeManualOpen: "hapje manuale",
|
||||
badgeSubRefused: "abonimi u refuzua",
|
||||
badgeNoTicket: "bileta nuk u printua",
|
||||
feedSourceBooth: "kabinë",
|
||||
feedSourceReader: "lexues",
|
||||
// dritarja e detajeve të eventit
|
||||
eventDetail: "Detajet e eventit",
|
||||
edType: "Lloji",
|
||||
edTime: "Ora",
|
||||
edIndex: "Indeksi në regjistër",
|
||||
edDirection: "Drejtimi",
|
||||
edSource: "Burimi",
|
||||
edIdentity: "Identiteti",
|
||||
edReason: "Arsyeja",
|
||||
edSnapshots: "Fotot",
|
||||
edPayload: "Të dhënat e nënshkruara",
|
||||
edChain: "Zinxhiri",
|
||||
edSignature: "Nënshkrimi",
|
||||
edKeyId: "Çelësi",
|
||||
edPrevHash: "Hash-i i mëparshëm",
|
||||
edNoPayload: "Ky event nuk ka të dhëna shtesë.",
|
||||
edCopy: "Kopjo",
|
||||
edCopied: "U kopjua",
|
||||
edDetails: "Detajet",
|
||||
edAuditData: "Të dhënat e auditimit (nënshkrimi & zinxhiri)",
|
||||
edAmount: "Shuma",
|
||||
edTender: "Mënyra e pagesës",
|
||||
edSession: "Sesioni",
|
||||
edPlate: "Targa",
|
||||
edCategory: "Kategoria",
|
||||
edOperator: "Operatori",
|
||||
edTariffVersion: "Versioni i tarifës",
|
||||
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
|
||||
edOccurrence: "ID e hyrjes",
|
||||
subscriber: "Abonent",
|
||||
edVia: "Mënyra e hyrjes",
|
||||
viaQr: "Kod QR",
|
||||
viaCard: "Kartë/çip RFID",
|
||||
viaPlate: "Targë",
|
||||
},
|
||||
// Mesazhet e përkthyera për REASON_CODES e nënshkruara (shih @parking/shared).
|
||||
// Çelësat përputhen 1:1 me kodet; {{param}} mbushet nga reasonParams i eventit.
|
||||
reason: {
|
||||
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
|
||||
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
|
||||
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
|
||||
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
||||
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
||||
"exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)",
|
||||
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape me dorë",
|
||||
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape me dorë",
|
||||
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape me dorë",
|
||||
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
|
||||
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
|
||||
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
|
||||
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
|
||||
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
||||
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tarifa",
|
||||
noRateCard: "Asnjë tarifë e publikuar — arka nuk mund të faturojë derisa të publikoni një.",
|
||||
activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.",
|
||||
currency: "Monedha",
|
||||
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
|
||||
billingIncrement: "Intervali i faturimit (min)",
|
||||
dailyCap: "Kufiri ditor (bosh = pa kufi)",
|
||||
dailyCapPh: "p.sh. 12.00",
|
||||
lostTicketFee: "Tarifa për biletë të humbur",
|
||||
exitGrace: "Periudha e kthimit në dalje (min)",
|
||||
rateBlocks: "Blloqet tarifore",
|
||||
rateBlocksHint: "Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.",
|
||||
bandDuration: "Kohëzgjatja e brezit",
|
||||
hoursUnit: "orë",
|
||||
egHours: "p.sh. 2",
|
||||
pricePerIncrement: "Çmimi / interval",
|
||||
thereafter: "më pas (i hapur)",
|
||||
remove: "Hiq",
|
||||
addBlock: "+ Shto bllok",
|
||||
publishNewVersion: "Publiko version të ri",
|
||||
publishing: "Duke publikuar…",
|
||||
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
||||
defaultCard: "Tarifa bazë (gjithmonë aktive)",
|
||||
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
||||
modeLadder: "Shkallë orësh",
|
||||
modeFlat: "Çmim fiks",
|
||||
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
|
||||
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
|
||||
tierName: "Emri",
|
||||
tierPriority: "Përparësia",
|
||||
tierCategory: "Kategoria",
|
||||
tierCategoryPh: "p.sh. autobus",
|
||||
tierDays: "Ditët",
|
||||
tierHours: "Orët",
|
||||
tierDates: "Datat",
|
||||
tierOvernight: "(kalon mesnatën)",
|
||||
addTier: "+ Shto nivel",
|
||||
dow1: "Hën",
|
||||
dow2: "Mar",
|
||||
dow3: "Mër",
|
||||
dow4: "Enj",
|
||||
dow5: "Pre",
|
||||
dow6: "Sht",
|
||||
dow0: "Die",
|
||||
},
|
||||
setup: {
|
||||
title: "Konfigurimi",
|
||||
intro:
|
||||
"Shto fillimisht kontrolluesit e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
|
||||
// Category titles + the singular noun used in buttons/modal titles.
|
||||
catControllers: "Kontrolluesit (barrierat + butoni i hyrjes)",
|
||||
catReaders: "Lexuesit (QR / RFID)",
|
||||
catCameras: "Kamerat (foto + targë)",
|
||||
catPrinters: "Printerat (bileta / vouchera)",
|
||||
nounController: "kontrollues",
|
||||
nounReader: "lexues",
|
||||
nounCamera: "kamerë",
|
||||
nounPrinter: "printer",
|
||||
add: "+ Shto {{noun}}",
|
||||
addAnother: "+ Shto edhe një {{noun}}",
|
||||
addTitle: "Shto {{noun}}",
|
||||
editTitle: "Ndrysho {{noun}}",
|
||||
needControllerFirst: "Shto fillimisht një kontrollues — {{noun}} drejtohet te një prej releve të tij.",
|
||||
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
|
||||
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
|
||||
// Direction labels (relay direction + inherited binding).
|
||||
dirEntry: "Hyrje",
|
||||
dirExit: "Dalje",
|
||||
dirBoth: "Hyrje + dalje",
|
||||
inherits: "trashëgon {{direction}}",
|
||||
// Warnings panel.
|
||||
warnTitle: "⚠ U ruajt, por nevojitet veprim:",
|
||||
dismiss: "Mbyll",
|
||||
// Assignment row.
|
||||
disabled: "(çaktivizuar)",
|
||||
edit: "Ndrysho",
|
||||
remove: "Hiq",
|
||||
removing: "Duke hequr…",
|
||||
confirmRemove: "Të hiqet kjo pajisje {{driver}}?",
|
||||
noRelaysSet: "asnjë rele e caktuar",
|
||||
unbound: "e palidhur",
|
||||
// Device form.
|
||||
noDrivers: "Asnjë drejtues i regjistruar.",
|
||||
chooseDevice: "Zgjidh një pajisje…",
|
||||
scan: "Skano për kontrollues",
|
||||
scanning: "Duke skanuar…",
|
||||
noControllersFound: "Asnjë kontrollues në LAN.",
|
||||
use: "Përdor",
|
||||
test: "Testo lidhjen",
|
||||
testing: "Duke testuar…",
|
||||
saveConfigure: "Ruaj & konfiguro",
|
||||
saveChanges: "Ruaj ndryshimet",
|
||||
saving: "Duke ruajtur…",
|
||||
cancel: "Anulo",
|
||||
testFailed: "Testi dështoi: {{error}}",
|
||||
saveFailed: "Ruajtja dështoi: {{error}}",
|
||||
deviceLabel: "Pajisja:",
|
||||
preconditionsOk: "● parakushtet OK",
|
||||
autoFixedOnSave: "(rregullohet vetë në ruajtje)",
|
||||
backendPushIp: "IP-ja e backend-it",
|
||||
chooseAddress: "Zgjidh një adresë…",
|
||||
onDeviceSubnet: "— në subnetin e pajisjes",
|
||||
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
|
||||
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
|
||||
// Relay editor.
|
||||
relaysTitle: "Relet në këtë kontrollues",
|
||||
relaysHint:
|
||||
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
||||
relay: "Rele",
|
||||
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
||||
presenceInput: "Sensori i pranisë (terminali)",
|
||||
presenceInputHint:
|
||||
"Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.",
|
||||
entryCooldown: "Pritje pas biletës (sek)",
|
||||
entryCooldownHint:
|
||||
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
||||
addRelay: "+ Shto rele",
|
||||
// Binding picker.
|
||||
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
||||
controller: "Kontrolluesi",
|
||||
choose: "Zgjidh…",
|
||||
relayLabel: "Rele {{relay}} ({{direction}})",
|
||||
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
|
||||
},
|
||||
subs: {
|
||||
title: "Abonimet",
|
||||
unnamed: "(pa emër)",
|
||||
unbound: "pa kufizim",
|
||||
car_one: "{{count}} makinë",
|
||||
car_other: "{{count}} makina",
|
||||
cred: "kredencial",
|
||||
plates: "{{count}} targë(a)",
|
||||
noPrice: "pa çmim",
|
||||
perMonth: "muaj",
|
||||
monthlyPrice: "Çmimi mujor",
|
||||
pricePlaceholder: "p.sh. 10000",
|
||||
edit: "Ndrysho",
|
||||
revoke: "Anulo",
|
||||
delete: "Fshij",
|
||||
noneYet: "Asnjë abonim ende.",
|
||||
add: "+ Shto abonim",
|
||||
new: "Abonim i ri",
|
||||
editTitle: "Ndrysho abonimin",
|
||||
holderName: "Emri i mbajtësit",
|
||||
contact: "Kontakti",
|
||||
carLimit: "Kufiri i makinave",
|
||||
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
|
||||
validFrom: "Vlen nga",
|
||||
validTo: "Vlen deri",
|
||||
months: "Muaj",
|
||||
monthsHint: "muaj të paguar",
|
||||
coverageHint: "deri më {{end}}",
|
||||
totalDue: "gjithsej {{total}}",
|
||||
validToOverride: "Vlen deri (manual)",
|
||||
isoDateOptional: "Datë ISO (opsionale)",
|
||||
boundPlates: "Targat e lidhura",
|
||||
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
||||
credentials: "Kredencialet",
|
||||
credentialsCardQr: "Kredencialet (kartë / QR)",
|
||||
rfCardTag: "Kartë/Tag RF",
|
||||
rfCardTagSoon: "Kartë/Tag RF (së shpejti)",
|
||||
rfPlaceholder: "numri i kartës (ose lexo kartën)",
|
||||
readCard: "Lexo kartën",
|
||||
captureChooseReader: "Zgjidh lexuesin, pastaj afro kartën:",
|
||||
captureNoReaders: "Asnjë lexues i konfiguruar.",
|
||||
captureWaiting: "Afro kartën te lexuesi…",
|
||||
captureTimeout: "Skadoi pa lexuar kartë. Provo sërish.",
|
||||
captured: "Karta u lexua: {{value}}",
|
||||
qr: "QR",
|
||||
qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje",
|
||||
credentialValue: "vlera e kredencialit",
|
||||
addCredential: "+ kredencial",
|
||||
needCredentialOrPlate: "Një abonim kërkon të paktën një kredencial OSE një targë të lidhur.",
|
||||
save: "Ruaj",
|
||||
cancel: "Anulo",
|
||||
saved: "Abonimi u ruajt.",
|
||||
savedPrinted: "Abonimi u ruajt — kodi QR u printua.",
|
||||
savedPrintFailed: "Abonimi u ruajt, por printimi dështoi ({{error}}). Përdor \"Printo kodin\".",
|
||||
printCode: "Printo kodin",
|
||||
printedOn: "Kodi u printua te {{printer}}.",
|
||||
confirmRevoke: "Të anulohet abonimi për {{name}}? Do të refuzohet te barriera.",
|
||||
confirmDelete: "Të fshihet abonimi për {{name}}? (t e kaluara ruhen.)",
|
||||
statusActive: "aktiv",
|
||||
statusSuspended: "pezulluar",
|
||||
statusRevoked: "anuluar",
|
||||
},
|
||||
site: {
|
||||
occupancy: "Prania:",
|
||||
noCapacitySet: "(pa kapacitet të caktuar)",
|
||||
free: "lirë",
|
||||
full: "PLOT",
|
||||
capacityLabel: "Kapaciteti (bosh = pa kufi):",
|
||||
capacityPlaceholder: "p.sh. 120",
|
||||
printExitDefault: "Printo biletën e daljes si parazgjedhje",
|
||||
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
|
||||
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
||||
save: "Ruaj",
|
||||
saved: "U ruajt.",
|
||||
fieldParkName: "Emri i parkimit",
|
||||
fieldParkNamePh: "p.sh. Acme Parking",
|
||||
fieldOperator: "Operatori (emri ligjor)",
|
||||
fieldOperatorPh: "kompania operuese",
|
||||
fieldNius: "NIUS",
|
||||
fieldNiusPh: "p.sh. L01234567A",
|
||||
fieldAddress: "Adresa",
|
||||
fieldPhone: "Telefoni",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
users: {
|
||||
title: "Përdoruesit",
|
||||
add: "+ Shto përdorues",
|
||||
new: "Përdorues i ri",
|
||||
none: "Asnjë përdorues.",
|
||||
username: "Përdoruesi",
|
||||
password: "Fjalëkalimi",
|
||||
passwordHint: "Të paktën 8 karaktere.",
|
||||
newPassword: "fjalëkalim i ri",
|
||||
role: "Roli",
|
||||
resetPassword: "Rivendos fjalëkalimin",
|
||||
edit: "Ndrysho",
|
||||
editTitle: "Ndrysho përdoruesin",
|
||||
save: "Ruaj",
|
||||
delete: "Fshi",
|
||||
confirmDelete: "Të fshihet përdoruesi \"{{name}}\"?",
|
||||
// Optional profile metadata.
|
||||
detailsSection: "Të dhënat (opsionale)",
|
||||
fullName: "Emri i plotë",
|
||||
phone: "Telefoni",
|
||||
email: "Email",
|
||||
address: "Adresa",
|
||||
},
|
||||
roles: {
|
||||
title: "Rolet",
|
||||
add: "+ Shto rol",
|
||||
new: "Rol i ri",
|
||||
editTitle: "Ndrysho rolin",
|
||||
name: "Emri",
|
||||
permissions: "Lejet",
|
||||
builtin: "i integruar",
|
||||
edit: "Ndrysho",
|
||||
delete: "Fshi",
|
||||
confirmDelete: "Të fshihet roli \"{{name}}\"?",
|
||||
permCount_one: "{{count}} leje",
|
||||
permCount_other: "{{count}} leje",
|
||||
userCount_one: "{{count}} përdorues",
|
||||
userCount_other: "{{count}} përdorues",
|
||||
},
|
||||
shift: {
|
||||
label: "Turni:",
|
||||
open: "hapur",
|
||||
notStarted: "i panisur",
|
||||
since: "që nga",
|
||||
startShift: "Fillo turnin",
|
||||
starting: "Duke filluar…",
|
||||
endShift: "Mbyll turnin",
|
||||
ending: "Duke mbyllur…",
|
||||
drawer: "Arka:",
|
||||
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||
amount: "shuma",
|
||||
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
|
||||
load: "Shto +",
|
||||
remove: "Hiq −",
|
||||
enterPositive: "Shkruaj një shumë pozitive.",
|
||||
drawerNow: "Arka tani {{amount}}.",
|
||||
zReport: "RAPORT Z",
|
||||
payments: "Pagesa:",
|
||||
cash: "Para:",
|
||||
card: "Kartë:",
|
||||
drawerSection: "— Arka —",
|
||||
openingFloat: "Bilanci fillestar:",
|
||||
cashTaken: "Para të marra:",
|
||||
cashAdded: "Para të shtuara:",
|
||||
cashRemoved: "Para të hequra:",
|
||||
expectedDrawer: "Arka e pritshme:",
|
||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||
// Header shift control + the booth shift gate.
|
||||
headerNoShift: "Asnjë turn",
|
||||
headerOpen: "Hap turnin",
|
||||
headerClose: "Mbyll turnin",
|
||||
headerHeldBy: "Turn i hapur nga {{operator}}",
|
||||
headerHeldByShort: "Turni: {{operator}}",
|
||||
gateTitle: "Hap një turn për të proceduar biletat",
|
||||
gateBody:
|
||||
"Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.",
|
||||
gateOtherTitle: "Turni i hapur i përket një operatori tjetër",
|
||||
gateOtherBody:
|
||||
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
|
||||
openNow: "Hap turnin tani",
|
||||
opening: "Duke hapur…",
|
||||
},
|
||||
shifts: {
|
||||
title: "Historiku i turneve",
|
||||
myTitle: "Turnet e mia",
|
||||
none: "Asnjë turn i mbyllur.",
|
||||
operator: "Operatori",
|
||||
started: "Filloi",
|
||||
ended: "Mbaroi",
|
||||
payments: "Pagesa",
|
||||
cash: "Para",
|
||||
card: "Kartë",
|
||||
expectedDrawer: "Arka e pritshme",
|
||||
// Filter (admin only).
|
||||
filterFrom: "Nga",
|
||||
filterTo: "Deri",
|
||||
allOperators: "Të gjithë operatorët",
|
||||
apply: "Apliko",
|
||||
clear: "Pastro",
|
||||
// Expanded drawer detail.
|
||||
drawerSection: "Arka",
|
||||
openingFloat: "Bilanci fillestar",
|
||||
cashTaken: "Para të marra",
|
||||
cashAdded: "Para të shtuara",
|
||||
cashRemoved: "Para të hequra",
|
||||
loadFailed: "Ngarkimi i turneve dështoi.",
|
||||
},
|
||||
logs: {
|
||||
title: "Regjistrat e sistemit",
|
||||
refresh: "Rifresko",
|
||||
level: "Niveli",
|
||||
source: "Burimi",
|
||||
since: "Që nga",
|
||||
apply: "Apliko",
|
||||
clear: "Pastro",
|
||||
allLevels: "Të gjitha nivelet",
|
||||
allSources: "Të gjitha burimet",
|
||||
frontend: "Ndërfaqja",
|
||||
backend: "Serveri",
|
||||
time: "Koha",
|
||||
message: "Mesazhi",
|
||||
status: "Statusi",
|
||||
path: "Rruga",
|
||||
empty: "Asnjë regjistër.",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Bileta",
|
||||
entry: "Hyrja",
|
||||
now: "Tani",
|
||||
duration: "Kohëzgjatja",
|
||||
statusLabel: "Statusi",
|
||||
paid: "PAGUAR",
|
||||
unpaid: "PAPAGUAR",
|
||||
total: "Totali",
|
||||
noTariff: "pa tarifë",
|
||||
tender: "Mënyra",
|
||||
cash: "Para",
|
||||
card: "Kartë",
|
||||
printExitVoucher: "Printo biletë dalje",
|
||||
selfExitHint: "(klienti del vetë te dalja)",
|
||||
payAndOpen: "Paguaj + hap barrierën",
|
||||
payAndVoucher: "Paguaj + printo biletën",
|
||||
openBarrier: "Hap barrierën",
|
||||
printVoucher: "Printo biletën",
|
||||
takingPayment: "Duke marrë pagesën…",
|
||||
printingVoucher: "Duke printuar biletën…",
|
||||
opening: "Duke hapur…",
|
||||
noSessionFound: "Nuk u gjet asnjë sesion për këtë biletë.",
|
||||
alreadyClosed: "Ky sesion është mbyllur tashmë (doli {{time}}).",
|
||||
lookingUp: "Duke kërkuar…",
|
||||
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||
subscription: "ABONIM",
|
||||
plan: "Plani",
|
||||
prepaid: "I PARAPAGUAR",
|
||||
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
|
||||
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||
// payment receipt (transparency slip)
|
||||
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
|
||||
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
||||
reprintReceipt: "Riprinto faturën",
|
||||
reprinting: "duke printuar…",
|
||||
// snapshots
|
||||
noSnapshots: "asnjë foto",
|
||||
loadingSnapshots: "duke ngarkuar fotot…",
|
||||
snapEntry: "hyrje",
|
||||
snapExit: "dalje",
|
||||
snapFailed: "kamera e paarritshme",
|
||||
},
|
||||
};
|
||||
|
||||
// The catalog SHAPE (keys + nesting), with string-typed values — so en.ts must
|
||||
// supply every key but may differ in value. (Not `typeof sq` with `as const`, which
|
||||
// would pin en.ts to the Albanian literals.)
|
||||
type Stringify<T> = { [K in keyof T]: T[K] extends object ? Stringify<T[K]> : string };
|
||||
export type Catalog = Stringify<typeof sq>;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { create } from "zustand";
|
||||
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||
|
||||
// CLIENT state for the live booth feed — deliberately small. Server data (the
|
||||
// authoritative event list, occupancy totals) is owned by TanStack Query; this
|
||||
// store holds only what Query shouldn't: the WS connection status, the latest
|
||||
// pushed occupancy snapshot, and a rolling in-memory tail of recent events for the
|
||||
// live ticker. Anything durable is re-fetched via Query. See lib/query.ts.
|
||||
|
||||
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
|
||||
export type WsStatus = "connecting" | "open" | "closed";
|
||||
|
||||
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
||||
* unbounded — the full history is always available via the /api/events query. */
|
||||
const MAX_FEED = 200;
|
||||
|
||||
interface LiveState {
|
||||
status: WsStatus;
|
||||
/** Most recent occupancy pushed by the server (rides on every ledger event). */
|
||||
occupancy: Occupancy | null;
|
||||
/** Newest-first tail of recently pushed ledger events (for the live ticker). */
|
||||
feed: LedgerEvent[];
|
||||
/** Live device status keyed by device id (for the footer): set from the WS
|
||||
* hello snapshot, then upserted per device on each device-status push. */
|
||||
devices: Record<string, DeviceStatus>;
|
||||
setStatus: (s: WsStatus) => void;
|
||||
setOccupancy: (o: Occupancy) => void;
|
||||
pushEvent: (e: LedgerEvent) => void;
|
||||
/** Replace the whole device-status set (WS hello / reconnect snapshot). */
|
||||
setDevices: (list: DeviceStatus[]) => void;
|
||||
/** Upsert one device's status (a device-status push). */
|
||||
upsertDevice: (d: DeviceStatus) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/** Index a device-status list by device id. */
|
||||
function byId(list: DeviceStatus[]): Record<string, DeviceStatus> {
|
||||
const m: Record<string, DeviceStatus> = {};
|
||||
for (const d of list) m[d.deviceId] = d;
|
||||
return m;
|
||||
}
|
||||
|
||||
export const useLiveStore = create<LiveState>((set) => ({
|
||||
status: "connecting",
|
||||
occupancy: null,
|
||||
feed: [],
|
||||
devices: {},
|
||||
setStatus: (status) => set({ status }),
|
||||
setOccupancy: (occupancy) => set({ occupancy }),
|
||||
pushEvent: (e) =>
|
||||
set((s) => ({
|
||||
// Newest first; de-dupe by id (a reconnect can replay) and cap the length.
|
||||
feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED),
|
||||
})),
|
||||
setDevices: (list) => set({ devices: byId(list) }),
|
||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
||||
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }),
|
||||
}));
|
||||
@@ -0,0 +1,198 @@
|
||||
// Frontend error/log collector. Ships failed requests, uncaught errors, and rejected
|
||||
// promises to the backend (POST /api/logs → app_logs), so a booth problem is
|
||||
// diagnosable from the host instead of needing the operator's devtools. See
|
||||
// wiki/concepts/app-logs.md.
|
||||
//
|
||||
// Design notes:
|
||||
// - BATCHED + THROTTLED: entries queue and flush on a short timer (and on page hide
|
||||
// via sendBeacon), so a burst of errors is one request, not hundreds.
|
||||
// - LOOP-SAFE: a failure of the /api/logs request itself is NEVER re-logged (that
|
||||
// would be an infinite error → log → error spiral). We also never recurse through
|
||||
// apiFetch — the flush uses raw fetch/sendBeacon.
|
||||
// - LEVEL-GATED noise: console.warn/error are only forwarded when the client log
|
||||
// level is debug/trace (off by default) — they're noisy (3rd-party chatter). The
|
||||
// high-signal sources (failed requests, uncaught errors) are always captured.
|
||||
|
||||
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
||||
|
||||
const ENDPOINT = "/api/logs";
|
||||
const FLUSH_MS = 4000;
|
||||
const MAX_QUEUE = 100; // drop oldest beyond this (bounded memory on a long-lived booth)
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
const CSRF_HEADER = "X-CSRF-Token";
|
||||
|
||||
/** The client capture threshold. Entries below this level are dropped before queueing.
|
||||
* Default `info`: failed requests (error) + uncaught errors (error) always pass;
|
||||
* console.warn/error forwarding is wired separately and only ON at debug/trace. */
|
||||
let clientLevel: LogLevel = (import.meta.env.VITE_LOG_LEVEL as LogLevel) || "info";
|
||||
|
||||
export function setClientLogLevel(level: LogLevel): void {
|
||||
clientLevel = level;
|
||||
}
|
||||
export function getClientLogLevel(): LogLevel {
|
||||
return clientLevel;
|
||||
}
|
||||
/** Are console.warn/error forwarded? Only when the client level is debug or trace. */
|
||||
function consoleForwardEnabled(): boolean {
|
||||
return LOG_LEVEL_ORDER[clientLevel] <= LOG_LEVEL_ORDER.debug;
|
||||
}
|
||||
|
||||
const queue: ClientLogInput[] = [];
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Set true only while flushing, so the flush's own network activity is never logged. */
|
||||
let flushing = false;
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return m ? decodeURIComponent(m[1]!) : null;
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (timer != null) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
void flush();
|
||||
}, FLUSH_MS);
|
||||
}
|
||||
|
||||
/** Enqueue an entry. Drops it if below the client level or if it concerns the log
|
||||
* endpoint itself (loop guard). */
|
||||
export function logClient(entry: ClientLogInput): void {
|
||||
if (LOG_LEVEL_ORDER[entry.level] < LOG_LEVEL_ORDER[clientLevel]) return;
|
||||
if (flushing) return; // don't log anything produced by the flush itself
|
||||
if (entry.path && entry.path.startsWith(ENDPOINT)) return; // never log the log call
|
||||
queue.push({ ...entry, at: entry.at ?? new Date().toISOString() });
|
||||
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
/** POST the queued entries. Raw fetch (not apiFetch) so a failure can't recurse. A
|
||||
* failed flush silently re-queues nothing — diagnostics are best-effort, never fatal. */
|
||||
async function flush(): Promise<void> {
|
||||
if (queue.length === 0) return;
|
||||
const entries = queue.splice(0, queue.length);
|
||||
flushing = true;
|
||||
try {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers[CSRF_HEADER] = csrf;
|
||||
await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ entries }),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// Drop on failure — we must not re-log (loop) nor grow unbounded.
|
||||
} finally {
|
||||
flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). */
|
||||
function flushBeacon(): void {
|
||||
if (queue.length === 0) return;
|
||||
const entries = queue.splice(0, queue.length);
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify({ entries })], { type: "application/json" });
|
||||
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
||||
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
||||
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
||||
navigator.sendBeacon(ENDPOINT, blob);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a FAILED API request (called from apiFetch's error path). Always high-signal. */
|
||||
export function logFailedRequest(info: {
|
||||
path: string;
|
||||
method: string;
|
||||
status: number;
|
||||
error?: string;
|
||||
requestId?: string;
|
||||
}): void {
|
||||
logClient({
|
||||
level: "error",
|
||||
message: `${info.method} ${info.path} → ${info.status}${info.error ? `: ${info.error}` : ""}`,
|
||||
httpStatus: info.status,
|
||||
path: info.path,
|
||||
context: { kind: "request_failed", method: info.method, requestId: info.requestId },
|
||||
});
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** Wire global handlers once, at app startup. Idempotent. */
|
||||
export function installClientLogging(): void {
|
||||
if (installed || typeof window === "undefined") return;
|
||||
installed = true;
|
||||
|
||||
// Uncaught runtime errors.
|
||||
window.addEventListener("error", (e: ErrorEvent) => {
|
||||
logClient({
|
||||
level: "error",
|
||||
message: e.message || "uncaught error",
|
||||
stack: e.error?.stack,
|
||||
path: location.pathname,
|
||||
context: {
|
||||
kind: "window_error",
|
||||
filename: e.filename,
|
||||
line: e.lineno,
|
||||
col: e.colno,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Unhandled promise rejections.
|
||||
window.addEventListener("unhandledrejection", (e: PromiseRejectionEvent) => {
|
||||
const reason = e.reason;
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "unhandled rejection";
|
||||
logClient({
|
||||
level: "error",
|
||||
message,
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
path: location.pathname,
|
||||
context: { kind: "unhandled_rejection" },
|
||||
});
|
||||
});
|
||||
|
||||
// console.warn / console.error → only forwarded at debug/trace (noisy otherwise).
|
||||
const origWarn = console.warn.bind(console);
|
||||
const origError = console.error.bind(console);
|
||||
console.warn = (...args: unknown[]) => {
|
||||
origWarn(...args);
|
||||
if (consoleForwardEnabled()) {
|
||||
logClient({ level: "warn", message: stringifyArgs(args), path: location.pathname, context: { kind: "console" } });
|
||||
}
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
origError(...args);
|
||||
if (consoleForwardEnabled()) {
|
||||
logClient({ level: "error", message: stringifyArgs(args), path: location.pathname, context: { kind: "console" } });
|
||||
}
|
||||
};
|
||||
|
||||
// Flush on tab hide / unload.
|
||||
window.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flushBeacon();
|
||||
});
|
||||
window.addEventListener("pagehide", flushBeacon);
|
||||
}
|
||||
|
||||
function stringifyArgs(args: unknown[]): string {
|
||||
return args
|
||||
.map((a) => (a instanceof Error ? a.message : typeof a === "string" ? a : safeStringify(a)))
|
||||
.join(" ")
|
||||
.slice(0, 2000);
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Single QueryClient for the app. TanStack Query owns SERVER state (fetch, cache,
|
||||
// refetch, loading/error) — wrapping the existing thin api.ts fetchers. Client/UI
|
||||
// state (live feed, WS status) lives in Zustand, not here. The WS layer invalidates
|
||||
// these caches on live events so Query stays the source of truth for server data.
|
||||
//
|
||||
// Defaults tuned for a single-appliance booth: no window-focus refetch (it's a
|
||||
// kiosk, not a tab someone switches to), and a short staleTime since the WS is the
|
||||
// real freshness mechanism — queries are the fallback/initial load.
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Stable query keys — referenced by both the screens and the WS invalidator. */
|
||||
export const qk = {
|
||||
me: ["me"] as const,
|
||||
occupancy: ["occupancy"] as const,
|
||||
events: ["events"] as const,
|
||||
activeSessions: ["active-sessions"] as const,
|
||||
siteConfig: ["site-config"] as const,
|
||||
shift: ["shift"] as const,
|
||||
deviceStatus: ["device-status"] as const,
|
||||
} as const;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { REASON_CODES, type LedgerEvent } from "@parking/shared";
|
||||
|
||||
// Localize a signed event's reason. The ledger signs a STABLE `reasonCode` (+ params)
|
||||
// plus an English `reason` fallback (see @parking/shared REASON_CODES). We translate
|
||||
// the code via the `reason.<code>` catalog so an Albanian operator reads Albanian and
|
||||
// an English operator reads English — from the SAME immutable event. Legacy events
|
||||
// (signed before reason codes existed) carry only `reason`, so we show that verbatim.
|
||||
|
||||
const CODE_SET = new Set<string>(REASON_CODES);
|
||||
|
||||
/** The localized reason sentence for an event, or null if it has no reason at all. */
|
||||
export function renderReason(payload: LedgerEvent["payload"], t: TFunction): string | null {
|
||||
if (!payload) return null;
|
||||
const code = typeof payload.reasonCode === "string" ? payload.reasonCode : null;
|
||||
if (code && CODE_SET.has(code)) {
|
||||
// i18next fills {{param}} from reasonParams; an absent key renders the raw token.
|
||||
return t(`reason.${code}`, (payload.reasonParams ?? {}) as Record<string, unknown>);
|
||||
}
|
||||
// No code (legacy) or an unknown code (forward-compat) → the signed English fallback.
|
||||
return typeof payload.reason === "string" ? payload.reason : null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Theme } from "../api.js";
|
||||
|
||||
// Theme application. The whole UI reads colour through the --color-term-* tokens;
|
||||
// the light palette lives in index.css under `html.theme-light`. Applying a theme is
|
||||
// just toggling that class on <html>. The active theme is the LOGGED-IN USER's stored
|
||||
// preference (users.theme), applied via applyTheme() after auth resolves — mirroring
|
||||
// how language works. Dark is the default before auth resolves. Printed tickets are
|
||||
// unaffected (always Albanian, dark-agnostic).
|
||||
|
||||
/** Apply a theme by toggling `theme-light` on <html>. Dark is the absence of the
|
||||
* class (the base tokens). No-op-safe to call repeatedly. */
|
||||
export function applyTheme(theme: Theme): void {
|
||||
document.documentElement.classList.toggle("theme-light", theme === "light");
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||
import { qk } from "./query.js";
|
||||
import { useLiveStore } from "./live-store.js";
|
||||
|
||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
||||
// so TanStack Query remains the source of truth for durable server data. The browser
|
||||
// attaches the auth cookie automatically; the backend gates by cookie + Origin
|
||||
// (see routes/ws.ts). Auto-reconnects with capped backoff so a booth left running
|
||||
// recovers from a server restart without a manual refresh.
|
||||
|
||||
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||
type WsMessage =
|
||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
|
||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||
| { kind: "printer-status"; event: unknown }
|
||||
| { kind: "device-status"; event: DeviceStatus };
|
||||
|
||||
/** Build the ws:// or wss:// URL for the same origin the SPA is served from. */
|
||||
function wsUrl(): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${window.location.host}/api/ws`;
|
||||
}
|
||||
|
||||
export function useLiveFeed(): void {
|
||||
const qc = useQueryClient();
|
||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore();
|
||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||
// double-invoke and unmount.
|
||||
const sockRef = useRef<WebSocket | null>(null);
|
||||
const retryRef = useRef(0);
|
||||
const closedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
closedRef.current = false;
|
||||
|
||||
const connect = () => {
|
||||
if (closedRef.current) return;
|
||||
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
||||
const sock = new WebSocket(wsUrl());
|
||||
sockRef.current = sock;
|
||||
|
||||
sock.onopen = () => {
|
||||
retryRef.current = 0;
|
||||
setStatus("open");
|
||||
};
|
||||
|
||||
sock.onmessage = (ev) => {
|
||||
let msg: WsMessage;
|
||||
try {
|
||||
msg = JSON.parse(ev.data as string) as WsMessage;
|
||||
} catch {
|
||||
return; // ignore malformed frames
|
||||
}
|
||||
if (msg.kind === "hello") {
|
||||
setOccupancy(msg.occupancy);
|
||||
// Initial device-status snapshot for the footer.
|
||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||
} else if (msg.kind === "device-status") {
|
||||
upsertDevice(msg.event);
|
||||
} else if (msg.kind === "ledger") {
|
||||
setOccupancy(msg.occupancy);
|
||||
pushEvent(msg.event);
|
||||
// Keep Query authoritative: the durable event list, occupancy totals,
|
||||
// and active-sessions list refetch on the next read instead of trusting
|
||||
// the pushed copy alone.
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||
// A shift open/close (or a drawer movement) changes the header control
|
||||
// state and the per-shift log window — refresh the shift status too.
|
||||
if (
|
||||
msg.event.type === "shift_open" ||
|
||||
msg.event.type === "shift_z_report" ||
|
||||
msg.event.type === "cash_movement"
|
||||
) {
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
}
|
||||
} else if (msg.kind === "printer-status") {
|
||||
void qc.invalidateQueries({ queryKey: ["printers"] });
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (closedRef.current) return;
|
||||
setStatus("closed");
|
||||
// Capped exponential backoff: 0.5s, 1s, 2s, … up to 10s.
|
||||
const delay = Math.min(500 * 2 ** retryRef.current, 10_000);
|
||||
retryRef.current += 1;
|
||||
window.setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
sock.onclose = scheduleReconnect;
|
||||
// onerror fires before onclose; let onclose own the reconnect to avoid double.
|
||||
sock.onerror = () => sock.close();
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closedRef.current = true;
|
||||
sockRef.current?.close();
|
||||
sockRef.current = null;
|
||||
};
|
||||
// qc / store setters are stable; run once on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchShift, type ShiftStatus } from "../api.js";
|
||||
import { qk } from "./query.js";
|
||||
|
||||
// Shared shift status for the whole app — the header control, the booth screen's
|
||||
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
|
||||
// they never disagree about whether a shift is open and whose it is. A shift is a
|
||||
// SITE-WIDE single-open accountability period (at most one open at a time). The WS
|
||||
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays
|
||||
// live without polling. See wiki/concepts/shift.md.
|
||||
|
||||
export interface ShiftState {
|
||||
/** Raw status from the server (null while loading / on error). */
|
||||
status: ShiftStatus | undefined;
|
||||
/** Is ANY shift open site-wide? */
|
||||
isOpen: boolean;
|
||||
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
|
||||
isMine: boolean;
|
||||
/** A shift is open but belongs to someone else — this operator is blocked. */
|
||||
blockedByOther: boolean;
|
||||
/** ISO start of the open shift, for scoping the per-shift log. */
|
||||
startedAt: string | null;
|
||||
/** Whoever holds the open shift (for "held by X" messaging). */
|
||||
heldBy: string | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useShift(): ShiftState {
|
||||
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
|
||||
const s = q.data;
|
||||
const isOpen = s?.open != null;
|
||||
const isMine = s?.isMine ?? false;
|
||||
return {
|
||||
status: s,
|
||||
isOpen,
|
||||
isMine,
|
||||
blockedByOther: isOpen && !isMine,
|
||||
startedAt: s?.open?.startedAt ?? null,
|
||||
heldBy: s?.open?.operator ?? null,
|
||||
isLoading: q.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import "./lib/i18n/index.js"; // initialize i18next before the app renders
|
||||
import { App } from "./App.js";
|
||||
import { ErrorBoundary } from "./lib/ErrorBoundary.js";
|
||||
import { installClientLogging } from "./lib/logger.js";
|
||||
|
||||
// Capture uncaught errors / rejections / console noise → backend log store, before
|
||||
// the app mounts so even an early crash is reported. See lib/logger.ts.
|
||||
installClientLogging();
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("root element not found");
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import {
|
||||
createRootRouteWithContext,
|
||||
createRoute,
|
||||
createRouter,
|
||||
Link,
|
||||
Outlet,
|
||||
redirect,
|
||||
} from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||
import { can, closeShift, logout, openShift, setLanguagePref, setThemePref } from "./api.js";
|
||||
import { qk, queryClient } from "./lib/query.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { applyTheme } from "./lib/theme.js";
|
||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||
import { StatusDot } from "./ui/StatusDot.js";
|
||||
import { BoothScreen } from "./BoothScreen.js";
|
||||
import { SetupWizard } from "./SetupWizard.js";
|
||||
import { TariffComposer } from "./TariffComposer.js";
|
||||
import { SubscriptionManager } from "./SubscriptionManager.js";
|
||||
import { ShiftControl } from "./ShiftControl.js";
|
||||
import { SiteSettings } from "./SiteSettings.js";
|
||||
import { UsersManager } from "./UsersManager.js";
|
||||
import { RolesManager } from "./RolesManager.js";
|
||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
|
||||
// Code-based TanStack Router (no file-based codegen — the app is small enough that
|
||||
// an explicit tree is clearer). The router context carries the signed-in user and
|
||||
// a setter so route guards can redirect by role. The root renders the terminal
|
||||
// chrome (nav + user + live status) and opens the booth WebSocket once, app-wide.
|
||||
|
||||
export interface RouterContext {
|
||||
user: SessionUser | null;
|
||||
setUser: (u: SessionUser | null) => void;
|
||||
}
|
||||
|
||||
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function NavLink({ to, label }: { to: string; label: string }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** A tab inside the Setup layout. `exact` (activeOptions) so the Devices tab at
|
||||
* `/setup` doesn't stay highlighted on the child tabs. */
|
||||
function SetupTab({ to, label, exact = false }: { to: string; label: string; exact?: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
activeOptions={{ exact }}
|
||||
className="border-b-2 border-transparent px-3 py-2 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||||
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
||||
* deep links and the back button work and a denied tab redirects to the booth. */
|
||||
function SetupLayout() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
const { t } = useTranslation();
|
||||
const show = (perm: Permission) => can(user, perm);
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
|
||||
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
||||
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
|
||||
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||
{show("shift:read") && <SetupTab to="/setup/shifts" label={t("nav.shifts")} />}
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
|
||||
* and applies it immediately. Updates the router-context user so App re-syncs. */
|
||||
function LanguageToggle({
|
||||
user,
|
||||
setUser,
|
||||
}: {
|
||||
user: SessionUser;
|
||||
setUser: (u: SessionUser | null) => void;
|
||||
}) {
|
||||
// The ACTIVE language is i18n's own state, not the router-context `user` — the
|
||||
// latter is captured at route-resolution time and does NOT re-render when we call
|
||||
// setUser, so reading `user.language` here goes stale after the first switch (the
|
||||
// highlight froze and the equality guard blocked switching back until a refresh).
|
||||
// useTranslation() subscribes to i18n's languageChanged, so this stays live.
|
||||
const { i18n } = useTranslation();
|
||||
const active = i18n.language as Lang;
|
||||
async function pick(lang: Lang) {
|
||||
if (lang === active) return;
|
||||
setLanguage(lang); // instant UI (fires i18n languageChanged → re-render)
|
||||
setUser({ ...user, language: lang }); // keep context eventually-consistent + persisted state
|
||||
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 ${
|
||||
active === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dark/light theme toggle. Same shape as the language toggle: applies instantly,
|
||||
* persists to the user's profile, and updates the router-context user so App
|
||||
* re-syncs. Restored on the next login from any booth. */
|
||||
function ThemeToggle({
|
||||
user,
|
||||
setUser,
|
||||
}: {
|
||||
user: SessionUser;
|
||||
setUser: (u: SessionUser | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Local state for the ACTIVE theme — same reason as LanguageToggle: the router
|
||||
// context `user` doesn't re-render on setUser, so reading `user.theme` here froze
|
||||
// the highlight after one switch and blocked toggling back until a refresh. Seed
|
||||
// from the prop; update optimistically on pick. App's effect keeps the DOM in sync
|
||||
// with the persisted user on (re)login.
|
||||
const [active, setActive] = useState<Theme>(user.theme);
|
||||
async function pick(theme: Theme) {
|
||||
if (theme === active) return;
|
||||
setActive(theme);
|
||||
applyTheme(theme); // instant UI
|
||||
setUser({ ...user, theme }); // keep context eventually-consistent + persisted state
|
||||
try {
|
||||
await setThemePref(theme); // persist
|
||||
} catch {
|
||||
/* non-fatal — the choice still applies this session */
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
||||
{(["dark", "light"] as const).map((th) => (
|
||||
<button
|
||||
key={th}
|
||||
type="button"
|
||||
onClick={() => pick(th)}
|
||||
className={`rounded-term px-1.5 py-0.5 ${
|
||||
active === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
{t(th === "dark" ? "common.themeDark" : "common.themeLight")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
||||
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
|
||||
* - another's shift open → disabled, labelled with who holds it (you can neither
|
||||
* open yours nor close theirs until they hand over).
|
||||
* On open/close it invalidates the shift status, the per-shift log, and occupancy.
|
||||
*/
|
||||
function ShiftButton() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function act(kind: "open" | "close") {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
if (kind === "open") await openShift();
|
||||
else await closeShift();
|
||||
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled when another operator holds the shift (can't open or close).
|
||||
const label = blockedByOther
|
||||
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
||||
: isMine
|
||||
? t("shift.headerClose")
|
||||
: t("shift.headerOpen");
|
||||
const tone = blockedByOther
|
||||
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
|
||||
: isMine
|
||||
? "border-term-red text-term-red hover:bg-term-red/10"
|
||||
: "border-term-green text-term-green hover:bg-term-green/10";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || blockedByOther}
|
||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||
onClick={() => act(isMine ? "close" : "open")}
|
||||
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
|
||||
>
|
||||
{busy ? t("shift.opening") : label}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||
)}
|
||||
{err && <span className="text-[10px] text-term-red">{err}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
const { user, setUser } = rootRoute.useRouteContext();
|
||||
const { t } = useTranslation();
|
||||
// One app-wide WebSocket for the live feed (booth + any live widget).
|
||||
useLiveFeed();
|
||||
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
|
||||
// the permission its screen needs (the route guards enforce the same server-side).
|
||||
const show = (perm: Permission) => can(user, perm);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||||
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shift" label={t("nav.shift")} />
|
||||
{/* One Setup entry — its tabs hold devices/tariff/subscriptions/site/users/
|
||||
roles/shifts. Shown if the user can reach ANY of those screens (an
|
||||
operator with only shift:read still gets in, landing on Shifts). */}
|
||||
{(show("site:update") ||
|
||||
show("tariff:read") ||
|
||||
show("subscription:read") ||
|
||||
show("site:read") ||
|
||||
show("user:read") ||
|
||||
show("role:read") ||
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
<span className="text-[11px] text-term-muted">
|
||||
{user?.username} · {user?.roleName}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
setUser(null);
|
||||
}}
|
||||
>
|
||||
{t("common.logout")}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<Outlet />
|
||||
</main>
|
||||
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
|
||||
{user && <DeviceFooter />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/booth" });
|
||||
},
|
||||
});
|
||||
|
||||
const boothRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/booth",
|
||||
component: BoothScreen,
|
||||
});
|
||||
|
||||
// Back-compat: the config screens used to be top-level routes. They now live under
|
||||
// /setup as tabs — redirect the old paths so existing bookmarks/links don't 404.
|
||||
const legacyRedirects = (
|
||||
[
|
||||
["/tariff", "/setup/tariff"],
|
||||
["/subscriptions", "/setup/subscriptions"],
|
||||
["/site", "/setup/site"],
|
||||
["/users", "/setup/users"],
|
||||
["/roles", "/setup/roles"],
|
||||
] as const
|
||||
).map(([from, to]) =>
|
||||
createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: from,
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const shiftRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/shift",
|
||||
component: function ShiftRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
// "Admin" actions on the shift screen (drawer cash) need shift:cash.
|
||||
return <ShiftControl isAdmin={can(user, "shift:cash")} />;
|
||||
},
|
||||
});
|
||||
|
||||
/** Guard factory: a route requiring `perm` redirects a user who lacks it back to
|
||||
* the booth. Same permission the server enforces — defence in depth, not the only
|
||||
* gate. */
|
||||
function requirePerm(perm: Permission) {
|
||||
return (ctx: RouterContext) => {
|
||||
if (!can(ctx.user, perm)) throw redirect({ to: "/booth" });
|
||||
};
|
||||
}
|
||||
|
||||
// The Setup tabs in display order, each with the permission its screen needs. Used
|
||||
// to land a user on the FIRST tab they may see when they open /setup without
|
||||
// `site:update` (e.g. an operator who only has shift:read → goes to /setup/shifts).
|
||||
const SETUP_TABS: { to: string; perm: Permission }[] = [
|
||||
{ to: "/setup", perm: "site:update" },
|
||||
{ to: "/setup/tariff", perm: "tariff:read" },
|
||||
{ to: "/setup/subscriptions", perm: "subscription:read" },
|
||||
{ to: "/setup/site", perm: "site:read" },
|
||||
{ to: "/setup/users", perm: "user:read" },
|
||||
{ to: "/setup/roles", perm: "role:read" },
|
||||
{ to: "/setup/shifts", perm: "shift:read" },
|
||||
{ to: "/setup/logs", perm: "log:read" },
|
||||
];
|
||||
|
||||
// /setup is a LAYOUT route (tab bar + <Outlet>); the config screens are its
|
||||
// children. The layout itself has no permission gate — each child enforces its own
|
||||
// (so a user who can reach ANY tab gets the hub, but only the tabs they're allowed).
|
||||
const setupRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/setup",
|
||||
component: SetupLayout,
|
||||
});
|
||||
// Index tab = Devices (the former SetupWizard). Lives at /setup exactly. A user who
|
||||
// lacks site:update (e.g. an operator) is redirected to the FIRST tab they CAN see
|
||||
// rather than bounced to the booth — so "Setup" always lands somewhere useful.
|
||||
const setupDevicesRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "/",
|
||||
beforeLoad: ({ context }) => {
|
||||
if (can(context.user, "site:update")) return;
|
||||
const firstOther = SETUP_TABS.find((tab) => tab.to !== "/setup" && can(context.user, tab.perm));
|
||||
throw redirect({ to: firstOther?.to ?? "/booth" });
|
||||
},
|
||||
component: () => <SetupWizard />,
|
||||
});
|
||||
const tariffRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "tariff",
|
||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||
component: () => <TariffComposer />,
|
||||
});
|
||||
const subscriptionsRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "subscriptions",
|
||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
||||
component: () => <SubscriptionManager />,
|
||||
});
|
||||
const siteRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "site",
|
||||
beforeLoad: ({ context }) => requirePerm("site:read")(context),
|
||||
component: function SiteRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <SiteSettings canEdit={can(user, "site:update")} />;
|
||||
},
|
||||
});
|
||||
const usersRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "users",
|
||||
beforeLoad: ({ context }) => requirePerm("user:read")(context),
|
||||
component: function UsersRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <UsersManager user={user} />;
|
||||
},
|
||||
});
|
||||
const rolesRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "roles",
|
||||
beforeLoad: ({ context }) => requirePerm("role:read")(context),
|
||||
component: function RolesRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <RolesManager user={user} />;
|
||||
},
|
||||
});
|
||||
// Shift history. Gated by shift:read (operators have it) — the SERVER scopes the
|
||||
// data: operators see only their own; shift:cash holders see all + can filter.
|
||||
const shiftsHistoryRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "shifts",
|
||||
beforeLoad: ({ context }) => requirePerm("shift:read")(context),
|
||||
component: function ShiftsHistoryRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <ShiftsHistory user={user} />;
|
||||
},
|
||||
});
|
||||
|
||||
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
|
||||
const logsRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "logs",
|
||||
beforeLoad: ({ context }) => requirePerm("log:read")(context),
|
||||
component: LogsViewer,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
boothRoute,
|
||||
...legacyRedirects,
|
||||
shiftRoute,
|
||||
setupRoute.addChildren([
|
||||
setupDevicesRoute,
|
||||
tariffRoute,
|
||||
subscriptionsRoute,
|
||||
siteRoute,
|
||||
usersRoute,
|
||||
rolesRoute,
|
||||
shiftsHistoryRoute,
|
||||
logsRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
context: { user: null, setUser: () => {} },
|
||||
defaultPreload: "intent",
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
|
||||
import { qk } from "../lib/query.js";
|
||||
import { useLiveStore } from "../lib/live-store.js";
|
||||
|
||||
// Fixed device-status footer for the booth chrome. One compact chip per configured
|
||||
// device — relays, readers, cameras, printers — labelled by ROLE, never vendor
|
||||
// (e.g. "Lexuesi hyrje", "Printer kabina", "Kamera dalje"), with a traffic-light
|
||||
// dot. Fault detail does NOT pollute the footer: clicking opens a small panel that
|
||||
// lists the degraded/offline devices and their issues. Status is fed by the
|
||||
// DeviceMonitor over the WS (snapshot on connect + per-device pushes, held in the
|
||||
// live store); a REST snapshot seeds it / fills in if the WS is briefly down.
|
||||
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
|
||||
|
||||
const DOT: Record<DeviceStatus["state"], string> = {
|
||||
ready: "bg-term-green",
|
||||
degraded: "bg-term-amber",
|
||||
offline: "bg-term-red",
|
||||
};
|
||||
|
||||
const TEXT: Record<DeviceStatus["state"], string> = {
|
||||
ready: "text-term-text",
|
||||
degraded: "text-term-amber",
|
||||
offline: "text-term-red",
|
||||
};
|
||||
|
||||
/** i18n key for a device category. */
|
||||
const CATEGORY_KEY: Record<DeviceStatus["category"], string> = {
|
||||
access: "devices.catAccess",
|
||||
reader: "devices.catReader",
|
||||
camera: "devices.catCamera",
|
||||
printer: "devices.catPrinter",
|
||||
};
|
||||
|
||||
/** i18n key for the role/direction token (null = no suffix). */
|
||||
function roleKey(roleKind: DeviceStatus["roleKind"]): string | null {
|
||||
return roleKind ? `devices.role.${roleKind}` : null;
|
||||
}
|
||||
|
||||
/** Stable display order: access (barrier) first, then readers, cameras, printers. */
|
||||
const ORDER: Record<DeviceStatus["category"], number> = {
|
||||
access: 0,
|
||||
reader: 1,
|
||||
camera: 2,
|
||||
printer: 3,
|
||||
};
|
||||
|
||||
/** "Lexuesi hyrje" — category word + localised role/direction (when known). */
|
||||
function useLabel() {
|
||||
const { t } = useTranslation();
|
||||
return (d: DeviceStatus) => {
|
||||
const cat = t(CATEGORY_KEY[d.category]);
|
||||
const rk = roleKey(d.roleKind);
|
||||
return rk ? `${cat} ${t(rk)}` : cat;
|
||||
};
|
||||
}
|
||||
|
||||
function sortDevices(list: DeviceStatus[]): DeviceStatus[] {
|
||||
return [...list].sort(
|
||||
(a, b) => ORDER[a.category] - ORDER[b.category] || (a.roleKind ?? "").localeCompare(b.roleKind ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceFooter() {
|
||||
const { t } = useTranslation();
|
||||
const label = useLabel();
|
||||
// Seed/fallback from REST; the WS keeps the live store authoritative thereafter.
|
||||
const seed = useQuery({ queryKey: qk.deviceStatus, queryFn: fetchDeviceStatus });
|
||||
const live = useLiveStore((s) => s.devices);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Close the issues panel on an outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Prefer the live store (WS); fall back to the REST snapshot before the first push.
|
||||
const fromLive = Object.values(live);
|
||||
const devices = sortDevices(fromLive.length > 0 ? fromLive : seed.data?.devices ?? []);
|
||||
const problems = devices.filter((d) => d.state !== "ready");
|
||||
|
||||
return (
|
||||
<footer
|
||||
ref={rootRef}
|
||||
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
|
||||
{t("devices.footerTitle")}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||
{devices.length === 0 ? (
|
||||
<span className="text-term-muted">{t("devices.none")}</span>
|
||||
) : (
|
||||
devices.map((d) => {
|
||||
const isProblem = d.state !== "ready";
|
||||
return (
|
||||
<button
|
||||
key={d.deviceId}
|
||||
type="button"
|
||||
// Only a problem chip is interactive (opens the issues panel).
|
||||
onClick={isProblem ? () => setOpen((v) => !v) : undefined}
|
||||
aria-disabled={!isProblem}
|
||||
title={isProblem ? t("devices.clickForIssues") : undefined}
|
||||
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-term border border-term-border bg-term-panel-2 px-2 py-0.5 ${
|
||||
isProblem ? "cursor-pointer hover:border-term-amber" : "cursor-default"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]} ${
|
||||
d.state === "offline" ? "animate-pulse" : ""
|
||||
}`}
|
||||
/>
|
||||
<span className={TEXT[d.state]}>{label(d)}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right-aligned roll-up; clicking opens the issues panel when any exist. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={problems.length === 0}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="ml-auto shrink-0 tabular-nums disabled:cursor-default"
|
||||
>
|
||||
{problems.length === 0 ? (
|
||||
devices.length > 0 ? (
|
||||
<span className="text-term-green">{t("devices.allOk")}</span>
|
||||
) : null
|
||||
) : (
|
||||
<span className="text-term-amber hover:underline">
|
||||
{t("devices.issuesCount", { count: problems.length })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Issues panel — anchored above the footer, lists only problem devices. */}
|
||||
{open && problems.length > 0 && (
|
||||
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("devices.issuesTitle")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-term-muted hover:text-term-text"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<ul className="max-h-[40vh] overflow-y-auto p-1.5">
|
||||
{problems.map((d) => (
|
||||
<li
|
||||
key={d.deviceId}
|
||||
className="flex items-start gap-2 border-b border-term-border/40 px-1.5 py-1.5 last:border-b-0"
|
||||
>
|
||||
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
{t(`devices.state.${d.state}`)}
|
||||
</span>
|
||||
</div>
|
||||
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>}
|
||||
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
|
||||
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Reusable modal shell — a thin wrapper over Radix Dialog matching the terminal
|
||||
// chrome (title bar + ✕, dark overlay, square panel). The same styling BoothPayModal
|
||||
// uses inline, factored out so every popped-out form looks identical. Radix handles
|
||||
// focus trap, Escape, and outside-click → onClose. `width` is a Tailwind max-width
|
||||
// class (the panel is responsive: w-full up to that cap).
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
width = "max-w-xl",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
/** Tailwind max-width class for the panel (default max-w-xl). */
|
||||
width?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
|
||||
<Dialog.Content
|
||||
className={`fixed left-1/2 top-1/2 z-50 max-h-[90vh] w-[95vw] ${width} -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl`}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
|
||||
✕
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -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,83 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
|
||||
|
||||
// Entry/exit evidence images for a session. Lets the operator verify the car at the
|
||||
// booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed,
|
||||
// served with a long immutable cache); clicking one enlarges it. Read-only.
|
||||
|
||||
export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["snapshots", identity],
|
||||
queryFn: () => fetchSnapshots(identity),
|
||||
enabled: !!identity,
|
||||
});
|
||||
const [zoom, setZoom] = useState<string | null>(null);
|
||||
|
||||
const shots = data?.snapshots ?? [];
|
||||
const failures = data?.failures ?? [];
|
||||
|
||||
/** Localized direction label for a snapshot/failure tile. */
|
||||
const dirLabel = (dir: "entry" | "exit" | null): string =>
|
||||
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
|
||||
|
||||
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
||||
if (shots.length === 0 && failures.length === 0)
|
||||
return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{shots.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setZoom(s.id)}
|
||||
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
||||
title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
|
||||
>
|
||||
<img
|
||||
src={snapshotImageUrl(s.id)}
|
||||
alt={s.direction ?? "snapshot"}
|
||||
className="h-20 w-28 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className={`text-[9px] uppercase tracking-wider ${
|
||||
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
||||
}`}
|
||||
>
|
||||
{dirLabel(s.direction)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Failed captures — a placeholder tile so an absent image is explained, not
|
||||
silently missing. Shown only when no successful shot exists for the same
|
||||
direction (the server already filters recovered captures out). */}
|
||||
{failures.map((f, i) => (
|
||||
<div
|
||||
key={`fail-${f.direction ?? "both"}-${i}`}
|
||||
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
|
||||
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
||||
>
|
||||
<span className="text-lg leading-none text-term-amber">⚠</span>
|
||||
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||
<span className="px-1 text-[9px] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{zoom && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
|
||||
onClick={() => setZoom(null)}
|
||||
>
|
||||
<img src={snapshotImageUrl(zoom)} alt="snapshot" className="max-h-full max-w-full object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLiveStore, type WsStatus } from "../lib/live-store.js";
|
||||
|
||||
// Small live-connection indicator for the booth chrome: a coloured dot + label
|
||||
// reflecting the WebSocket status. Green = live, amber = connecting, red = down.
|
||||
|
||||
const COLOR: Record<WsStatus, string> = {
|
||||
open: "bg-term-green",
|
||||
connecting: "bg-term-amber",
|
||||
closed: "bg-term-red",
|
||||
};
|
||||
const LABEL_KEY: Record<WsStatus, string> = {
|
||||
open: "status.live",
|
||||
connecting: "status.connecting",
|
||||
closed: "status.offline",
|
||||
};
|
||||
|
||||
export function StatusDot() {
|
||||
const { t } = useTranslation();
|
||||
const status = useLiveStore((s) => s.status);
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
|
||||
/>
|
||||
{t(LABEL_KEY[status])}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// Operator SPA. Built by Vite and served by Fastify in production
|
||||
// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the
|
||||
// local Fastify server.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
|
||||
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
|
||||
// notably under WSL2 mirrored networking.
|
||||
"/api": "http://127.0.0.1:3000",
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
// The live booth feed (/api/ws) is a WebSocket — without `ws: true` the
|
||||
// proxy would not forward the upgrade. The backend's Origin allowlist must
|
||||
// include the dev origin (http://localhost:5173) via WS_ALLOWED_ORIGINS.
|
||||
ws: true,
|
||||
},
|
||||
"/health": "http://127.0.0.1:3000",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Rename permit → subscription (master data only). The signed ledger keeps its
|
||||
-- immutable `permitId` payload — NOT touched here. Data-preserving ALTER RENAMEs
|
||||
-- (SQLite 3.25+) rather than drop/recreate, so existing subscriptions survive.
|
||||
-- Adds per-subscription pricing (price_minor + period + currency) and a site-wide
|
||||
-- default monthly price. See wiki/entities/subscription.md.
|
||||
|
||||
ALTER TABLE `permits` RENAME TO `subscriptions`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `permit_credentials` RENAME TO `subscription_credentials`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `subscription_credentials` RENAME COLUMN `permit_id` TO `subscription_id`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `permit_plates` RENAME TO `subscription_plates`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `subscription_plates` RENAME COLUMN `permit_id` TO `subscription_id`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `subscriptions` ADD `price_minor` integer;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `subscriptions` ADD `period` text DEFAULT 'monthly' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `subscriptions` ADD `currency` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `sessions` RENAME COLUMN `permit_id` TO `subscription_id`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `subscription_monthly_price_minor` integer;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `site_config` ADD `timezone` text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `site_config` ADD `default_vehicle_category` text;
|
||||
@@ -0,0 +1,103 @@
|
||||
-- Dynamic RBAC: roles become DATA. Replaces the hardcoded users.role enum with a
|
||||
-- role_id FK into a composable `roles` table + a `role_permissions` grid.
|
||||
-- See wiki/entities/local-jwt-auth.md and @parking/shared PERMISSIONS.
|
||||
|
||||
-- 1. Roles + the role→permission grid.
|
||||
CREATE TABLE `roles` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`builtin` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `roles_name_unique` ON `roles` (`name`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `role_permissions` (
|
||||
`role_id` text NOT NULL,
|
||||
`permission` text NOT NULL,
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `role_permissions_role_id_permission_unique` ON `role_permissions` (`role_id`,`permission`);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- 2. Seed the protected built-in `admin` role. Its permission set is enforced in
|
||||
-- code (always ALL), but we materialise the rows too so the grid is complete.
|
||||
INSERT INTO `roles` (`id`, `name`, `builtin`) VALUES ('admin', 'Admin', 1);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||
('admin','user:create'),('admin','user:read'),('admin','user:update'),('admin','user:delete'),
|
||||
('admin','role:create'),('admin','role:read'),('admin','role:update'),('admin','role:delete'),
|
||||
('admin','tariff:read'),('admin','tariff:update'),
|
||||
('admin','subscription:read'),('admin','subscription:create'),('admin','subscription:update'),('admin','subscription:delete'),
|
||||
('admin','site:read'),('admin','site:update'),
|
||||
('admin','device:read'),
|
||||
('admin','shift:read'),('admin','shift:create'),('admin','shift:cash'),
|
||||
('admin','payment:read'),('admin','payment:create'),
|
||||
('admin','session:read'),
|
||||
('admin','event:read'),('admin','event:void'),
|
||||
('admin','report:read');
|
||||
--> statement-breakpoint
|
||||
|
||||
-- 3. Seed composable roles matching the OLD enum's intended behaviour, so any
|
||||
-- existing operator/cashier/readonly user keeps working. These are ordinary
|
||||
-- (non-builtin) rows an admin may later edit or delete.
|
||||
INSERT INTO `roles` (`id`, `name`, `builtin`) VALUES ('operator', 'Operator', 0);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||
('operator','payment:read'),('operator','payment:create'),
|
||||
('operator','session:read'),
|
||||
('operator','shift:read'),('operator','shift:create'),
|
||||
('operator','subscription:read'),
|
||||
('operator','tariff:read'),
|
||||
('operator','site:read'),
|
||||
('operator','device:read'),
|
||||
('operator','event:read'),
|
||||
('operator','report:read');
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `roles` (`id`, `name`, `builtin`) VALUES ('cashier', 'Cashier', 0);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||
('cashier','payment:read'),('cashier','payment:create'),
|
||||
('cashier','session:read'),
|
||||
('cashier','shift:read'),('cashier','shift:create'),
|
||||
('cashier','subscription:read'),
|
||||
('cashier','tariff:read'),
|
||||
('cashier','site:read'),
|
||||
('cashier','device:read'),
|
||||
('cashier','event:read'),
|
||||
('cashier','report:read');
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `roles` (`id`, `name`, `builtin`) VALUES ('readonly', 'Read-only', 0);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||
('readonly','session:read'),
|
||||
('readonly','subscription:read'),
|
||||
('readonly','tariff:read'),
|
||||
('readonly','site:read'),
|
||||
('readonly','device:read'),
|
||||
('readonly','event:read'),
|
||||
('readonly','report:read');
|
||||
--> statement-breakpoint
|
||||
|
||||
-- 4. Rebuild `users` to swap the `role` enum column for a `role_id` FK. SQLite
|
||||
-- can't DROP a column cleanly, so: create the new shape, copy rows mapping the
|
||||
-- old role string -> role id (identical strings), drop, rename.
|
||||
CREATE TABLE `users_new` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`role_id` text NOT NULL,
|
||||
`language` text DEFAULT 'sq' NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `users_new` (`id`, `username`, `password_hash`, `role_id`, `language`, `created_at`)
|
||||
SELECT `id`, `username`, `password_hash`, `role`, `language`, `created_at` FROM `users`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `users`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `users_new` RENAME TO `users`;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE `users` ADD `theme` text DEFAULT 'dark' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `full_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `phone` text;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `email` text;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `address` text;
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE `app_logs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`level` text NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`message` text NOT NULL,
|
||||
`context` text,
|
||||
`http_status` integer,
|
||||
`path` text,
|
||||
`stack` text,
|
||||
`user_id` text,
|
||||
`user_agent` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `app_logs_created_at_idx` ON `app_logs` (`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `app_logs_level_idx` ON `app_logs` (`level`);--> statement-breakpoint
|
||||
-- Grant the new log:read permission to the built-in admin role (enforcement is
|
||||
-- runtime-special-cased to ALL permissions, but the Roles UI lists the grid from these
|
||||
-- rows — keep it in sync). INSERT OR IGNORE: harmless if the row already exists.
|
||||
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES ('admin','log:read');
|
||||
@@ -1,11 +1,179 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "721bbb8f-b929-4018-9420-0ae75b03ff93",
|
||||
"id": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"events": {
|
||||
"name": "events",
|
||||
"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",
|
||||
@@ -35,13 +203,6 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lane": {
|
||||
"name": "lane",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
@@ -56,6 +217,13 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payload": {
|
||||
"name": "payload",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"occurred_at": {
|
||||
"name": "occurred_at",
|
||||
"type": "text",
|
||||
@@ -76,11 +244,18 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_id": {
|
||||
"name": "key_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"events_index_unique": {
|
||||
"name": "events_index_unique",
|
||||
"ledger_events_index_unique": {
|
||||
"name": "ledger_events_index_unique",
|
||||
"columns": [
|
||||
"index"
|
||||
],
|
||||
@@ -92,6 +267,426 @@
|
||||
"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
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "1073123c-0df9-4109-84bf-7f23b95ec5bd",
|
||||
"prevId": "721bbb8f-b929-4018-9420-0ae75b03ff93",
|
||||
"id": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
|
||||
"prevId": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
|
||||
"tables": {
|
||||
"events": {
|
||||
"name": "events",
|
||||
"blocklist": {
|
||||
"name": "blocklist",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
@@ -14,43 +14,90 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"index": {
|
||||
"name": "index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lane": {
|
||||
"name": "lane",
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"added_by": {
|
||||
"name": "added_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identity": {
|
||||
"name": "identity",
|
||||
"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,
|
||||
@@ -61,39 +108,18 @@
|
||||
"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
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"events_index_unique": {
|
||||
"name": "events_index_unique",
|
||||
"columns": [
|
||||
"index"
|
||||
],
|
||||
"isUnique": true
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"lane_devices": {
|
||||
"name": "lane_devices",
|
||||
"devices": {
|
||||
"name": "devices",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
@@ -102,13 +128,6 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lane": {
|
||||
"name": "lane",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
@@ -153,6 +172,306 @@
|
||||
"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": {
|
||||
@@ -177,6 +496,239 @@
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"site_config": {
|
||||
"name": "site_config",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"capacity": {
|
||||
"name": "capacity",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"park_name": {
|
||||
"name": "park_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operator_name": {
|
||||
"name": "operator_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"nius": {
|
||||
"name": "nius",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"snapshots": {
|
||||
"name": "snapshots",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identity": {
|
||||
"name": "identity",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content_type": {
|
||||
"name": "content_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"bytes": {
|
||||
"name": "bytes",
|
||||
"type": "blob",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"captured_at": {
|
||||
"name": "captured_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tariff_versions": {
|
||||
"name": "tariff_versions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tariff_id": {
|
||||
"name": "tariff_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"effective_from": {
|
||||
"name": "effective_from",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"structure": {
|
||||
"name": "structure",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tariffs": {
|
||||
"name": "tariffs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'site'"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
|
||||
@@ -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,71 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1781389618205,
|
||||
"tag": "0000_absent_rocket_raccoon",
|
||||
"when": 1781632874398,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1781416636098,
|
||||
"tag": "0001_cuddly_maria_hill",
|
||||
"when": 1781682176094,
|
||||
"tag": "0001_neat_slipstream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1781713560438,
|
||||
"tag": "0002_panoramic_tiger_shark",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1781774228086,
|
||||
"tag": "0003_early_hawkeye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1781800000000,
|
||||
"tag": "0004_subscriptions_rename",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1781884800000,
|
||||
"tag": "0005_site_timezone",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1781884900000,
|
||||
"tag": "0006_site_default_category",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1781885000000,
|
||||
"tag": "0007_rbac",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1781885100000,
|
||||
"tag": "0008_user_profile_theme",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1781885200000,
|
||||
"tag": "0009_app_logs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
||||
export * from "./schema.js";
|
||||
// Re-export the query helpers consumers need, so they don't depend on
|
||||
// drizzle-orm directly (it's an implementation detail of this package).
|
||||
export { eq, and, desc, sql } from "drizzle-orm";
|
||||
export { eq, and, desc, gte, sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user