From cdb55a86527225ab543420fc977bf5cc2683ffa8 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 20 Jun 2026 15:45:57 +0200 Subject: [PATCH] feat: show recognized plate in live feed + active sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/event-enrich.ts | 42 ++++++++++++++-- apps/server/src/pay-station.ts | 15 +++++- apps/server/src/plate-lookup.ts | 85 ++++++++++++++++++++++++++++++++ apps/server/src/routes/events.ts | 9 ++-- apps/web/src/ActiveSessions.tsx | 8 +++ apps/web/src/BoothScreen.tsx | 12 ++++- apps/web/src/api.ts | 4 ++ apps/web/src/lib/i18n/en.ts | 1 + apps/web/src/lib/i18n/sq.ts | 1 + packages/shared/src/index.ts | 5 ++ 10 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/plate-lookup.ts diff --git a/apps/server/src/event-enrich.ts b/apps/server/src/event-enrich.ts index 54c6286..3a06c32 100644 --- a/apps/server/src/event-enrich.ts +++ b/apps/server/src/event-enrich.ts @@ -1,5 +1,6 @@ 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). @@ -45,12 +46,43 @@ export function clearHolderCache(): void { } /** - * Attach read-time display fields to a raw ledger row before it goes to a client. - * Currently: `subscriberLabel` for subscription occurrences. Idempotent and cheap; - * non-subscription events pass through unchanged (no `subscriberLabel`). + * 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) return event; - return { ...event, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK }; + 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; + }); } diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index 6273229..28d5d0e 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -2,6 +2,7 @@ import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariff import { priceSession, type TariffStructure, type Tender } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import type { EventLog } from "./event-log.js"; +import { plateForIdentity, platesForIdentities } from "./plate-lookup.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: @@ -78,6 +79,9 @@ export interface ActiveSession { readonly subscriptionId: string | null; /** The subscriber's holder name (for a friendly label instead of the raw key). */ readonly subscriptionHolder: string | null; + /** Advisory licence plate recognized for this session (ANPR-on-snapshot), shown for + * at-a-glance identification. Null when no plate was read. Never an access decision. */ + readonly plate: string | null; } /** Booth session view: everything the pay/exit modal needs in one read. */ @@ -104,6 +108,9 @@ export interface SessionLookup { readonly subscription: boolean; readonly subscriptionId: string | null; readonly subscriptionHolder: string | null; + /** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when + * none. Display/audit only — never an access decision. */ + readonly plate: string | null; } export class PayStation { @@ -243,7 +250,7 @@ export class PayStation { return { identity: id, found: false, open: false, enteredAt: null, exitedAt: null, paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null, - overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, + overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null, }; } // Subscription occurrence? The entry payload carries permit:true + permitId. @@ -288,6 +295,7 @@ export class PayStation { paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay, subscription: isSubscription, subscriptionId, subscriptionHolder: this.#holderOf(subscriptionId), + plate: plateForIdentity(this.#db, id)?.plate ?? null, }; } @@ -337,6 +345,10 @@ export class PayStation { } } + // Resolve advisory plates for all candidate identities in ONE device_events scan + // (cheaper than one lookup per row). + const plates = platesForIdentities(this.#db, byId.keys()); + const now = Date.now(); const out: ActiveSession[] = []; for (const [identity, a] of byId) { @@ -396,6 +408,7 @@ export class PayStation { subscription: isSubscription, subscriptionId: a.subscriptionId ?? null, subscriptionHolder: this.#holderOf(a.subscriptionId ?? null), + plate: plates.get(identity)?.plate ?? null, }); } diff --git a/apps/server/src/plate-lookup.ts b/apps/server/src/plate-lookup.ts new file mode 100644 index 0000000..919ce87 --- /dev/null +++ b/apps/server/src/plate-lookup.ts @@ -0,0 +1,85 @@ +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, + }; +} diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts index ca9ebca..5e5498b 100644 --- a/apps/server/src/routes/events.ts +++ b/apps/server/src/routes/events.ts @@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify"; import { desc, gte, ledgerEvents, type Db } from "@parking/db"; import type { LedgerEvent } from "@parking/shared"; import { requirePermission } from "../auth.js"; -import { enrichEvent } from "../event-enrich.js"; +import { enrichEvents } from "../event-enrich.js"; import type { EventLog } from "../event-log.js"; // Read access to the append-only signed event log. NO write/update/delete routes @@ -35,9 +35,10 @@ export async function eventRoutes( .orderBy(desc(ledgerEvents.index)) .limit(limit) .all(); - // Attach read-time display fields (e.g. subscriber name) without touching the - // signed record. The cast bridges the Drizzle row to the shared LedgerEvent. - const events = rows.map((r) => enrichEvent(db, r as unknown as LedgerEvent)); + // Attach read-time display fields (subscriber name, advisory plate) without + // touching the signed record. One plate scan for the whole page (enrichEvents). + // The cast bridges the Drizzle row to the shared LedgerEvent. + const events = enrichEvents(db, rows as unknown as LedgerEvent[]); return { events }; }, ); diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index bbc98db..2ecab32 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -155,6 +155,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void {s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity} + {s.plate && ( + + {s.plate} + + )} {formatRelativeDateTime(s.enteredAt, t)} {formatDuration(s.enteredAt, new Date().toISOString())} v > {hhmmss(e.occurredAt)} {label} - {displayIdentity(e)} + + {displayIdentity(e)} + {e.plate && ( + + {e.plate} + + )} + #{e.index} {showDetail && (
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index c530819..213100e 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -762,6 +762,8 @@ export interface SessionLookup { subscription: boolean; subscriptionId: string | null; subscriptionHolder: string | null; + /** Advisory licence plate recognized for this session (ANPR). Null when none. */ + plate: string | null; } /** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */ @@ -789,6 +791,8 @@ export interface ActiveSession { subscription: boolean; subscriptionId: string | null; subscriptionHolder: string | null; + /** Advisory licence plate recognized for this session (ANPR). Null when none. */ + plate: string | null; } /** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */ diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 1e149d0..cf4c02c 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -106,6 +106,7 @@ export const en: Catalog = { badgeOverstay: "overstay", badgeOverstayTitle: "Paid session. The customer failed to exit during the grace period. A new period began.", + plateTitle: "Licence plate recognized by the camera (advisory — not an access decision).", // filters filterSearchSessions: "Search ticket / subscriber / plate…", filterSearchFeed: "Search event / identity / plate…", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 7f813dd..e6d45c1 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -108,6 +108,7 @@ export const sq = { badgeOverstay: "tej afatit", badgeOverstayTitle: "Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.", + plateTitle: "Targa e njohur nga kamera (orientuese — nuk është vendim aksesi).", // filtra filterSearchSessions: "Kërko biletë / abonent / targë…", filterSearchFeed: "Kërko event / identitet / targë…", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a99bd6f..ec12d3a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -97,6 +97,11 @@ export interface LedgerEvent { * name here so the UI shows "Aqif Kopertoni" instead of "SUBSESS-08cd1c52…". * Absent on non-subscription events and on legacy serializers. */ readonly subscriberLabel?: string | null; + /** READ-TIME ENRICHMENT — not signed, not stored. A licence plate ADVISORILY + * recognized for this session (ANPR-on-snapshot, device_events kind="read"), shown + * next to entry/exit events. Uppercased, no confidence/region (those live on the + * snapshot review panel). Absent when no plate was read or for non-entry/exit events. */ + readonly plate?: string | null; } /** Business/accountability events that live in the SIGNED, hash-chained ledger. */