cdb55a8652
Surface the advisory ANPR plate (device_events kind="read", keyed by session identity — unsigned, prunable, never an access decision) next to entry/exit events in the live feed and on active-session rows. Resolved at serialize time (new plate-lookup.ts; prefers an entry read; one device_events scan per page) like subscriber-name enrichment — the signed ledger is untouched. Adds plate? to the shared LedgerEvent and to ActiveSession/SessionLookup; a small amber badge in the UI. Caveat: a vehicle_entry is signed + pushed over WS before the async ANPR read lands, so a fresh feed row may show no plate until reload; always present on active sessions. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
89 lines
4.1 KiB
TypeScript
89 lines
4.1 KiB
TypeScript
import { eq, subscriptions, type Db } from "@parking/db";
|
|
import type { LedgerEvent } from "@parking/shared";
|
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
|
|
|
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
|
|
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
|
|
// We resolve them when serializing an event for the API / WS feed — never on the
|
|
// signed record itself.
|
|
//
|
|
// Today: a subscription occurrence's identity is an opaque `SUBSESS-…` key. The human
|
|
// who matters is the subscription HOLDER, whose name lives on the subscriptions row
|
|
// (mutable master data — NOT signed into the event). We resolve payload.permitId →
|
|
// holder_name so the feed reads "Aqif Kopertoni" rather than "SUBSESS-08cd1c52e219".
|
|
|
|
/** Fallback label when a subscription has no holder name (or was deleted). Matches the
|
|
* i18n key `booth.subscriberFallback`; kept here in English for the API/log layer. */
|
|
const SUBSCRIBER_FALLBACK = "Subscriber";
|
|
|
|
/** Tiny holder-name cache. Single-writer SQLite; a subscription rename is rare and the
|
|
* feed is not security-sensitive, so a short-lived cache is plenty. Invalidate by
|
|
* process lifetime — restart picks up renames; for live correctness the lookup is
|
|
* cheap enough that we just read per miss. */
|
|
const holderCache = new Map<string, string | null>();
|
|
|
|
/** Resolve a subscription id to its holder name (or null), memoized. */
|
|
function holderName(db: Db, permitId: string): string | null {
|
|
if (holderCache.has(permitId)) return holderCache.get(permitId) ?? null;
|
|
const row = db
|
|
.select({ holderName: subscriptions.holderName })
|
|
.from(subscriptions)
|
|
.where(eq(subscriptions.id, permitId))
|
|
.get();
|
|
const name = row?.holderName?.trim() || null;
|
|
holderCache.set(permitId, name);
|
|
return name;
|
|
}
|
|
|
|
/** Drop a cached holder name (call after a subscription create/update/delete). */
|
|
export function invalidateHolder(permitId: string): void {
|
|
holderCache.delete(permitId);
|
|
}
|
|
|
|
/** Clear the whole holder cache (call on bulk subscription changes). */
|
|
export function clearHolderCache(): void {
|
|
holderCache.clear();
|
|
}
|
|
|
|
/**
|
|
* Attach read-time display fields to a raw ledger row before it goes to a client:
|
|
* - `subscriberLabel` for a subscription occurrence (payload.permitId → holder name);
|
|
* - `plate` for an entry/exit event whose session has an advisory ANPR read.
|
|
* Idempotent and cheap; events without either pass through unchanged. Used by the WS
|
|
* feed (per event). For the bulk feed page prefer `enrichEvents` (one plate scan).
|
|
*/
|
|
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
|
|
let out: T = event;
|
|
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
|
|
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
|
if ((event.type === "vehicle_entry" || event.type === "vehicle_exit") && event.identity) {
|
|
const p = plateForIdentity(db, event.identity);
|
|
if (p) out = { ...out, plate: p.plate };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Bulk variant for the feed page: enriches a list of events with subscriber labels AND
|
|
* plates using a SINGLE device_events scan for all the plates (instead of one per row).
|
|
* Order preserved.
|
|
*/
|
|
export function enrichEvents<T extends LedgerEvent>(db: Db, events: T[]): T[] {
|
|
// Collect identities of entry/exit events to resolve their plates in one scan.
|
|
const wanted = new Set<string>();
|
|
for (const e of events) {
|
|
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) wanted.add(e.identity);
|
|
}
|
|
const plates = wanted.size ? platesForIdentities(db, wanted) : new Map();
|
|
return events.map((e) => {
|
|
let out: T = e;
|
|
const permitId = e.payload && typeof e.payload.permitId === "string" ? e.payload.permitId : null;
|
|
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
|
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) {
|
|
const p = plates.get(e.identity);
|
|
if (p) out = { ...out, plate: p.plate };
|
|
}
|
|
return out;
|
|
});
|
|
}
|