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
+112 -1
View File
@@ -90,6 +90,11 @@ export interface LedgerEvent {
readonly signature: string;
/** Which signer/key produced `signature` (verifiable across a signer swap). */
readonly keyId: string;
/** READ-TIME ENRICHMENT — not signed, not stored. When the event belongs to a
* subscription occurrence (payload.permitId), the server resolves the holder's
* name here so the UI shows "Aqif Kopertoni" instead of "SUBSESS-08cd1c52…".
* Absent on non-subscription events and on legacy serializers. */
readonly subscriberLabel?: string | null;
}
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */
@@ -134,8 +139,17 @@ export interface LedgerPayload {
readonly discountMinor?: number;
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
readonly fxRate?: number | null;
/** void / anomaly / override: a human/machine reason code. */
/** void / anomaly / override: a human-readable English sentence, signed as the
* immutable fallback. Prefer `reasonCode` for display (it localizes); `reason` is
* what's shown for legacy events with no code, and what an English log records. */
readonly reason?: string;
/** Stable, language-neutral classification of WHY this event happened (e.g.
* "exit.refused.unpaid"). The presentation layer localizes it via REASONS; the
* signed bytes never change, so a language added later applies retroactively. */
readonly reasonCode?: ReasonCode;
/** Interpolation values for `reasonCode`'s message template (counts, ids, ratios).
* Signed alongside the code so the rendered sentence is reproducible. */
readonly reasonParams?: Record<string, string | number>;
/** plate/vehicle from the vision service (advisory). */
readonly plate?: string;
readonly plateConfidence?: number;
@@ -146,6 +160,103 @@ export interface LedgerPayload {
readonly [k: string]: unknown;
}
/**
* The closed set of reasons an anomaly/payment/override can carry. These are STABLE
* language-neutral keys — the anti-fraud ledger signs the code (+ params), and the
* presentation layer translates it. Adding a language = adding catalog entries, with
* NO re-signing of past events. Codes are grouped by flow: entry.* / exit.* / sub.*.
*
* When you add a new reason at an append site, add its code here AND a message in
* BOTH web catalogs (`reason.<code>` in sq.ts + en.ts) — the type makes a missing
* code a compile error at the call site, and Catalog parity makes a missing
* translation a build error.
*/
export const REASON_CODES = [
// entry
"entry.refused.full",
"entry.held.noTicket",
// exit refusals
"exit.refused.closed",
"exit.refused.noSession",
"exit.refused.unpaid",
"exit.refused.graceExpired",
// exit recorded but the barrier could not be driven (operator must open by hand)
"exit.open.noBarrier",
"exit.open.unavailable",
"exit.open.failed",
// free $0 grace exit
"exit.freeGrace",
// manual / human-intervention barrier open
"exit.manualOpen",
// subscriptions
"sub.refused.notFound",
"sub.refused.outOfWindow",
"sub.refused.noSession",
"sub.refused.atCapacity",
] as const;
export type ReasonCode = (typeof REASON_CODES)[number];
/**
* English message templates for each reason code — the SINGLE source for the signed
* `reason` fallback string (server renders this) AND the en.ts catalog. `{name}`
* placeholders are filled from `reasonParams`. Other languages live in the web
* catalogs keyed `reason.<code>`; this English copy stays here so the server can sign
* a human fallback without importing a UI catalog.
*/
export const REASON_EN: Record<ReasonCode, string> = {
"entry.refused.full": "entry refused — lot full ({count}/{capacity})",
"entry.held.noTicket": "entry held — ticket not printed: {detail}",
"exit.refused.closed": "exit refused — session already closed",
"exit.refused.noSession": "exit refused — no open session for ticket",
"exit.refused.unpaid": "exit refused — not paid (take payment first)",
"exit.refused.graceExpired": "exit refused — walk-back grace expired (top-up required)",
"exit.open.noBarrier": "exit recorded, but no exit barrier is configured — open manually",
"exit.open.unavailable": "exit recorded, but the barrier is unavailable — open manually",
"exit.open.failed": "exit recorded, but the barrier did not open — open manually",
"exit.freeGrace": "free entry-grace (no charge)",
"exit.manualOpen": "manual barrier open (human intervention)",
"sub.refused.notFound": "subscription refused — not found",
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
};
/**
* Fill a `{name}` template from params. Missing params are left as the literal token
* (defensive — a malformed event still renders something). Shared by the server (to
* sign the English fallback) and any caller that has a template string + params.
*/
export function fillTemplate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (whole, key: string) =>
key in params ? String(params[key]) : whole,
);
}
/** Render a reason code to its English sentence (the signed fallback). */
export function renderReasonEn(code: ReasonCode, params?: Record<string, string | number>): string {
return fillTemplate(REASON_EN[code], params);
}
/**
* Build the trio of reason fields to merge into a signed payload: the stable code,
* its params, and the rendered English `reason` (the immutable, localization-free
* fallback). Use at every anomaly/payment/override append site so the ledger is
* self-describing and the UI can localize without parsing free text. Spread it:
* payload: { ...reasonPayload("exit.refused.unpaid"), exitRefused: true }
*/
export function reasonPayload(
code: ReasonCode,
params?: Record<string, string | number>,
): { reasonCode: ReasonCode; reasonParams?: Record<string, string | number>; reason: string } {
return {
reasonCode: code,
...(params ? { reasonParams: params } : {}),
reason: renderReasonEn(code, params),
};
}
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";