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
+6 -1
View File
@@ -1,6 +1,8 @@
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 type { EventLog } from "../event-log.js";
// Read access to the append-only signed event log. NO write/update/delete routes
@@ -33,7 +35,10 @@ export async function eventRoutes(
.orderBy(desc(ledgerEvents.index))
.limit(limit)
.all();
return { events: rows };
// 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));
return { events };
},
);