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:
2026-06-19 10:57:17 +02:00
parent 040c0ff4ca
commit f31e57b4ae
15 changed files with 686 additions and 65 deletions
+56
View File
@@ -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 };
}