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, ReadOutcome } 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(); 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 { const key = `${m.permitId}:${m.carKey}`; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; this.#inFlight.add(key); try { return await this.#run(lane, e, m); } catch (err) { this.#logger.error(`permit-flow failed (lane ${lane}): ${(err as Error).message}`); return { accepted: false, reason: (err as Error).message }; } finally { this.#inFlight.delete(key); } } async #run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise { const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get(); if (!permit) return { accepted: false, reason: "permit not found" }; // Validity: active + within the coverage window. const now = new Date().toISOString(); const invalid = permit.status !== "active" || (permit.validFrom != null && now < permit.validFrom) || (permit.validTo != null && now > permit.validTo); if (invalid) { const reason = `permit ${permit.status}/out-of-window`; await this.#reject(lane, m, reason); return { accepted: false, reason }; } 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 { accepted: true, direction: "exit" }; } // ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. if (permit.maxConcurrent != null) { const open = this.#permitOpenCount(m.permitId); if (open >= permit.maxConcurrent) { const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`; await this.#reject(lane, m, reason); return { accepted: false, direction: "entry", reason }; } } 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}`); } return { accepted: true, direction: "entry" }; } /** Does this specific car (credential value) have an open session right now? */ #carHasOpenSession(carKey: string): boolean { const rows = this.#db .select() .from(ledgerEvents) .where(eq(ledgerEvents.identity, carKey)) .orderBy(ledgerEvents.index) .all(); const entries = rows.filter((r) => r.type === "vehicle_entry").length; const exits = rows.filter((r) => r.type === "vehicle_exit").length; return entries > exits; } /** How many of this permit's cars are inside right now (fold over the ledger). */ #permitOpenCount(permitId: string): number { const rows = this.#db .select() .from(ledgerEvents) .where(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 { 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 { 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 { 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; } } }