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 };
},
);
+42 -4
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { desc, eq, snapshots, type Db } from "@parking/db";
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
import { requirePermission } from "../auth.js";
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
@@ -12,11 +12,15 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
const guard = requirePermission("session:read");
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
// first — lets the UI show "entry/exit image" links beside an event.
// first — lets the UI show "entry/exit image" links beside an event. We also return
// FAILED capture attempts (from snapshot telemetry) so the operator can tell a
// camera that was offline from a direction that simply has no camera — otherwise a
// missing shot is a silent gap. See snapshot.ts (recordFailure).
app.get<{ Params: { identity: string } }>(
"/api/snapshots/by-identity/:identity",
{ preHandler: guard },
async (req) => {
const identity = req.params.identity;
const rows = db
.select({
id: snapshots.id,
@@ -27,10 +31,44 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
capturedAt: snapshots.capturedAt,
})
.from(snapshots)
.where(eq(snapshots.identity, req.params.identity))
.where(eq(snapshots.identity, identity))
.orderBy(desc(snapshots.capturedAt))
.all();
return { snapshots: rows };
// Failed attempts: kind="snapshot" telemetry whose detail.identity matches and
// detail.ok === false. There may be both a failure and (on a retry) a success
// for the same direction; we keep only failures with NO successful shot in the
// same direction, so a recovered capture doesn't show a stale warning.
const haveDir = new Set<string | null>(rows.map((r) => r.direction));
const telemetry = db
.select({ detail: deviceEvents.detail, deviceId: deviceEvents.deviceId, occurredAt: deviceEvents.occurredAt })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "snapshot")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const failures: {
direction: "entry" | "exit" | null;
deviceId: string;
error: string;
occurredAt: string;
}[] = [];
const seenFailDir = new Set<string>();
for (const row of telemetry) {
const d = (row.detail ?? {}) as { identity?: string; ok?: boolean; error?: string; direction?: string };
if (d.identity !== identity || d.ok !== false) continue;
const dir = d.direction === "entry" || d.direction === "exit" ? d.direction : null;
const dirKey = dir ?? "both";
if (haveDir.has(dir) || seenFailDir.has(dirKey)) continue; // a success exists, or already shown
seenFailDir.add(dirKey);
failures.push({
direction: dir,
deviceId: row.deviceId ?? "",
error: d.error ?? "capture failed",
occurredAt: row.occurredAt ?? "",
});
}
return { snapshots: rows, failures };
},
);
+4
View File
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import { requirePermission } from "../auth.js";
import { invalidateHolder } from "../event-enrich.js";
import { printSubscriptionCard } from "../booth-print.js";
import type { CredentialCapture } from "../credential-capture.js";
import { directionOf } from "../device-resolve.js";
@@ -310,6 +311,8 @@ export async function subscriptionRoutes(
.where(eq(subscriptions.id, req.params.id))
.run();
writeChildren(req.params.id, b);
// The holder name may have changed — drop the feed-label cache for this sub.
invalidateHolder(req.params.id);
return loadAggregate(req.params.id);
},
);
@@ -362,6 +365,7 @@ export async function subscriptionRoutes(
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
invalidateHolder(req.params.id);
return reply.code(204).send();
},
);
+5 -1
View File
@@ -1,7 +1,9 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js";
import { deviceEvents } from "../device-events.js";
import { enrichEvent } from "../event-enrich.js";
import type { DeviceMonitor } from "../device-monitor.js";
import { getOccupancy } from "../occupancy.js";
@@ -92,7 +94,9 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
// Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative.
const offLedger = deviceEvents.onLedger((event) => {
send({ kind: "ledger", event, occupancy: getOccupancy(db) });
// Enrich with read-time display fields (subscriber name) before fan-out.
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) });
});
const offPrinter = deviceEvents.onPrinterStatus((event) => {
send({ kind: "printer-status", event });