feat: show recognized plate in live feed + active sessions
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
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { eq, subscriptions, type Db } from "@parking/db";
|
import { eq, subscriptions, type Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
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
|
// 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).
|
// 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.
|
* Attach read-time display fields to a raw ledger row before it goes to a client:
|
||||||
* Currently: `subscriberLabel` for subscription occurrences. Idempotent and cheap;
|
* - `subscriberLabel` for a subscription occurrence (payload.permitId → holder name);
|
||||||
* non-subscription events pass through unchanged (no `subscriberLabel`).
|
* - `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 {
|
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;
|
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
|
||||||
if (!permitId) return event;
|
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
||||||
return { ...event, 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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariff
|
|||||||
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
|
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { EventLog } from "./event-log.js";
|
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
|
// 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:
|
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||||
@@ -78,6 +79,9 @@ export interface ActiveSession {
|
|||||||
readonly subscriptionId: string | null;
|
readonly subscriptionId: string | null;
|
||||||
/** The subscriber's holder name (for a friendly label instead of the raw key). */
|
/** The subscriber's holder name (for a friendly label instead of the raw key). */
|
||||||
readonly subscriptionHolder: string | null;
|
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. */
|
/** Booth session view: everything the pay/exit modal needs in one read. */
|
||||||
@@ -104,6 +108,9 @@ export interface SessionLookup {
|
|||||||
readonly subscription: boolean;
|
readonly subscription: boolean;
|
||||||
readonly subscriptionId: string | null;
|
readonly subscriptionId: string | null;
|
||||||
readonly subscriptionHolder: 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 {
|
export class PayStation {
|
||||||
@@ -243,7 +250,7 @@ export class PayStation {
|
|||||||
return {
|
return {
|
||||||
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||||
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: 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.
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
@@ -288,6 +295,7 @@ export class PayStation {
|
|||||||
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
||||||
subscription: isSubscription, subscriptionId,
|
subscription: isSubscription, subscriptionId,
|
||||||
subscriptionHolder: this.#holderOf(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 now = Date.now();
|
||||||
const out: ActiveSession[] = [];
|
const out: ActiveSession[] = [];
|
||||||
for (const [identity, a] of byId) {
|
for (const [identity, a] of byId) {
|
||||||
@@ -396,6 +408,7 @@ export class PayStation {
|
|||||||
subscription: isSubscription,
|
subscription: isSubscription,
|
||||||
subscriptionId: a.subscriptionId ?? null,
|
subscriptionId: a.subscriptionId ?? null,
|
||||||
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
||||||
|
plate: plates.get(identity)?.plate ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<string>): Map<string, PlateView> {
|
||||||
|
const want = new Set(identities);
|
||||||
|
const out = new Map<string, PlateView>();
|
||||||
|
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<string, ReadDetail[]>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { desc, gte, ledgerEvents, type Db } from "@parking/db";
|
import { desc, gte, ledgerEvents, type Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { enrichEvent } from "../event-enrich.js";
|
import { enrichEvents } from "../event-enrich.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
// Read access to the append-only signed event log. NO write/update/delete routes
|
// 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))
|
.orderBy(desc(ledgerEvents.index))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all();
|
.all();
|
||||||
// Attach read-time display fields (e.g. subscriber name) without touching the
|
// Attach read-time display fields (subscriber name, advisory plate) without
|
||||||
// signed record. The cast bridges the Drizzle row to the shared LedgerEvent.
|
// touching the signed record. One plate scan for the whole page (enrichEvents).
|
||||||
const events = rows.map((r) => enrichEvent(db, r as unknown as LedgerEvent));
|
// The cast bridges the Drizzle row to the shared LedgerEvent.
|
||||||
|
const events = enrichEvents(db, rows as unknown as LedgerEvent[]);
|
||||||
return { events };
|
return { events };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -155,6 +155,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
<span className="text-term-text">
|
<span className="text-term-text">
|
||||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||||
</span>
|
</span>
|
||||||
|
{s.plate && (
|
||||||
|
<span
|
||||||
|
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
||||||
|
title={t("booth.plateTitle")}
|
||||||
|
>
|
||||||
|
{s.plate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
||||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -165,7 +165,17 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v
|
|||||||
>
|
>
|
||||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||||
|
{e.plate && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
||||||
|
title={t("booth.plateTitle")}
|
||||||
|
>
|
||||||
|
{e.plate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="text-term-muted">#{e.index}</span>
|
<span className="text-term-muted">#{e.index}</span>
|
||||||
{showDetail && (
|
{showDetail && (
|
||||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
|||||||
@@ -762,6 +762,8 @@ export interface SessionLookup {
|
|||||||
subscription: boolean;
|
subscription: boolean;
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
subscriptionHolder: 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). */
|
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||||
@@ -789,6 +791,8 @@ export interface ActiveSession {
|
|||||||
subscription: boolean;
|
subscription: boolean;
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
subscriptionHolder: 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). */
|
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export const en: Catalog = {
|
|||||||
badgeOverstay: "overstay",
|
badgeOverstay: "overstay",
|
||||||
badgeOverstayTitle:
|
badgeOverstayTitle:
|
||||||
"Paid session. The customer failed to exit during the grace period. A new period began.",
|
"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
|
// filters
|
||||||
filterSearchSessions: "Search ticket / subscriber / plate…",
|
filterSearchSessions: "Search ticket / subscriber / plate…",
|
||||||
filterSearchFeed: "Search event / identity / plate…",
|
filterSearchFeed: "Search event / identity / plate…",
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const sq = {
|
|||||||
badgeOverstay: "tej afatit",
|
badgeOverstay: "tej afatit",
|
||||||
badgeOverstayTitle:
|
badgeOverstayTitle:
|
||||||
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
|
"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
|
// filtra
|
||||||
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
||||||
filterSearchFeed: "Kërko event / identitet / targë…",
|
filterSearchFeed: "Kërko event / identitet / targë…",
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ export interface LedgerEvent {
|
|||||||
* name here so the UI shows "Aqif Kopertoni" instead of "SUBSESS-08cd1c52…".
|
* name here so the UI shows "Aqif Kopertoni" instead of "SUBSESS-08cd1c52…".
|
||||||
* Absent on non-subscription events and on legacy serializers. */
|
* Absent on non-subscription events and on legacy serializers. */
|
||||||
readonly subscriberLabel?: string | null;
|
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. */
|
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */
|
||||||
|
|||||||
Reference in New Issue
Block a user