import { and, desc, deviceEvents, eq, type Db } from "@parking/db"; // READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it // lives in the unsigned, prunable `device_events` (kind="read") stream written by the // ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`. // It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a // signed fact). To SHOW it next to a feed event or an active session we resolve it here, // at serialize time, the same way subscriber names are resolved (see event-enrich.ts). // // Preference: an ENTRY read over an exit read (the plate as it arrived identifies the // session); within a direction, the newest read wins. Returns the plate text only — // confidence/region detail stays on the snapshot review panel, not the at-a-glance feed. /** The best advisory plate observed for a session, for display. */ export interface PlateView { readonly plate: string; readonly confidence: number | null; readonly direction: "entry" | "exit" | null; } interface ReadDetail { identity?: string; plate?: string; confidence?: number; direction?: string; } /** Best plate for one identity, or null. Prefers an entry read, then the newest read. */ export function plateForIdentity(db: Db, identity: string): PlateView | null { const rows = db .select({ detail: deviceEvents.detail }) .from(deviceEvents) .where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read"))) .orderBy(desc(deviceEvents.occurredAt)) .all(); return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity); } /** Resolve plates for MANY identities in one device_events scan (used by the active- * sessions list and the feed page, which each carry tens–hundreds of rows). */ export function platesForIdentities(db: Db, identities: Iterable): Map { const want = new Set(identities); const out = new Map(); if (want.size === 0) return out; // Newest first so the first acceptable read per (identity,direction) is the freshest. const rows = db .select({ detail: deviceEvents.detail }) .from(deviceEvents) .where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read"))) .orderBy(desc(deviceEvents.occurredAt)) .all(); const byId = new Map(); for (const r of rows) { const d = (r.detail ?? {}) as ReadDetail; if (!d.identity || !d.plate || !want.has(d.identity)) continue; let list = byId.get(d.identity); if (!list) byId.set(d.identity, (list = [])); list.push(d); } for (const [id, reads] of byId) { const best = pickBest(reads, id); if (best) out.set(id, best); } return out; } /** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an * exit read; otherwise the first (newest) acceptable read wins. */ function pickBest(reads: ReadDetail[], identity: string): PlateView | null { let fallback: ReadDetail | null = null; for (const d of reads) { if (d.identity !== identity || !d.plate) continue; if (d.direction === "entry") return toView(d); if (!fallback) fallback = d; } return fallback ? toView(fallback) : null; } function toView(d: ReadDetail): PlateView { return { plate: d.plate!.trim().toUpperCase(), confidence: typeof d.confidence === "number" ? d.confidence : null, direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null, }; }