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(); /** 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(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(db: Db, events: T[]): T[] { // Collect identities of entry/exit events to resolve their plates in one scan. const wanted = new Set(); 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; }); }