Compare commits
6 Commits
2696d281ce
...
3429642edb
| Author | SHA1 | Date | |
|---|---|---|---|
| 3429642edb | |||
| c24d99b0f4 | |||
| b4d0dfadd6 | |||
| f18e28eeca | |||
| a8c6d6e714 | |||
| 2a36830880 |
+11
-5
@@ -18,9 +18,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 +61,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 +69,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge: TOKEN_TTL_SECONDS,
|
||||
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,17 @@ export interface DeviceInputEvent {
|
||||
readonly source: "push" | "poll";
|
||||
}
|
||||
|
||||
// A credential read at a lane: a ticket scanned at exit, a plate from LPR, a card
|
||||
// at a reader. Drives identity-based flows (exit validation, and later permits /
|
||||
// pay-station lookup). `kind` mirrors IdentitySource. See parking-session.md.
|
||||
export interface DeviceReadEvent {
|
||||
readonly driverId: string;
|
||||
readonly deviceId: string; // lane_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
|
||||
}
|
||||
|
||||
/** A printer's status as tracked by the live monitor (status + identity). */
|
||||
export interface PrinterStatusEvent {
|
||||
readonly deviceId: string; // lane_devices id
|
||||
@@ -33,6 +44,15 @@ class DeviceEventBus extends EventEmitter {
|
||||
return () => this.off("input", cb);
|
||||
}
|
||||
|
||||
/** A credential read (ticket scan, plate, card) at a lane. */
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { and, eq, laneDevices, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import type { LedgerPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent } 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 lane: number;
|
||||
readonly enteredAt: string;
|
||||
readonly open: boolean; // no vehicle_exit yet
|
||||
readonly paidAt: string | null; // latest payment time, if any
|
||||
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Handle a transient-ticket read at a known exit lane (lane pre-resolved by the
|
||||
* read dispatcher, which has already ruled out a permit match). */
|
||||
async handleAt(lane: number, e: DeviceReadEvent): Promise<void> {
|
||||
const key = `${e.deviceId}:${e.value}`;
|
||||
if (this.#inFlight.has(key)) return;
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
await this.#runExit(lane, e);
|
||||
} catch (err) {
|
||||
this.#logger.error(`exit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #runExit(lane: number, e: DeviceReadEvent): Promise<void> {
|
||||
const view = this.#sessionFor(e.value);
|
||||
|
||||
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||
if (!view || !view.open) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: e.value,
|
||||
payload: { reason: view ? "exit refused — session already closed" : "exit refused — no open session for credential", exitRefused: true },
|
||||
});
|
||||
this.#logger.warn(`exit refused (lane ${lane}): no open session for ${e.value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// PAID + within walk-back grace?
|
||||
const paid = view.paidAt != null;
|
||||
const withinGrace =
|
||||
paid &&
|
||||
view.graceExitMin != null &&
|
||||
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||
|
||||
if (!paid || !withinGrace) {
|
||||
const reason = !paid
|
||||
? "exit refused — not paid (pay at the station)"
|
||||
: "exit refused — walk-back grace expired (top-up required)";
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: e.value,
|
||||
payload: { reason, exitRefused: true, sessionRef: e.value },
|
||||
});
|
||||
this.#logger.warn(`exit refused (lane ${lane}, ${e.value}): ${reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid: sign the exit BEFORE opening, then open, then update the cache.
|
||||
await this.#log.append({
|
||||
type: "vehicle_exit",
|
||||
lane,
|
||||
direction: "exit",
|
||||
source: e.kind === "plate" ? "lpr" : "ticket",
|
||||
identity: e.value,
|
||||
payload: { sessionRef: e.value },
|
||||
});
|
||||
|
||||
const access = await this.#exitAccess(lane);
|
||||
if (access) {
|
||||
await access.pulseOpen(1); // exit barrier; door mapping is config-driven later
|
||||
} else {
|
||||
this.#logger.warn(`exit signed for ${e.value} but lane ${lane} has no access device to open`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.#db
|
||||
.update(sessions)
|
||||
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||
.where(eq(sessions.id, e.value))
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
identity,
|
||||
lane: entry.lane,
|
||||
enteredAt: entry.occurredAt,
|
||||
open: !exited,
|
||||
paidAt,
|
||||
graceExitMin,
|
||||
};
|
||||
}
|
||||
|
||||
/** The lane's access device, to open the exit barrier. */
|
||||
async #exitAccess(lane: number): Promise<AccessControlDevice | null> {
|
||||
const row = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane)))
|
||||
.get();
|
||||
if (!row || !row.enabled) return 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,4 +1,4 @@
|
||||
import { laneDevices, type Db } from "@parking/db";
|
||||
import { and, eq, laneDevices, type Db } from "@parking/db";
|
||||
|
||||
// Resolves a device instance id (lane_devices.id) to its lane number.
|
||||
//
|
||||
@@ -28,3 +28,21 @@ export class LaneMap {
|
||||
return this.#byDeviceId.get(deviceId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane a reader/scanner belongs to, IF that lane has an access (barrier)
|
||||
* device to open — shared by the read-driven flows (exit + permit). A read is an
|
||||
* identity signal; it only drives a barrier where there's one to drive. Returns
|
||||
* the lane number or null. (Distinguishing entry- vs. exit-readers per lane is a
|
||||
* later lane-direction model.)
|
||||
*/
|
||||
export async function readerLaneWithAccess(db: Db, deviceId: string): Promise<number | null> {
|
||||
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const access = await db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, row.lane)))
|
||||
.get();
|
||||
return access && access.enabled ? row.lane : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||
// 1. quote(identity) → look up the open session, price it against the tariff in
|
||||
// force at entry, return the amount due (no side effect).
|
||||
// 2. pay(identity, tender) → re-price, append a SIGNED `payment` event carrying
|
||||
// the amount, currency, tender, tariffVersionId, and graceExitMin (so the exit
|
||||
// flow can validate paid + within walk-back grace). Payment is a signed ledger
|
||||
// event, never a mutable "paid" flag — an operator can't forge or delete it.
|
||||
// See wiki/concepts/tariff.md, parking-session.md.
|
||||
|
||||
export class NoOpenSessionError extends Error {
|
||||
constructor(identity: string) {
|
||||
super(`no open session for ${identity}`);
|
||||
this.name = "NoOpenSessionError";
|
||||
}
|
||||
}
|
||||
export class NoTariffError extends Error {
|
||||
constructor() {
|
||||
super("no active tariff configured");
|
||||
this.name = "NoTariffError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
readonly identity: string;
|
||||
readonly enteredAt: string;
|
||||
readonly amountMinor: number;
|
||||
readonly currency: string;
|
||||
readonly tariffVersionId: string;
|
||||
readonly graceExitMin: number;
|
||||
}
|
||||
|
||||
export class PayStation {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Price an open session against the tariff in force at its entry. No side effect. */
|
||||
quote(identity: string): Quote {
|
||||
const entry = this.#openEntry(identity);
|
||||
if (!entry) throw new NoOpenSessionError(identity);
|
||||
|
||||
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||
if (!tv) throw new NoTariffError();
|
||||
const structure = tv.structure as unknown as TariffStructure;
|
||||
|
||||
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure);
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
amountMinor,
|
||||
currency: tv.currency,
|
||||
tariffVersionId: tv.id,
|
||||
graceExitMin: structure.gracePeriodExitMin,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take payment for a session and append the signed `payment` event. Re-quotes at
|
||||
* the moment of payment (the customer pays for time parked SO FAR). For an
|
||||
* overstay top-up the same call re-prices entry→now and the exit flow's
|
||||
* grace-window restarts from this payment. `overrideMinor` lets the operator set
|
||||
* an arbitrary amount (lost ticket / dispute) — recorded as the charged amount.
|
||||
*/
|
||||
async pay(
|
||||
identity: string,
|
||||
tender: Tender,
|
||||
overrideMinor?: number,
|
||||
): Promise<{ amountMinor: number; currency: string }> {
|
||||
const q = this.quote(identity);
|
||||
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||
|
||||
await this.#log.append({
|
||||
type: "payment",
|
||||
lane: -1, // payment happens at a central station, not a lane
|
||||
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 };
|
||||
}
|
||||
|
||||
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
||||
#openEntry(identity: string) {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, identity))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return null;
|
||||
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already closed
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||
* (single, for now) active site tariff. */
|
||||
#tariffVersionFor(at: string) {
|
||||
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||
if (!tariff) return null;
|
||||
const versions = this.#db
|
||||
.select()
|
||||
.from(tariffVersions)
|
||||
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||
.all();
|
||||
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { and, eq, laneDevices, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db } from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
|
||||
// Reached from the read dispatcher when a read matches a permit (not an open ticket).
|
||||
// See wiki/entities/permit.md.
|
||||
//
|
||||
// Two optional, independent bindings:
|
||||
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
|
||||
// permit's cars may be inside at once; enforced over the session projection.
|
||||
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
|
||||
// too (card/QR OR plate). When unset, any car may use the permit's card/QR.
|
||||
//
|
||||
// Direction is inferred from session state for THAT car (the read credential value
|
||||
// is the per-car session key): no open session → ENTRY; open session → EXIT. So a
|
||||
// fleet permit can have several cars in at once, each its own session, and
|
||||
// anti-passback falls out (a second "entry" on a car already in becomes its exit).
|
||||
|
||||
export interface PermitMatch {
|
||||
readonly permitId: string;
|
||||
/** The specific credential/plate value read — the per-car session key. */
|
||||
readonly carKey: string;
|
||||
readonly via: "card" | "qr" | "plate";
|
||||
}
|
||||
|
||||
export class PermitFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #inFlight = new Set<string>();
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Resolve a read to a permit (by card/QR credential, or by a bound plate), or null. */
|
||||
match(e: DeviceReadEvent): PermitMatch | null {
|
||||
// Card / QR / generic credential value.
|
||||
const cred = this.#db
|
||||
.select()
|
||||
.from(permitCredentials)
|
||||
.where(eq(permitCredentials.value, e.value))
|
||||
.get();
|
||||
if (cred) {
|
||||
return { permitId: cred.permitId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
||||
}
|
||||
// Plate binding: a read plate that matches a permit's bound plate is an identity.
|
||||
if (e.kind === "plate") {
|
||||
const plate = this.#db.select().from(permitPlates).where(eq(permitPlates.plate, e.value)).get();
|
||||
if (plate) return { permitId: plate.permitId, carKey: e.value, via: "plate" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Run the permit entry/exit for a matched read at a lane. */
|
||||
async run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<void> {
|
||||
const key = `${m.permitId}:${m.carKey}`;
|
||||
if (this.#inFlight.has(key)) return;
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
await this.#run(lane, e, m);
|
||||
} catch (err) {
|
||||
this.#logger.error(`permit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<void> {
|
||||
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
|
||||
if (!permit) return;
|
||||
|
||||
// Validity: active + within the coverage window.
|
||||
const now = new Date().toISOString();
|
||||
const invalid =
|
||||
permit.status !== "active" ||
|
||||
(permit.validFrom != null && now < permit.validFrom) ||
|
||||
(permit.validTo != null && now > permit.validTo);
|
||||
if (invalid) {
|
||||
await this.#reject(lane, m, `permit ${permit.status}/out-of-window`);
|
||||
return;
|
||||
}
|
||||
|
||||
const carOpen = this.#carHasOpenSession(m.carKey);
|
||||
|
||||
if (carOpen) {
|
||||
// EXIT: this car is already inside → the read is its exit.
|
||||
await this.#log.append({
|
||||
type: "vehicle_exit",
|
||||
lane,
|
||||
direction: "exit",
|
||||
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
||||
identity: m.carKey,
|
||||
payload: { sessionRef: m.carKey, permitId: m.permitId },
|
||||
});
|
||||
await this.#open(lane, m.carKey, "permit exit");
|
||||
this.#closeCache(m.carKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
|
||||
if (permit.maxConcurrent != null) {
|
||||
const open = this.#permitOpenCount(m.permitId);
|
||||
if (open >= permit.maxConcurrent) {
|
||||
await this.#reject(lane, m, `permit at capacity (${open}/${permit.maxConcurrent} cars in)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
lane,
|
||||
direction: "entry",
|
||||
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
||||
identity: m.carKey,
|
||||
// No ticket, no fee — the permit IS the authorization. Recorded for audit.
|
||||
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true },
|
||||
occurredAt: now,
|
||||
});
|
||||
await this.#open(lane, m.carKey, "permit entry");
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: m.carKey, lane, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" })
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Does this specific car (credential value) have an open session right now? */
|
||||
#carHasOpenSession(carKey: string): boolean {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, carKey))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
const entries = rows.filter((r) => r.type === "vehicle_entry").length;
|
||||
const exits = rows.filter((r) => r.type === "vehicle_exit").length;
|
||||
return entries > exits;
|
||||
}
|
||||
|
||||
/** How many of this permit's cars are inside right now (fold over the ledger). */
|
||||
#permitOpenCount(permitId: string): number {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(and(eq(ledgerEvents.type, "vehicle_entry")))
|
||||
.all()
|
||||
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId);
|
||||
let open = 0;
|
||||
for (const entry of rows) {
|
||||
if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
|
||||
open += 1;
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
async #reject(lane: number, m: PermitMatch, reason: string): Promise<void> {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: m.carKey,
|
||||
payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true },
|
||||
});
|
||||
this.#logger.warn(`permit refused (lane ${lane}, ${m.carKey}): ${reason}`);
|
||||
}
|
||||
|
||||
async #open(lane: number, carKey: string, what: string): Promise<void> {
|
||||
const access = await this.#access(lane);
|
||||
if (access) await access.pulseOpen(1);
|
||||
else this.#logger.warn(`${what} signed for ${carKey} but lane ${lane} has no access device`);
|
||||
}
|
||||
|
||||
#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}`);
|
||||
}
|
||||
}
|
||||
|
||||
async #access(lane: number): Promise<AccessControlDevice | null> {
|
||||
const row = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane)))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(row.config as never) as AccessControlDevice;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent } from "./device-events.js";
|
||||
import type { ExitFlow } from "./exit-flow.js";
|
||||
import type { PermitFlow } from "./permit-flow.js";
|
||||
import { readerLaneWithAccess } from "./lane-map.js";
|
||||
|
||||
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||||
// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||
// credential is (decision 2026-06-15):
|
||||
// - matches a permit (card/QR/bound plate) → PERMIT flow (direction inferred from
|
||||
// the car's open-session state),
|
||||
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||
// Lane is resolved once here; both flows act on a known access-equipped lane.
|
||||
|
||||
export class ReadDispatcher {
|
||||
readonly #db: Db;
|
||||
readonly #exit: ExitFlow;
|
||||
readonly #permit: PermitFlow;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
|
||||
constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#exit = exit;
|
||||
this.#permit = permit;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
async dispatch(e: DeviceReadEvent): Promise<void> {
|
||||
const lane = await readerLaneWithAccess(this.#db, e.deviceId);
|
||||
if (lane == null) return; // reader not on an access-equipped lane — ignore
|
||||
|
||||
const permit = this.#permit.match(e);
|
||||
if (permit) {
|
||||
await this.#permit.run(lane, e, permit);
|
||||
return;
|
||||
}
|
||||
// Not a permit → transient ticket exit (the exit flow rejects+logs if unknown).
|
||||
await this.#exit.handleAt(lane, e);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import bcrypt from "bcrypt";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import {
|
||||
TOKEN_TTL,
|
||||
clearAuthCookies,
|
||||
newCsrfToken,
|
||||
requireRole,
|
||||
@@ -34,10 +33,13 @@ 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).
|
||||
const token = await reply.jwtSign({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
csrf,
|
||||
});
|
||||
setAuthCookies(reply, token, csrf);
|
||||
return { id: user.id, username: user.username, role: user.role };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireRole } from "../auth.js";
|
||||
import {
|
||||
NoOpenSessionError,
|
||||
NoTariffError,
|
||||
type PayStation,
|
||||
} from "../pay-station.js";
|
||||
|
||||
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session
|
||||
// then takes payment; the payment becomes a signed ledger event. PCI scope stays
|
||||
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender`
|
||||
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md.
|
||||
|
||||
interface QuoteQuery {
|
||||
identity: string;
|
||||
}
|
||||
interface PayBody {
|
||||
identity: string;
|
||||
tender: "cash" | "card";
|
||||
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
|
||||
overrideMinor?: number;
|
||||
}
|
||||
|
||||
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> {
|
||||
// Cashier/operator/admin operate the pay station; readonly may not.
|
||||
const guard = requireRole("admin", "operator", "cashier");
|
||||
|
||||
// Quote: what does this session owe right now? (No side effect.)
|
||||
app.get<{ Querystring: QuoteQuery }>(
|
||||
"/api/pay/quote",
|
||||
{ preHandler: guard },
|
||||
async (req, reply) => {
|
||||
const identity = (req.query.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
try {
|
||||
return payStation.quote(identity);
|
||||
} catch (err) {
|
||||
return mapError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Pay: take payment and append the signed `payment` event.
|
||||
app.post<{ Body: PayBody }>(
|
||||
"/api/pay",
|
||||
{ preHandler: guard },
|
||||
async (req, reply) => {
|
||||
const { identity, tender, overrideMinor } = req.body ?? {};
|
||||
if (!identity || (tender !== "cash" && tender !== "card")) {
|
||||
return reply.code(400).send({ error: "identity and tender (cash|card) required" });
|
||||
}
|
||||
if (overrideMinor != null && (!Number.isInteger(overrideMinor) || overrideMinor < 0)) {
|
||||
return reply.code(400).send({ error: "overrideMinor must be a non-negative integer (minor units)" });
|
||||
}
|
||||
try {
|
||||
const res = await payStation.pay(identity, tender, overrideMinor);
|
||||
return reply.code(201).send(res);
|
||||
} catch (err) {
|
||||
return mapError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mapError(reply: import("fastify").FastifyReply, err: unknown) {
|
||||
if (err instanceof NoOpenSessionError) return reply.code(404).send({ error: err.message });
|
||||
if (err instanceof NoTariffError) return reply.code(409).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, permitCredentials, permitPlates, permits, type Db } from "@parking/db";
|
||||
import { requireRole } from "../auth.js";
|
||||
|
||||
// Permit (subscription) admin CRUD. A permit is mutable master data — admins
|
||||
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
|
||||
// trail stays append-only (see wiki/entities/permit.md). A permit is an aggregate:
|
||||
// the permit row + its credentials (card/QR) + its bound plates. The API treats them
|
||||
// as one unit (create/update replace the child sets; delete removes all).
|
||||
|
||||
interface Credential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
interface PermitBody {
|
||||
holderName?: string;
|
||||
contact?: string;
|
||||
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
|
||||
maxConcurrent?: number | null;
|
||||
validFrom?: string | null;
|
||||
validTo?: string | null;
|
||||
status?: "active" | "suspended" | "revoked";
|
||||
credentials?: Credential[];
|
||||
/** Plate binding (optional): bound plates that also serve as identity. */
|
||||
plates?: string[];
|
||||
}
|
||||
|
||||
export async function permitRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Admin manages permits; operator/cashier/readonly may LIST (to look one up).
|
||||
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
const writeGuard = requireRole("admin");
|
||||
|
||||
// Validate the body; returns problems (empty = ok). Shared by create + update.
|
||||
function validate(b: PermitBody): string[] {
|
||||
const errs: string[] = [];
|
||||
if (b.maxConcurrent != null) {
|
||||
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
|
||||
errs.push("maxConcurrent must be a positive integer, or null for unbound");
|
||||
}
|
||||
}
|
||||
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||
errs.push("status must be active|suspended|revoked");
|
||||
}
|
||||
for (const c of b.credentials ?? []) {
|
||||
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
|
||||
errs.push("each credential needs kind (rf|qr) and a non-empty value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
|
||||
errs.push("a permit needs at least one credential or one bound plate (else nothing identifies it)");
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
function loadAggregate(id: string) {
|
||||
const permit = db.select().from(permits).where(eq(permits.id, id)).get();
|
||||
if (!permit) return null;
|
||||
const credentials = db.select().from(permitCredentials).where(eq(permitCredentials.permitId, id)).all();
|
||||
const plates = db.select().from(permitPlates).where(eq(permitPlates.permitId, id)).all();
|
||||
return {
|
||||
...permit,
|
||||
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
|
||||
plates: plates.map((p) => p.plate),
|
||||
};
|
||||
}
|
||||
|
||||
// Replace a permit's child rows (credentials + plates) from the body.
|
||||
function writeChildren(id: string, b: PermitBody) {
|
||||
db.delete(permitCredentials).where(eq(permitCredentials.permitId, id)).run();
|
||||
db.delete(permitPlates).where(eq(permitPlates.permitId, id)).run();
|
||||
for (const c of b.credentials ?? []) {
|
||||
db.insert(permitCredentials).values({ id: randomUUID(), permitId: id, kind: c.kind, value: c.value.trim() }).run();
|
||||
}
|
||||
for (const p of b.plates ?? []) {
|
||||
if (p.trim()) db.insert(permitPlates).values({ id: randomUUID(), permitId: id, plate: p.trim() }).run();
|
||||
}
|
||||
}
|
||||
|
||||
// List all permits (with their credentials + plates).
|
||||
app.get("/api/permits", { preHandler: readGuard }, async () => {
|
||||
const rows = db.select().from(permits).all();
|
||||
return { permits: rows.map((r) => loadAggregate(r.id)) };
|
||||
});
|
||||
|
||||
// Create a permit.
|
||||
app.post<{ Body: PermitBody }>("/api/permits", { preHandler: writeGuard }, async (req, reply) => {
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
|
||||
const id = randomUUID();
|
||||
db.insert(permits)
|
||||
.values({
|
||||
id,
|
||||
holderName: b.holderName ?? null,
|
||||
contact: b.contact ?? null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: b.validTo ?? null,
|
||||
status: b.status ?? "active",
|
||||
})
|
||||
.run();
|
||||
writeChildren(id, b);
|
||||
return reply.code(201).send(loadAggregate(id));
|
||||
});
|
||||
|
||||
// Update a permit (replaces fields + child sets).
|
||||
app.put<{ Params: { id: string }; Body: PermitBody }>(
|
||||
"/api/permits/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const existing = db.select().from(permits).where(eq(permits.id, req.params.id)).get();
|
||||
if (!existing) return reply.code(404).send({ error: "permit not found" });
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
|
||||
db.update(permits)
|
||||
.set({
|
||||
holderName: b.holderName ?? null,
|
||||
contact: b.contact ?? null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: b.validTo ?? null,
|
||||
status: b.status ?? existing.status,
|
||||
})
|
||||
.where(eq(permits.id, req.params.id))
|
||||
.run();
|
||||
writeChildren(req.params.id, b);
|
||||
return loadAggregate(req.params.id);
|
||||
},
|
||||
);
|
||||
|
||||
// Revoke (soft): the common case — keeps the permit + its history, just bars it.
|
||||
// A revoked permit fails the entry check (see permit-flow.ts). Use DELETE only to
|
||||
// fully remove a permit created in error.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/api/permits/:id/revoke",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.update(permits).set({ status: "revoked" }).where(eq(permits.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
|
||||
return loadAggregate(req.params.id);
|
||||
},
|
||||
);
|
||||
|
||||
// Hard delete a permit + its child rows. (Past ledger events that reference it
|
||||
// are untouched — the audit trail is append-only and independent of this row.)
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/permits/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.delete(permits).where(eq(permits.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
|
||||
db.delete(permitCredentials).where(eq(permitCredentials.permitId, req.params.id)).run();
|
||||
db.delete(permitPlates).where(eq(permitPlates.permitId, req.params.id)).run();
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { validateTariffStructure, type TariffStructure } from "@parking/shared";
|
||||
import { requireRole } from "../auth.js";
|
||||
|
||||
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
|
||||
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
|
||||
// mutates one; a session reprices against the version in force at its entry, and
|
||||
// the `payment` event records the tariffVersionId. "One active tariff per site" for
|
||||
// now (a single `tariffs` row, lazily created). See wiki/concepts/tariff.md.
|
||||
|
||||
interface PublishBody {
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||
effectiveFrom?: string;
|
||||
}
|
||||
|
||||
const SITE_TARIFF_NAME = "Site tariff";
|
||||
|
||||
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Any signed-in role may READ the tariff (the pay station / operator UI needs it).
|
||||
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
// Only an admin may PUBLISH a new version (it changes what customers are charged).
|
||||
const writeGuard = requireRole("admin");
|
||||
|
||||
// The single site tariff row, created on first read/publish.
|
||||
function ensureSiteTariff(): string {
|
||||
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||
if (existing) return existing.id;
|
||||
const id = randomUUID();
|
||||
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
|
||||
return id;
|
||||
}
|
||||
|
||||
// Current state: the active (latest-effective, ≤ now) version + the full history.
|
||||
app.get("/api/tariff", { preHandler: readGuard }, async () => {
|
||||
const tariffId = ensureSiteTariff();
|
||||
const versions = db
|
||||
.select()
|
||||
.from(tariffVersions)
|
||||
.where(eq(tariffVersions.tariffId, tariffId))
|
||||
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||
.all();
|
||||
const now = new Date().toISOString();
|
||||
const active = versions.find((v) => v.effectiveFrom <= now) ?? null;
|
||||
return { tariffId, active, versions };
|
||||
});
|
||||
|
||||
// Publish a new immutable version. Validates the structure first — a malformed
|
||||
// rate card can never be published (the fee calc + the chain depend on it).
|
||||
app.post<{ Body: PublishBody }>(
|
||||
"/api/tariff/versions",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
|
||||
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||
}
|
||||
const problems = validateTariffStructure(structure);
|
||||
if (problems.length) {
|
||||
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||
}
|
||||
const tariffId = ensureSiteTariff();
|
||||
const id = randomUUID();
|
||||
const row = {
|
||||
id,
|
||||
tariffId,
|
||||
effectiveFrom: effectiveFrom ?? new Date().toISOString(),
|
||||
currency,
|
||||
structure: structure as unknown as Record<string, unknown>,
|
||||
createdBy: req.user?.username ?? null,
|
||||
};
|
||||
db.insert(tariffVersions).values(row).run();
|
||||
return reply.code(201).send(row);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,19 @@ import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { EventLog } from "./event-log.js";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
import { PayStation } from "./pay-station.js";
|
||||
import { PermitFlow } from "./permit-flow.js";
|
||||
import { ReadDispatcher } from "./read-dispatch.js";
|
||||
import { LaneMap } from "./lane-map.js";
|
||||
import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { buildSigner } from "./signer.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { deviceRoutes } from "./routes/devices.js";
|
||||
import { eventRoutes } from "./routes/events.js";
|
||||
import { payRoutes } from "./routes/pay.js";
|
||||
import { permitRoutes } from "./routes/permits.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
import { printerRoutes } from "./routes/printers.js";
|
||||
import { setupRoutes } from "./routes/setup.js";
|
||||
|
||||
@@ -40,7 +47,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 },
|
||||
});
|
||||
|
||||
@@ -91,6 +99,29 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeEntry());
|
||||
|
||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||
// dispatcher to either the PERMIT flow (if it matches a permit) or the transient
|
||||
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md.
|
||||
const exitFlow = new ExitFlow(db, eventLog, app.log);
|
||||
const permitFlow = new PermitFlow(db, eventLog, app.log);
|
||||
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log);
|
||||
const unsubscribeRead = deviceEvents.onRead((e) => {
|
||||
void readDispatcher.dispatch(e);
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeRead());
|
||||
|
||||
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
||||
const payStation = new PayStation(db, eventLog, app.log);
|
||||
await payRoutes(app, payStation);
|
||||
|
||||
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||
await tariffRoutes(app, db);
|
||||
|
||||
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||
await permitRoutes(app, db);
|
||||
|
||||
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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchMe, logout, type SessionUser } from "./api.js";
|
||||
import { Login } from "./Login.js";
|
||||
import { PermitManager } from "./PermitManager.js";
|
||||
import { SetupWizard } from "./SetupWizard.js";
|
||||
import { TariffComposer } from "./TariffComposer.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.
|
||||
@@ -39,7 +41,11 @@ export function App() {
|
||||
</span>
|
||||
</header>
|
||||
{user.role === "admin" ? (
|
||||
<SetupWizard />
|
||||
<>
|
||||
<SetupWizard />
|
||||
<TariffComposer />
|
||||
<PermitManager />
|
||||
</>
|
||||
) : (
|
||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
createPermit,
|
||||
deletePermit,
|
||||
fetchPermits,
|
||||
revokePermit,
|
||||
updatePermit,
|
||||
type Permit,
|
||||
type PermitCredential,
|
||||
type PermitInput,
|
||||
} from "./api.js";
|
||||
|
||||
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
|
||||
// credentials (card/QR) and bound plates. A permit is mutable master data; every
|
||||
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
|
||||
|
||||
interface FormState {
|
||||
holderName: string;
|
||||
contact: string;
|
||||
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||
maxConcurrent: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
credentials: PermitCredential[];
|
||||
platesText: string; // comma/space separated
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
|
||||
}
|
||||
function formFrom(p: Permit): FormState {
|
||||
return {
|
||||
holderName: p.holderName ?? "",
|
||||
contact: p.contact ?? "",
|
||||
carBound: p.maxConcurrent != null,
|
||||
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
|
||||
validFrom: p.validFrom ?? "",
|
||||
validTo: p.validTo ?? "",
|
||||
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
|
||||
platesText: p.plates.join(", "),
|
||||
};
|
||||
}
|
||||
function toInput(f: FormState): PermitInput {
|
||||
return {
|
||||
holderName: f.holderName.trim() || null,
|
||||
contact: f.contact.trim() || null,
|
||||
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
||||
validFrom: f.validFrom.trim() || null,
|
||||
validTo: f.validTo.trim() || null,
|
||||
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
|
||||
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export function PermitManager() {
|
||||
const [permits, setPermits] = useState<Permit[] | null>(null);
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
function reload() {
|
||||
fetchPermits()
|
||||
.then((r) => setPermits(r.permits))
|
||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
}
|
||||
useEffect(reload, []);
|
||||
|
||||
function startNew() {
|
||||
setForm(emptyForm());
|
||||
setEditing("new");
|
||||
setMsg(null);
|
||||
}
|
||||
function startEdit(p: Permit) {
|
||||
setForm(formFrom(p));
|
||||
setEditing(p.id);
|
||||
setMsg(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
if (editing === "new") await createPermit(toInput(form));
|
||||
else if (editing) await updatePermit(editing, toInput(form));
|
||||
setEditing(null);
|
||||
reload();
|
||||
setMsg({ kind: "ok", text: "Permit saved." });
|
||||
} catch (e) {
|
||||
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||||
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||
}
|
||||
}
|
||||
async function doRevoke(p: Permit) {
|
||||
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
|
||||
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
async function doDelete(p: Permit) {
|
||||
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
|
||||
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
|
||||
function setCred(i: number, patch: Partial<PermitCredential>) {
|
||||
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
||||
}
|
||||
|
||||
if (!permits) return null;
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Permits</h2>
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{permits.map((p) => (
|
||||
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
|
||||
<strong>{p.holderName ?? "(unnamed)"}</strong>
|
||||
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
|
||||
<span style={{ color: "#666" }}>
|
||||
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
|
||||
{p.credentials.length} cred · {p.plates.length} plate(s)
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => startEdit(p)}>Edit</button>
|
||||
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
|
||||
<button type="button" onClick={() => doDelete(p)}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
|
||||
</ul>
|
||||
|
||||
{editing == null ? (
|
||||
<button type="button" onClick={startNew}>+ Add permit</button>
|
||||
) : (
|
||||
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
||||
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
||||
<label>Holder name</label>
|
||||
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||
<label>Contact</label>
|
||||
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||
<label>Car limit</label>
|
||||
<span>
|
||||
<label style={{ marginRight: "0.5rem" }}>
|
||||
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once
|
||||
</label>
|
||||
{form.carBound && (
|
||||
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
||||
)}
|
||||
</span>
|
||||
<label>Valid from</label>
|
||||
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
|
||||
<label>Valid to</label>
|
||||
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
|
||||
<label>Bound plates</label>
|
||||
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
|
||||
</div>
|
||||
|
||||
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||
<option value="rf">RF card/tag</option>
|
||||
<option value="qr">QR</option>
|
||||
</select>
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button>
|
||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||
A permit needs at least one credential OR one bound plate.
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||
<button type="button" onClick={save}>Save</button>
|
||||
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
fetchTariff,
|
||||
publishTariffVersion,
|
||||
type TariffBlock,
|
||||
type TariffStructure,
|
||||
type TariffState,
|
||||
} from "./api.js";
|
||||
|
||||
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
|
||||
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||
// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
|
||||
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
|
||||
|
||||
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||
interface BlockForm {
|
||||
uptoMin: string; // "" = open-ended (last block)
|
||||
price: string; // major units, e.g. "2.00"
|
||||
}
|
||||
interface FormState {
|
||||
currency: string;
|
||||
gracePeriodEntryMin: string;
|
||||
incrementMin: string;
|
||||
dailyCap: string; // "" = no cap
|
||||
lostTicket: string;
|
||||
gracePeriodExitMin: string;
|
||||
blocks: BlockForm[];
|
||||
}
|
||||
|
||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
currency: "EUR",
|
||||
gracePeriodEntryMin: "15",
|
||||
incrementMin: "60",
|
||||
dailyCap: "",
|
||||
lostTicket: "20.00",
|
||||
gracePeriodExitMin: "15",
|
||||
blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }],
|
||||
};
|
||||
}
|
||||
|
||||
function formFromActive(s: TariffState): FormState {
|
||||
const v = s.active;
|
||||
if (!v) return emptyForm();
|
||||
const st = v.structure;
|
||||
return {
|
||||
currency: v.currency,
|
||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||
incrementMin: String(st.incrementMin),
|
||||
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
|
||||
lostTicket: toMajor(st.lostTicketMinor),
|
||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||
blocks: st.blocks.map((b) => ({
|
||||
uptoMin: b.uptoMin == null ? "" : String(b.uptoMin),
|
||||
price: toMajor(b.priceMinorPerIncrement),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function toStructure(f: FormState): TariffStructure {
|
||||
const blocks: TariffBlock[] = f.blocks.map((b) => ({
|
||||
uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)),
|
||||
priceMinorPerIncrement: toMinor(b.price),
|
||||
}));
|
||||
return {
|
||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||
incrementMin: Math.round(Number(f.incrementMin)),
|
||||
blocks,
|
||||
dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap),
|
||||
lostTicketMinor: toMinor(f.lostTicket),
|
||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||
overstay: "reprice",
|
||||
};
|
||||
}
|
||||
|
||||
export function TariffComposer() {
|
||||
const [state, setState] = useState<TariffState | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTariff()
|
||||
.then((s) => {
|
||||
setState(s);
|
||||
setForm(formFromActive(s));
|
||||
})
|
||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
}, []);
|
||||
|
||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
function setBlock(i: number, patch: Partial<BlockForm>) {
|
||||
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
function addBlock() {
|
||||
setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] }));
|
||||
}
|
||||
function removeBlock(i: number) {
|
||||
setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) }));
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
||||
const fresh = await fetchTariff();
|
||||
setState(fresh);
|
||||
setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." });
|
||||
} catch (e) {
|
||||
const text =
|
||||
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
||||
? `${e.message}: ${((e as ApiError & { problems?: string[] }).problems ?? []).join("; ")}`
|
||||
: (e as Error).message;
|
||||
setMsg({ kind: "err", text });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Tariff</h2>
|
||||
{!state?.active ? (
|
||||
<p style={{ color: "#b45309" }}>
|
||||
No rate card published yet — the pay station can't charge until you publish one.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ color: "#555" }}>
|
||||
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "}
|
||||
{state.versions.length} version(s) in history. Publishing creates a new version; past
|
||||
sessions keep their original pricing.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
|
||||
<label>Currency</label>
|
||||
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
|
||||
<label>Free entry grace (min)</label>
|
||||
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label>Billing increment (min)</label>
|
||||
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label>Daily cap (blank = none)</label>
|
||||
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
|
||||
<label>Lost-ticket fee</label>
|
||||
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label>Exit walk-back grace (min)</label>
|
||||
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
|
||||
Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last
|
||||
block's bound blank for "thereafter". Price is per billing increment.
|
||||
</p>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{form.blocks.map((b, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<input
|
||||
value={b.uptoMin}
|
||||
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
|
||||
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||
+ Add block
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<button type="button" onClick={publish} disabled={saving}>
|
||||
{saving ? "Publishing…" : "Publish new version"}
|
||||
</button>
|
||||
</div>
|
||||
{msg && (
|
||||
<p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson", marginTop: "0.5rem" }}>{msg.text}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -197,3 +197,83 @@ 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;
|
||||
}
|
||||
export interface TariffStructure {
|
||||
gracePeriodEntryMin: number;
|
||||
incrementMin: number;
|
||||
blocks: TariffBlock[];
|
||||
dailyCapMinor: number | null;
|
||||
lostTicketMinor: number;
|
||||
gracePeriodExitMin: number;
|
||||
overstay: "reprice";
|
||||
}
|
||||
export interface TariffVersion {
|
||||
id: string;
|
||||
tariffId: string;
|
||||
effectiveFrom: string;
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
createdBy?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
export interface TariffState {
|
||||
tariffId: string;
|
||||
active: TariffVersion | null;
|
||||
versions: TariffVersion[];
|
||||
}
|
||||
|
||||
export function fetchTariff(): Promise<TariffState> {
|
||||
return apiFetch<TariffState>("/api/tariff");
|
||||
}
|
||||
|
||||
/** Publish a new immutable tariff version (becomes the active rate card). */
|
||||
export function publishTariffVersion(body: {
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
effectiveFrom?: string;
|
||||
}): Promise<TariffVersion> {
|
||||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
// --- Permits --------------------------------------------------------------
|
||||
|
||||
export interface PermitCredential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
export interface Permit {
|
||||
id: string;
|
||||
holderName: string | null;
|
||||
contact: string | null;
|
||||
maxConcurrent: number | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
status: "active" | "suspended" | "revoked";
|
||||
credentials: PermitCredential[];
|
||||
plates: string[];
|
||||
}
|
||||
export type PermitInput = Omit<Permit, "id" | "status"> & {
|
||||
status?: Permit["status"];
|
||||
};
|
||||
|
||||
export function fetchPermits(): Promise<{ permits: Permit[] }> {
|
||||
return apiFetch("/api/permits");
|
||||
}
|
||||
export function createPermit(body: PermitInput): Promise<Permit> {
|
||||
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
|
||||
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function revokePermit(id: string): Promise<Permit> {
|
||||
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" });
|
||||
}
|
||||
export function deletePermit(id: string): Promise<void> {
|
||||
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -113,6 +113,103 @@ export interface TariffBlock {
|
||||
readonly priceMinorPerIncrement: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the parking fee (integer minor units) for a stay, from a TariffStructure.
|
||||
* PURE + deterministic + offline — the pay station calls it with asOf = now; the
|
||||
* result is fixed into a signed `payment` event, so it must be reproducible.
|
||||
*
|
||||
* Algorithm (wiki/concepts/tariff.md): round duration UP to incrementMin; free if
|
||||
* within entry grace; else walk the stay one rolling-24h segment at a time, charging
|
||||
* each increment at its block's rate (blocks consumed in order by cumulative minutes),
|
||||
* capping each segment at dailyCapMinor. Times are ISO-8601; bad input → 0 (caller
|
||||
* validates the tariff exists first).
|
||||
*/
|
||||
export function computeFee(
|
||||
enteredAt: string,
|
||||
asOf: string,
|
||||
tariff: TariffStructure,
|
||||
): number {
|
||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
const rawMinutes = ms / 60_000;
|
||||
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
||||
// 60 min — otherwise rounding-up would defeat the grace window).
|
||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
|
||||
const inc = Math.max(1, tariff.incrementMin);
|
||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||
|
||||
const DAY = 24 * 60;
|
||||
let total = 0;
|
||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||
const segEnd = Math.min(segStart + DAY, minutes);
|
||||
let segFee = 0;
|
||||
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
||||
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
||||
for (let within = 0; segStart + within < segEnd; within += inc) {
|
||||
segFee += rateAt(tariff.blocks, within);
|
||||
}
|
||||
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
|
||||
total += segFee;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an admin-authored tariff structure. Returns [] if valid, else a list
|
||||
* of human-readable problems. Pure — used by the composer route (and any caller)
|
||||
* so a malformed rate card can never be published. See wiki/concepts/tariff.md.
|
||||
*/
|
||||
export function validateTariffStructure(s: unknown): string[] {
|
||||
const errs: string[] = [];
|
||||
if (!s || typeof s !== "object") return ["structure must be an object"];
|
||||
const t = s as Partial<TariffStructure>;
|
||||
|
||||
const nonNegInt = (v: unknown, label: string) => {
|
||||
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
|
||||
};
|
||||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin");
|
||||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin");
|
||||
nonNegInt(t.lostTicketMinor, "lostTicketMinor");
|
||||
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||||
errs.push("incrementMin must be a positive integer");
|
||||
}
|
||||
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor");
|
||||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||
|
||||
if (!Array.isArray(t.blocks) || t.blocks.length === 0) {
|
||||
errs.push("blocks must be a non-empty array");
|
||||
} else {
|
||||
let prevBound = 0;
|
||||
t.blocks.forEach((b, i) => {
|
||||
const last = i === t.blocks!.length - 1;
|
||||
nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`);
|
||||
if (b?.uptoMin == null) {
|
||||
if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`);
|
||||
} else {
|
||||
if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
|
||||
errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
|
||||
} else {
|
||||
prevBound = b.uptoMin;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
/** Price of the increment that starts at `cumulativeMin` — the block whose range
|
||||
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
|
||||
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||
let prev = 0;
|
||||
for (const b of blocks) {
|
||||
if (b.uptoMin == null || cumulativeMin < b.uptoMin) return b.priceMinorPerIncrement;
|
||||
prev = b.uptoMin;
|
||||
void prev;
|
||||
}
|
||||
// No open-ended block and past the last bound: charge the last block's rate.
|
||||
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0;
|
||||
}
|
||||
|
||||
export const ROLES: readonly Role[] = [
|
||||
"admin",
|
||||
"operator",
|
||||
|
||||
@@ -96,8 +96,34 @@ Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorizatio
|
||||
|
||||
## What this unblocks (build order)
|
||||
|
||||
The device layer left the entry flow dangling — `input_received` events land in the log and stop
|
||||
([[device-input-flow]] "the entry flow itself is the next build"). The session domain is that next
|
||||
step: consume `input_received` / a reader event → mint a signed `vehicle_entry` → print + open.
|
||||
Then the pay-station and exit-validation flows. Schema + code follow this page and [[tariff]];
|
||||
the decision is recorded in [[session-model]].
|
||||
The device layer left the entry flow dangling — the session domain is that next step. Schema + code
|
||||
follow this page and [[tariff]]; the decision is recorded in [[session-model]].
|
||||
|
||||
### As-built (2026-06-15)
|
||||
|
||||
- **Entry flow** (`apps/server/src/entry-flow.ts`): access-device input edge → print ticket
|
||||
(failover) → signed `vehicle_entry` → `pulseOpen`. Holds (anomaly, no open, no entry) if printing
|
||||
fails. See [[device-input-flow]].
|
||||
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
|
||||
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
|
||||
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built.
|
||||
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) →
|
||||
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
|
||||
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
|
||||
stays closed. Validation folds the **ledger** (authoritative), then updates the `sessions` cache.
|
||||
- **Not a fail-state:** an unpaid reject keeps the barrier closed deliberately (driver returns to
|
||||
the pay station); "exit fails open" ([[fail-state-safety]]) is about the *system* being unable
|
||||
to decide (host/power loss), not an unpaid car.
|
||||
- **Pay station** (`apps/server/src/pay-station.ts`, routes `GET /api/pay/quote` + `POST /api/pay`):
|
||||
look up the open session → resolve the active tariff version (latest `effectiveFrom ≤ entry`) →
|
||||
`computeFee` → append a signed `payment` event (amount, currency, tender, `tariffVersionId`,
|
||||
`graceExitMin`). An operator `overrideMinor` covers lost-ticket/dispute (recorded as the charged
|
||||
amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays
|
||||
out of the app — `tender` only records cash/card; card capture is the standalone P2PE terminal.
|
||||
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
|
||||
session closed, `verifyChain` ok.
|
||||
|
||||
> **Design gap (flagged):** `lane_devices` has **no entry/exit direction** model. Entry is
|
||||
> button-driven and exit is read-driven, so they don't currently collide — but a lane with both an
|
||||
> entry reader and an exit reader can't yet be distinguished. A lane-direction/role model is needed
|
||||
> before multi-reader lanes (relates to [[open-questions]] #1 topology).
|
||||
|
||||
@@ -87,6 +87,31 @@ Deterministic, side-effect-free, unit-testable; the daily cap is applied **per r
|
||||
overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec
|
||||
because the chain + reconciliation depend on the result being reproducible.
|
||||
|
||||
**Settled edges (2026-06-15, with tests):**
|
||||
- **Grace uses RAW duration** — a stay within `gracePeriodEntryMin` is free even though the
|
||||
increment would round it up (else rounding defeats the grace window).
|
||||
- **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h
|
||||
stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily.
|
||||
|
||||
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
||||
across grace, block steps, daily cap, and multi-day reset.
|
||||
|
||||
### Composer (as-built 2026-06-15)
|
||||
|
||||
The admin authors the rate card at runtime — no hand-seeding:
|
||||
|
||||
- **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any
|
||||
signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**).
|
||||
Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers,
|
||||
ordered/ascending block bounds, only the last block open-ended — so a malformed card can never be
|
||||
published. The single site `tariffs` row is created lazily on first read/publish.
|
||||
- **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment,
|
||||
daily cap, lost-ticket fee, and add/remove rate blocks; amounts entered in major units, converted
|
||||
to integer minor units on submit. Shows the active version + history; "Publish" creates a new
|
||||
version (past sessions keep their pricing).
|
||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||
|
||||
## The pay-on-foot consequence
|
||||
|
||||
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
|
||||
|
||||
@@ -14,13 +14,13 @@ Authentication and authorization, kept **fully local** — a direct consequence
|
||||
- `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to
|
||||
start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no
|
||||
insecure default.
|
||||
- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15).
|
||||
- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15, built).
|
||||
Booth reality breaks any fixed clock: relief arrives late, fails to show, or one operator is
|
||||
forced to work two shifts in a row — a token that expired mid-duty would strand an active
|
||||
operator. So the login persists until logout; a **[[shift]] is a separate, explicit boundary**,
|
||||
not tied to token lifetime. (Superseded the earlier "8h expiry, bound to a shift" assumption.)
|
||||
> ⚠️ Code still mints an 8h-expiry token — this page records the decided design; the server
|
||||
> change (drop `expiresIn`, persist until logout) is pending.
|
||||
The JWT carries no `exp`; the cookie has a long fixed `maxAge` (30 days) so a browser restart
|
||||
doesn't log out an active operator, and `logout` clears it.
|
||||
- A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The
|
||||
first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint).
|
||||
- Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier /
|
||||
|
||||
@@ -99,6 +99,36 @@ stays append-only even though the permit record itself is editable.
|
||||
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
|
||||
refused, per policy (OPEN).
|
||||
|
||||
## As-built (2026-06-15)
|
||||
|
||||
`apps/server/src/permit-flow.ts`, reached via the **read dispatcher**
|
||||
(`read-dispatch.ts`): a credential read routes to the permit flow if it **matches a permit**
|
||||
(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler
|
||||
serves both populations ([[entry-exit-readers]]), disambiguated by *what the credential is*.
|
||||
|
||||
- **Direction is inferred from session state for that car** — the read credential value is the
|
||||
per-car session key. No open session for that car → **ENTRY** (check `maxConcurrent`, sign
|
||||
`vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_exit`, open, close). A fleet
|
||||
permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an
|
||||
inside car is its exit, never a second entry).
|
||||
- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's
|
||||
`vehicle_entry` events whose car has no later exit; reject at the limit (`null` = unbound).
|
||||
- **Validity** (active + within `validFrom`/`validTo`) and **plate-OR-card identity** as designed.
|
||||
No ticket, no fee — the permit is the authorization; every use is still a signed ledger event
|
||||
carrying `permitId`.
|
||||
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
|
||||
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
|
||||
|
||||
**Admin CRUD** (`apps/server/src/routes/permits.ts` + `apps/web/src/PermitManager.tsx`): a permit is
|
||||
an **aggregate** (the row + its credentials + bound plates); create/update treat it as one unit
|
||||
(child sets are replaced on update). `GET /api/permits` (any signed-in role — for lookup),
|
||||
`POST/PUT/DELETE /api/permits[/:id]` + `POST /api/permits/:id/revoke` (**admin only**). Validation:
|
||||
`maxConcurrent` is a positive int or `null` (unbound); a permit must have **at least one credential
|
||||
or one bound plate** (else nothing identifies it). Revoke is the soft, common case (keeps history,
|
||||
barred at the barrier); DELETE hard-removes — past ledger events that reference the permit are
|
||||
untouched (the audit trail is append-only and independent). Verified via inject (validation, child
|
||||
replacement, RBAC, revoke/delete).
|
||||
|
||||
## Resolved (2026-06-15)
|
||||
|
||||
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
|
||||
|
||||
+77
@@ -500,3 +500,80 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
||||
new custody/session shape. Captured as [[valet-overcapacity]] + made [[capacity-occupancy]] FULL a
|
||||
soft policy; NOT built into the entry flow (clean seam left). Deferred.
|
||||
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]].
|
||||
|
||||
## [2026-06-15] build | Exit flow (pay-on-foot validation)
|
||||
- Built `apps/server/src/exit-flow.ts`. Added a `read` channel to the device bus (DeviceReadEvent:
|
||||
ticket/plate/qr/card) — readers/LPR emit reads; entry stays button-driven, so reads are
|
||||
unambiguously exit/identity events for now.
|
||||
- Flow: read → fold the SIGNED ledger for that identity → validate open + PAID + within
|
||||
`gracePeriodExitMin` → signed `vehicle_exit` → pulseOpen → close the session cache. Unpaid /
|
||||
grace-expired / unknown → signed `anomaly`, barrier stays closed (a deliberate business reject,
|
||||
NOT a fail-state; "exit fails open" is about host/power loss). Validation reads the ledger
|
||||
(authoritative), not the cache.
|
||||
- Pay station doesn't exist yet → no `payment` events → every transient exit currently REJECTS.
|
||||
Correct end-state, not passable until pay-station lands (decided).
|
||||
- VERIFIED against stubs: unpaid→anomaly+no-open; paid+grace→vehicle_exit+open+closed; grace-expired
|
||||
→anomaly; unknown ticket→anomaly; verifyChain ok across entry→pay→exit.
|
||||
- GAP flagged: lane_devices has no entry/exit DIRECTION model (door mapping hardcoded to 1 for exit);
|
||||
fine while entry=button/exit=read, but multi-reader lanes need a lane-direction/role model (ties to
|
||||
[[open-questions]] #1). Updated [[parking-session]] as-built + gap, [[index]].
|
||||
|
||||
## [2026-06-15] build | Pay station + fee calc; JWT 8h → until-logout
|
||||
- JWT: dropped the 8h `expiresIn` (server.ts global + login). Token now valid **until explicit
|
||||
logout**; cookie maxAge = 30 days so a browser restart doesn't log out an active operator
|
||||
(auth.ts `COOKIE_MAX_AGE_SECONDS`). Closes the pending change from the shift decision; updated
|
||||
[[local-jwt-auth]].
|
||||
- `computeFee(enteredAt, asOf, structure)` in `packages/shared` — pure integer fee calc.
|
||||
TWO BUGS caught by tests: (1) grace must use RAW duration, not the rounded-up minutes (a 10-min
|
||||
stay was being charged a full hour); (2) the block ladder must RESET each rolling-24h day (decision:
|
||||
day 2 restarts at first-block pricing → 25h = 1200 cap + 200). Both fixed; 9 cases pass.
|
||||
- Pay station (`apps/server/src/pay-station.ts` + routes `GET /api/pay/quote`, `POST /api/pay`):
|
||||
open session → active tariff version → computeFee → signed `payment` event (amount/currency/tender/
|
||||
tariffVersionId/graceExitMin); `overrideMinor` for lost-ticket/dispute. Cashier/operator/admin guard.
|
||||
- VERIFIED: full loop entry→quote(300 for 90min)→pay→exit opens+closes, verifyChain ok. (A
|
||||
raw-SQL backdate in one test correctly broke the chain — the tamper-evidence working, not a flow bug.)
|
||||
- Updated [[tariff]] (settled edges + as-built), [[parking-session]] (pay station as-built; full
|
||||
loop passes).
|
||||
|
||||
## [2026-06-15] build | Tariff composer (makes the pay station operable)
|
||||
- `validateTariffStructure` in `packages/shared` — non-negative ints, ascending block bounds, only
|
||||
the last block open-ended; a malformed card can't be published.
|
||||
- Routes (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active + history, any role) and
|
||||
`POST /api/tariff/versions` (publish immutable version, ADMIN only). Single site `tariffs` row
|
||||
created lazily. Editing = publish a new version (effective-dated, immutable).
|
||||
- UI (`apps/web/src/TariffComposer.tsx`, admin shell next to SetupWizard): currency, grace windows,
|
||||
increment, daily cap, lost-ticket, add/remove rate blocks; major-unit input → minor on submit;
|
||||
shows active + history.
|
||||
- VERIFIED via Fastify inject: GET empty→active null; invalid (out-of-order blocks)→400 w/ problem;
|
||||
valid→201 createdBy=admin; readonly publish→403; after publish the pay station quote returns 404
|
||||
(session) not 409 (no tariff) — i.e. it now sees the active card. Full build 5/5.
|
||||
- Updated [[tariff]] (composer as-built).
|
||||
|
||||
## [2026-06-15] build | Permit entry/exit branch + read dispatcher
|
||||
- `apps/server/src/permit-flow.ts` + `read-dispatch.ts`. A credential read now routes by WHAT the
|
||||
credential is: matches a permit (card/QR credential, or a bound plate) → permit flow; else →
|
||||
transient exit flow. Lane resolved once (`readerLaneWithAccess`, shared in lane-map.ts). Refactored
|
||||
ExitFlow.onRead → handleAt(lane,e) so the dispatcher owns lane resolution.
|
||||
- Permit DIRECTION inferred from session state for that car (the read value is the per-car session
|
||||
key): no open session → ENTRY (enforce maxConcurrent, sign vehicle_entry, open); open → EXIT (sign
|
||||
vehicle_exit, open, close). Fleet permit = one session per car; anti-passback falls out.
|
||||
- maxConcurrent enforced as a fold over the signed ledger (count the permit's entries whose car has
|
||||
no later exit); null = unbound. Validity window + status + plate-OR-card identity as designed.
|
||||
No ticket/fee; every use is a signed event carrying permitId. Refusals = signed anomaly, no open.
|
||||
- VERIFIED against stubs: card entry → inferred exit; fleet maxConcurrent=2 (F1,F2 in, F3 rejected,
|
||||
F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through
|
||||
to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5.
|
||||
- Updated [[permit]] (as-built), [[parking-session]] (read dispatch).
|
||||
|
||||
## [2026-06-15] build | Permit admin CRUD (route + UI)
|
||||
- `apps/server/src/routes/permits.ts`: a permit is an aggregate (row + credentials + bound plates);
|
||||
create/update replace the child sets as one unit. GET (any role, for lookup), POST/PUT/DELETE +
|
||||
POST /:id/revoke (admin only). Validation: maxConcurrent positive-int-or-null; must have ≥1
|
||||
credential OR ≥1 plate. Revoke = soft (keeps history); DELETE = hard (past ledger events untouched).
|
||||
- `apps/web/src/PermitManager.tsx` in the admin shell: list + add/edit (holder, car-bound toggle →
|
||||
maxConcurrent or unbound, validity window, credentials add/remove, plates as a list), revoke, delete.
|
||||
- Makes permits usable without hand-seeding (companion to the tariff composer).
|
||||
- VERIFIED via inject: empty + maxConcurrent=0 → 400 w/ messages; valid → 201; operator LIST 200 but
|
||||
create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then
|
||||
404, children cleaned. Full build 5/5.
|
||||
- Updated [[permit]] (CRUD as-built).
|
||||
|
||||
Reference in New Issue
Block a user