From f31e57b4aec5135163d86674119f703427ed8cf6 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 19 Jun 2026 10:57:17 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20explainable=20activity=20log=20?= =?UTF-8?q?=E2=80=94=20reasons,=20subscriber=20names,=20snapshot=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 --- apps/server/src/entry-flow.ts | 10 +- apps/server/src/event-enrich.ts | 56 ++++++ apps/server/src/exit-flow.ts | 58 +++--- apps/server/src/routes/events.ts | 7 +- apps/server/src/routes/snapshots.ts | 46 ++++- apps/server/src/routes/subscriptions.ts | 4 + apps/server/src/routes/ws.ts | 6 +- apps/server/src/subscription-flow.ts | 29 +-- apps/web/src/BoothScreen.tsx | 227 +++++++++++++++++++++++- apps/web/src/api.ts | 17 +- apps/web/src/lib/i18n/en.ts | 64 +++++++ apps/web/src/lib/i18n/sq.ts | 63 +++++++ apps/web/src/lib/reason.ts | 22 +++ apps/web/src/ui/SnapshotStrip.tsx | 29 ++- packages/shared/src/index.ts | 113 +++++++++++- 15 files changed, 686 insertions(+), 65 deletions(-) create mode 100644 apps/server/src/event-enrich.ts create mode 100644 apps/web/src/lib/reason.ts diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index 84a14d9..1f0c79e 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -10,7 +10,7 @@ import { type TicketData, type TicketHeader, } from "@parking/devices"; -import { DEFAULT_VEHICLE_CATEGORY } from "@parking/shared"; +import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceInputEvent } from "./device-events.js"; import { getOccupancy } from "./occupancy.js"; @@ -82,7 +82,11 @@ export class EntryFlow { if (occ.full) { await this.#log.append({ type: "anomaly", - payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true }, + payload: { + ...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }), + entryRefused: true, + full: true, + }, }); this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`); return; @@ -107,7 +111,7 @@ export class EntryFlow { await this.#log.append({ type: "anomaly", identity: ticketId, - payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false }, + payload: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false }, }); this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`); return; diff --git a/apps/server/src/event-enrich.ts b/apps/server/src/event-enrich.ts new file mode 100644 index 0000000..54c6286 --- /dev/null +++ b/apps/server/src/event-enrich.ts @@ -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(); + +/** 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(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 }; +} diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 5ce6573..5003cde 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -2,7 +2,7 @@ import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, typ import { registry, type AccessControlDevice } from "@parking/devices"; import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js"; import { snapshotAsync } from "./snapshot.js"; -import { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared"; +import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { EventLog } from "./event-log.js"; @@ -97,10 +97,10 @@ export class ExitFlow { // No open session — unknown/closed ticket. Sign an anomaly (same as the reader // path) so a booth attempt on a bad ticket is auditable. if (!view || !view.open) { - const reason = view ? "exit refused — session already closed" : "exit refused — no open session for ticket"; - await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } }); - this.#logger.warn(`booth exit refused (${id}): ${reason}`); - return { ok: false, status: view ? "closed" : "no_session", reason }; + const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession"); + await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } }); + this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`); + return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason }; } // PAID + within grace, OR free entry-grace — the same checks the reader uses. @@ -110,12 +110,10 @@ export class ExitFlow { paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000; if (!freeGrace && (!paid || !withinGrace)) { - const reason = !paid - ? "exit refused — not paid (take payment first)" - : "exit refused — walk-back grace expired (top-up required)"; - await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } }); - this.#logger.warn(`booth exit refused (${id}): ${reason}`); - return { ok: false, status: paid ? "grace_expired" : "unpaid", reason }; + const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid"); + await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } }); + this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`); + return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason }; } // Free entry-grace path: mint the $0 payment first (ledger invariant), as the @@ -130,7 +128,7 @@ export class ExitFlow { currency: view.freeGrace.currency, tariffVersionId: view.freeGrace.tariffVersionId, graceExitMin: view.freeGrace.graceExitMin, - reason: "free entry-grace (no charge)", + ...reasonPayload("exit.freeGrace"), }, }); } @@ -144,18 +142,18 @@ export class ExitFlow { if (!resolved) { await this.#openFailedAnomaly(id, "no exit relay configured"); - return { ok: true, opened: false, reason: "exit recorded, but no exit barrier is configured — open manually" }; + return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") }; } const access = this.#buildAccess(resolved.controller); if (!access) { await this.#openFailedAnomaly(id, "exit controller would not build"); - return { ok: true, opened: false, reason: "exit recorded, but the barrier is unavailable — open manually" }; + return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") }; } try { await access.pulseOpen(resolved.relay); } catch (err) { await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`); - return { ok: true, opened: false, reason: "exit recorded, but the barrier did not open — open manually" }; + return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") }; } this.#fireExitSnapshot(id); @@ -210,7 +208,7 @@ export class ExitFlow { type: "anomaly", identity: id, payload: { - reason: "manual barrier open (human intervention)", + ...reasonPayload("exit.manualOpen"), source: "booth", barrierReopen: true, ...(operator ? { operator } : {}), @@ -229,18 +227,18 @@ export class ExitFlow { if (!resolved) { this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`); - return { ok: true, opened: false, reason: "no exit barrier configured — open manually" }; + return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") }; } const access = this.#buildAccess(resolved.controller); if (!access) { this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`); - return { ok: true, opened: false, reason: "barrier unavailable — open manually" }; + return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") }; } try { await access.pulseOpen(resolved.relay); } catch (err) { this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`); - return { ok: true, opened: false, reason: "barrier did not open — open manually" }; + return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") }; } this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`); return { ok: true, opened: true }; @@ -270,14 +268,14 @@ export class ExitFlow { // No matching open session — unknown/duplicate ticket. Reject + log. if (!view || !view.open) { - const reason = view ? "exit refused — session already closed" : "exit refused — no open session for credential"; + const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession"); await this.#log.append({ type: "anomaly", identity: e.value, - payload: { reason, exitRefused: true }, + payload: { ...rp, exitRefused: true }, }); this.#logger.warn(`exit refused: no open session for ${e.value}`); - return { accepted: false, direction: "exit", reason }; + return { accepted: false, direction: "exit", reason: rp.reason }; } // FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate @@ -295,7 +293,7 @@ export class ExitFlow { currency: view.freeGrace.currency, tariffVersionId: view.freeGrace.tariffVersionId, graceExitMin: view.freeGrace.graceExitMin, - reason: "free entry-grace (no charge)", + ...reasonPayload("exit.freeGrace"), }, }); this.#logger.info(`exit free within entry-grace (${e.value})`); @@ -310,16 +308,14 @@ export class ExitFlow { Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000; if (!paid || !withinGrace) { - const reason = !paid - ? "exit refused — not paid (pay at the station)" - : "exit refused — walk-back grace expired (top-up required)"; + const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid"); await this.#log.append({ type: "anomaly", identity: e.value, - payload: { reason, exitRefused: true, sessionRef: e.value }, + payload: { ...rp, exitRefused: true, sessionRef: e.value }, }); - this.#logger.warn(`exit refused (${e.value}): ${reason}`); - return { accepted: false, direction: "exit", reason }; + this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`); + return { accepted: false, direction: "exit", reason: rp.reason }; } // Valid (a real payment within walk-back grace): sign + open. @@ -352,7 +348,7 @@ export class ExitFlow { identity, payload: { sessionRef: identity, - ...(source === "manual" ? { reason: "human-intervention exit (manual barrier open)" } : {}), + ...(source === "manual" ? reasonPayload("exit.manualOpen") : {}), }, }); } @@ -386,7 +382,7 @@ export class ExitFlow { await this.#log.append({ type: "anomaly", identity, - payload: { reason: "exit signed but barrier open failed", detail, source: "booth", exitOpenFailed: true }, + payload: { ...reasonPayload("exit.open.failed"), detail, source: "booth", exitOpenFailed: true }, }); this.#logger.error(`booth exit open failed (${identity}): ${detail}`); } diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts index b2f3820..ca9ebca 100644 --- a/apps/server/src/routes/events.ts +++ b/apps/server/src/routes/events.ts @@ -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 }; }, ); diff --git a/apps/server/src/routes/snapshots.ts b/apps/server/src/routes/snapshots.ts index 3b7eaf9..da9bf21 100644 --- a/apps/server/src/routes/snapshots.ts +++ b/apps/server/src/routes/snapshots.ts @@ -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( "/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(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(); + 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 }; }, ); diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts index 1c4e2d4..e57e4ef 100644 --- a/apps/server/src/routes/subscriptions.ts +++ b/apps/server/src/routes/subscriptions.ts @@ -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(); }, ); diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts index c523a1c..d0b2b10 100644 --- a/apps/server/src/routes/ws.ts +++ b/apps/server/src/routes/ws.ts @@ -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 }); diff --git a/apps/server/src/subscription-flow.ts b/apps/server/src/subscription-flow.ts index e10e578..435834d 100644 --- a/apps/server/src/subscription-flow.ts +++ b/apps/server/src/subscription-flow.ts @@ -10,6 +10,7 @@ import { type DeviceRow, } from "@parking/db"; import { registry, type AccessControlDevice } from "@parking/devices"; +import { reasonPayload, type ReasonCode } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { EventLog } from "./event-log.js"; @@ -98,7 +99,7 @@ export class SubscriptionFlow { async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise { const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get(); - if (!sub) return { accepted: false, reason: "subscription not found" }; + if (!sub) return { accepted: false, reason: await this.#reject(m, "sub.refused.notFound") }; // Validity: active + within the coverage window. const now = new Date().toISOString(); @@ -107,8 +108,7 @@ export class SubscriptionFlow { (sub.validFrom != null && now < sub.validFrom) || (sub.validTo != null && now > sub.validTo); if (invalid) { - const reason = `subscription ${sub.status}/out-of-window`; - await this.#reject(m, reason); + const reason = await this.#reject(m, "sub.refused.outOfWindow", { status: sub.status }); return { accepted: false, reason }; } @@ -136,8 +136,7 @@ export class SubscriptionFlow { // subscription has NOTHING open, an exit read is a no-op anti-passback signal. const oldest = open[0]; if (!oldest) { - const reason = "subscription exit with no open session (already out / never entered)"; - await this.#reject(m, reason); + const reason = await this.#reject(m, "sub.refused.noSession"); return { accepted: false, direction: "exit", reason }; } const occurrenceId = oldest.identity; @@ -157,8 +156,10 @@ export class SubscriptionFlow { // ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a // fresh per-occurrence id so a fleet can have several open at once. if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) { - const reason = `subscription at capacity (${open.length}/${sub.maxConcurrent} cars in)`; - await this.#reject(m, reason); + const reason = await this.#reject(m, "sub.refused.atCapacity", { + inUse: open.length, + max: sub.maxConcurrent, + }); return { accepted: false, direction: "entry", reason }; } @@ -226,14 +227,22 @@ export class SubscriptionFlow { return open; } - async #reject(m: SubscriptionMatch, reason: string): Promise { + /** Sign a refused-subscription anomaly with a localizable reason code, and return + * the rendered English reason for the caller's ReadOutcome. */ + async #reject( + m: SubscriptionMatch, + code: ReasonCode, + params?: Record, + ): Promise { + const rp = reasonPayload(code, params); await this.#log.append({ type: "anomaly", identity: m.carKey, // `permitId`/`permitRefused` are the on-chain field names (immutable). - payload: { reason: `subscription refused — ${reason}`, permitId: m.subscriptionId, permitRefused: true }, + payload: { ...rp, permitId: m.subscriptionId, permitRefused: true }, }); - this.#logger.warn(`subscription refused (${m.carKey}): ${reason}`); + this.#logger.warn(`subscription refused (${m.carKey}): ${rp.reason}`); + return rp.reason; } async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise { diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index 06f2210..456b463 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -1,7 +1,8 @@ -import { useRef, useState } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js"; +import { formatMoney } from "./lib/format.js"; import { qk } from "./lib/query.js"; import { useLiveStore } from "./lib/live-store.js"; import { useShift } from "./lib/use-shift.js"; @@ -9,6 +10,9 @@ import { Panel } from "./ui/Panel.js"; import { StatusDot } from "./ui/StatusDot.js"; import { BoothPayModal } from "./BoothPayModal.js"; import { ActiveSessions } from "./ActiveSessions.js"; +import { Modal } from "./ui/Modal.js"; +import { SnapshotStrip } from "./ui/SnapshotStrip.js"; +import { renderReason } from "./lib/reason.js"; // The live operator booth view — the real-time heart of the console. Occupancy // gauge + a streaming entry/exit/payment ticker. Query owns the initial load and @@ -70,20 +74,226 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) { ); } -function EventRow({ e }: { e: LedgerEvent }) { +/** Translated classification badges derived from a payload's boolean flags. Unlike + * `reason` (an immutable English sentence baked into the signed ledger, shown + * verbatim), these are computed client-side so they CAN be localized. They give a + * glanceable "what kind of anomaly" tag without parsing the free-text reason. */ +function eventBadges(p: LedgerEvent["payload"]): string[] { + if (!p) return []; + const keys: string[] = []; + if (p.entryRefused) keys.push("booth.badgeEntryRefused"); + if (p.exitRefused) keys.push("booth.badgeExitRefused"); + if (p.full) keys.push("booth.badgeLotFull"); + if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed"); + if (p.permitRefused) keys.push("booth.badgeSubRefused"); + if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket"); + if (p.source === "manual") keys.push("booth.badgeManualOpen"); + return keys; +} + +/** A short money summary for payment events (e.g. "350.00 ALL"). */ +function paymentSummary(p: LedgerEvent["payload"]): string | null { + if (!p || typeof p.amountMinor !== "number" || !p.currency) return null; + return formatMoney(p.amountMinor, p.currency); +} + +/** What to SHOW for an event's actor. A subscription occurrence has an opaque + * `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`, + * so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */ +function displayIdentity(e: LedgerEvent): string { + return e.subscriberLabel ?? e.identity ?? "—"; +} + +function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) { const { t } = useTranslation(); const style = EVENT_STYLE[e.type]; const label = style ? t(style.labelKey) : e.type.toUpperCase(); + const isAnomaly = e.type === "anomaly"; + const p = e.payload; + // Localize the reason from the signed reasonCode (falls back to the English text on + // legacy events). Anomalies ALWAYS get a detail line so a red flag is never silent. + const reason = renderReason(p, t); + const amount = paymentSummary(p); + const badges = eventBadges(p); + const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null); + const showDetail = detail != null || badges.length > 0; + + // The whole row is a button → opens the event-detail modal (full payload + the + // session's entry/exit snapshots). A grid keeps the time/label/identity/index + // columns aligned across rows; the detail line lives in its own row, indented to + // start under the identity column so it never collides with the ticket code. return ( -
+ + ); +} + +/** One label/value line in the event-detail modal. */ +function DetailRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children}
); } +/** Full read-only detail for one ledger event: business fields + the human-readable + * reason + the session's entry/exit snapshots, then the signed-chain provenance + * (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable; + * this only DISPLAYS the signed record. */ +function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) { + const { t } = useTranslation(); + const style = EVENT_STYLE[e.type]; + const label = style ? t(style.labelKey) : e.type.toUpperCase(); + const p = e.payload; + const reason = renderReason(p, t); + const amount = paymentSummary(p); + const badges = eventBadges(p); + const isAnomaly = e.type === "anomaly"; + + // Pretty money for any minor-unit amount in the payload. + const money = + p && typeof p.amountMinor === "number" && typeof p.currency === "string" + ? formatMoney(p.amountMinor, p.currency) + : null; + // Pull out the business fields worth a labelled row. Everything else (and the raw + // bytes) lives behind the audit disclosure — the operator sees a clean summary. + const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null; + const plate = typeof p?.plate === "string" ? p.plate : null; + const category = typeof p?.category === "string" ? p.category : null; + const operator = typeof p?.operator === "string" ? p.operator : null; + const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null; + + return ( + +
+ {/* Headline: the type + localized reason, prominent for anomalies. */} +
+
{label}
+ {(reason || money) && ( +
+ {reason ?? money} +
+ )} + {!reason && !money && isAnomaly && ( +
{t("booth.evtNoReason")}
+ )} + {badges.length > 0 && ( +
+ {badges.map((k) => ( + + {t(k)} + + ))} +
+ )} +
+ + {/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */} +
+ {new Date(e.occurredAt).toLocaleString()} + #{e.index} + {e.direction && {e.direction}} + {e.source && {e.source}} + {displayIdentity(e)} + {/* When we showed a subscriber NAME above, also expose the raw occurrence id + (the SUBSESS-… session key) for traceability against the ledger. */} + {e.subscriberLabel && e.identity && ( + + {e.identity} + + )} + {money && ( + + {money} + + )} + {typeof p?.tender === "string" && {p.tender}} + {category && {category}} + {plate && {plate}} + {operator && {operator}} + {sessionRef && sessionRef !== e.identity && ( + {sessionRef} + )} + {tariffVersionId && ( + + {tariffVersionId} + + )} +
+ + {/* The entry/exit evidence images for this session's identity. */} + {e.identity && ( +
+
{t("booth.edSnapshots")}
+ +
+ )} + + {/* Audit data — collapsed by default. The signed-chain provenance (signature, + key, prev-hash) and the raw payload are an auditor's concern, not the + operator's; tucking them behind a disclosure keeps the common view clean + while preserving the tamper-evidence trail on demand. */} +
+ + {t("booth.edAuditData")} + +
+ + {e.signature} + + + {e.keyId} + + + {e.prevHash ?? "—"} + +
+ {t("booth.edRawPayload")} +
+ {p && Object.keys(p).length > 0 ? ( +
+                {JSON.stringify(p, null, 2)}
+              
+ ) : ( +
{t("booth.edNoPayload")}
+ )} +
+
+
+
+ ); +} + /** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual * operator types it. Either way, submit opens the pay/exit modal for that id. The * input auto-focuses and re-focuses after a scan so the scanner always lands here. */ @@ -138,6 +348,8 @@ export function BoothScreen() { // The ticket currently open in the pay/exit modal (null = no modal). const [activeTicket, setActiveTicket] = useState(null); + // The ledger event open in the read-only detail modal (null = closed). + const [detailEvent, setDetailEvent] = useState(null); // Live overlays from the WS store. const liveOcc = useLiveStore((s) => s.occupancy); @@ -195,12 +407,13 @@ export function BoothScreen() { ) : events.length === 0 ? (
{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}
) : ( - events.map((e) => ) + events.map((e) => ) )}
{activeTicket && setActiveTicket(null)} />} + {detailEvent && setDetailEvent(null)} />} ); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index e3c8d46..2c00d67 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -747,9 +747,20 @@ export interface SnapshotMeta { capturedAt: string; } -/** Snapshot metadata for a session identity (newest first). Image bytes are at - * `/api/snapshots/:id` — use that URL directly as an . */ -export function fetchSnapshots(identity: string): Promise<{ snapshots: SnapshotMeta[] }> { +/** A capture that was ATTEMPTED but failed (camera offline, config) — surfaced so a + * missing image isn't a silent gap. From snapshot telemetry, not the image store. */ +export interface SnapshotFailure { + direction: "entry" | "exit" | null; + deviceId: string; + error: string; + occurredAt: string; +} + +/** Snapshot metadata for a session identity (newest first) PLUS failed capture + * attempts. Image bytes are at `/api/snapshots/:id` — use that as an . */ +export function fetchSnapshots( + identity: string, +): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[] }> { return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`); } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 43d6058..dbbd963 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -103,6 +103,67 @@ export const en: Catalog = { evtShiftZ: "SHIFT Z", evtCashMovement: "CASH", evtAnomaly: "ANOMALY", + // live-feed event detail line + classification badges (computed from payload) + evtNoReason: "no reason recorded", + badgeEntryRefused: "entry refused", + badgeExitRefused: "exit refused", + badgeLotFull: "lot full", + badgeBarrierFailed: "barrier did not open", + badgeManualOpen: "manual open", + badgeSubRefused: "subscription refused", + badgeNoTicket: "ticket not printed", + feedSourceBooth: "booth", + feedSourceReader: "reader", + // event-detail modal + eventDetail: "Event detail", + edType: "Type", + edTime: "Time", + edIndex: "Ledger index", + edDirection: "Direction", + edSource: "Source", + edIdentity: "Identity", + edReason: "Reason", + edSnapshots: "Snapshots", + edPayload: "Signed payload", + edChain: "Chain", + edSignature: "Signature", + edKeyId: "Key", + edPrevHash: "Prev hash", + edNoPayload: "No payload on this event.", + edCopy: "Copy", + edCopied: "Copied", + edDetails: "Details", + edAuditData: "Audit data (signature & chain)", + edAmount: "Amount", + edTender: "Tender", + edSession: "Session", + edPlate: "Plate", + edCategory: "Category", + edOperator: "Operator", + edTariffVersion: "Tariff version", + edRawPayload: "Raw signed payload", + edOccurrence: "Occurrence id", + subscriber: "Subscriber", + }, + // Localized messages for the signed REASON_CODES (see @parking/shared). Keys MUST + // match the codes 1:1; {{param}} placeholders are filled from the event's + // reasonParams. Legacy events with no code fall back to the signed English `reason`. + reason: { + "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)", }, tariff: { title: "Tariff", @@ -446,5 +507,8 @@ export const en: Catalog = { reprinting: "printing…", noSnapshots: "no snapshots", loadingSnapshots: "loading snapshots…", + snapEntry: "entry", + snapExit: "exit", + snapFailed: "camera unreachable", }, }; diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 0470ca6..305de00 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -105,6 +105,66 @@ export const sq = { evtShiftZ: "TURN Z", evtCashMovement: "ARKË", evtAnomaly: "ANOMALI", + // rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload) + evtNoReason: "pa arsye të regjistruar", + badgeEntryRefused: "hyrje e refuzuar", + badgeExitRefused: "dalje e refuzuar", + badgeLotFull: "parkimi plot", + badgeBarrierFailed: "barriera nuk u hap", + badgeManualOpen: "hapje manuale", + badgeSubRefused: "abonimi u refuzua", + badgeNoTicket: "bileta nuk u printua", + feedSourceBooth: "kabinë", + feedSourceReader: "lexues", + // dritarja e detajeve të eventit + eventDetail: "Detajet e eventit", + edType: "Lloji", + edTime: "Ora", + edIndex: "Indeksi në regjistër", + edDirection: "Drejtimi", + edSource: "Burimi", + edIdentity: "Identiteti", + edReason: "Arsyeja", + edSnapshots: "Fotot", + edPayload: "Të dhënat e nënshkruara", + edChain: "Zinxhiri", + edSignature: "Nënshkrimi", + edKeyId: "Çelësi", + edPrevHash: "Hash-i i mëparshëm", + edNoPayload: "Ky event nuk ka të dhëna shtesë.", + edCopy: "Kopjo", + edCopied: "U kopjua", + edDetails: "Detajet", + edAuditData: "Të dhënat e auditimit (nënshkrimi & zinxhiri)", + edAmount: "Shuma", + edTender: "Mënyra e pagesës", + edSession: "Sesioni", + edPlate: "Targa", + edCategory: "Kategoria", + edOperator: "Operatori", + edTariffVersion: "Versioni i tarifës", + edRawPayload: "Të dhënat e papërpunuara të nënshkruara", + edOccurrence: "ID e hyrjes", + subscriber: "Abonent", + }, + // Mesazhet e përkthyera për REASON_CODES e nënshkruara (shih @parking/shared). + // Çelësat përputhen 1:1 me kodet; {{param}} mbushet nga reasonParams i eventit. + reason: { + "entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})", + "entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}", + "exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë", + "exit.refused.noSession": "Dalja u refuzua — biletë e panjohur", + "exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)", + "exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)", + "exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape me dorë", + "exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape me dorë", + "exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape me dorë", + "exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)", + "exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)", + "sub.refused.notFound": "Abonimi u refuzua — nuk u gjet", + "sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit", + "sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)", + "sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)", }, tariff: { title: "Tarifa", @@ -459,6 +519,9 @@ export const sq = { // snapshots noSnapshots: "asnjë foto", loadingSnapshots: "duke ngarkuar fotot…", + snapEntry: "hyrje", + snapExit: "dalje", + snapFailed: "kamera e paarritshme", }, }; diff --git a/apps/web/src/lib/reason.ts b/apps/web/src/lib/reason.ts new file mode 100644 index 0000000..740ad97 --- /dev/null +++ b/apps/web/src/lib/reason.ts @@ -0,0 +1,22 @@ +import type { TFunction } from "i18next"; +import { REASON_CODES, type LedgerEvent } from "@parking/shared"; + +// Localize a signed event's reason. The ledger signs a STABLE `reasonCode` (+ params) +// plus an English `reason` fallback (see @parking/shared REASON_CODES). We translate +// the code via the `reason.` catalog so an Albanian operator reads Albanian and +// an English operator reads English — from the SAME immutable event. Legacy events +// (signed before reason codes existed) carry only `reason`, so we show that verbatim. + +const CODE_SET = new Set(REASON_CODES); + +/** The localized reason sentence for an event, or null if it has no reason at all. */ +export function renderReason(payload: LedgerEvent["payload"], t: TFunction): string | null { + if (!payload) return null; + const code = typeof payload.reasonCode === "string" ? payload.reasonCode : null; + if (code && CODE_SET.has(code)) { + // i18next fills {{param}} from reasonParams; an absent key renders the raw token. + return t(`reason.${code}`, (payload.reasonParams ?? {}) as Record); + } + // No code (legacy) or an unknown code (forward-compat) → the signed English fallback. + return typeof payload.reason === "string" ? payload.reason : null; +} diff --git a/apps/web/src/ui/SnapshotStrip.tsx b/apps/web/src/ui/SnapshotStrip.tsx index d27ca06..f637b0d 100644 --- a/apps/web/src/ui/SnapshotStrip.tsx +++ b/apps/web/src/ui/SnapshotStrip.tsx @@ -17,20 +17,26 @@ export function SnapshotStrip({ identity }: { identity: string }) { const [zoom, setZoom] = useState(null); const shots = data?.snapshots ?? []; + const failures = data?.failures ?? []; + + /** Localized direction label for a snapshot/failure tile. */ + const dirLabel = (dir: "entry" | "exit" | null): string => + dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—"; if (isLoading) return
{t("pay.loadingSnapshots")}
; - if (shots.length === 0) return
{t("pay.noSnapshots")}
; + if (shots.length === 0 && failures.length === 0) + return
{t("pay.noSnapshots")}
; return ( <> -
+
{shots.map((s) => ( ))} + + {/* Failed captures — a placeholder tile so an absent image is explained, not + silently missing. Shown only when no successful shot exists for the same + direction (the server already filters recovered captures out). */} + {failures.map((f, i) => ( +
+ ⚠ + {dirLabel(f.direction)} + {t("pay.snapFailed")} +
+ ))}
{zoom && ( diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ae17c4f..e4e5137 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; /** 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.` 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.`; this English copy stays here so the server can sign + * a human fallback without importing a UI catalog. + */ +export const REASON_EN: Record = { + "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 { + 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 { + 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, +): { reasonCode: ReasonCode; reasonParams?: Record; 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";