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:
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user