feat: explainable activity log — reasons, subscriber names, snapshot gaps
The live activity feed flagged anomalies with no explanation and showed opaque session keys. Make events self-describing and clickable. - Clickable feed rows → read-only event-detail modal: humanized fields, entry/exit snapshots, and signed-chain provenance collapsed behind an audit disclosure (operator sees the story, auditor expands for crypto). - Localized reason codes (backend i18n): the signed ledger now carries a stable REASON_CODE + params (+ English fallback) instead of free-text English. The UI translates via reason.<code> catalogs in sq/en, so an Albanian operator reads Albanian — from the same immutable event. Adding a language is a catalog change, no re-signing. (@parking/shared REASON_CODES, reasonPayload; entry/exit/subscription flows emit codes.) - Subscriber-name resolution: a SUBSESS-… occurrence now shows the subscription holder's name (fallback "Abonent"/"Subscriber"). Resolved read-time server-side (events API + WS push) as a non-signed subscriberLabel; cached with invalidation on subscription edit/delete. - Failed-snapshot visibility: a camera that was attempted but unreachable now shows a "⚠ camera unreachable" tile instead of a silent gap. The snapshots API returns failures[] from telemetry, filtered so a recovered capture shows no stale warning. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { eq, subscriptions, type Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
|
||||
// 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.
|
||||
* Currently: `subscriberLabel` for subscription occurrences. Idempotent and cheap;
|
||||
* non-subscription events pass through unchanged (no `subscriberLabel`).
|
||||
*/
|
||||
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user