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:
@@ -10,7 +10,7 @@ import {
|
|||||||
type TicketData,
|
type TicketData,
|
||||||
type TicketHeader,
|
type TicketHeader,
|
||||||
} from "@parking/devices";
|
} 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 { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceInputEvent } from "./device-events.js";
|
import type { DeviceInputEvent } from "./device-events.js";
|
||||||
import { getOccupancy } from "./occupancy.js";
|
import { getOccupancy } from "./occupancy.js";
|
||||||
@@ -82,7 +82,11 @@ export class EntryFlow {
|
|||||||
if (occ.full) {
|
if (occ.full) {
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
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})`);
|
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
|
||||||
return;
|
return;
|
||||||
@@ -107,7 +111,7 @@ export class EntryFlow {
|
|||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
identity: ticketId,
|
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)`);
|
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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<string, string | null>();
|
||||||
|
|
||||||
|
/** 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<T extends LedgerEvent>(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 };
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, typ
|
|||||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
import { snapshotAsync } from "./snapshot.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 { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { EventLog } from "./event-log.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
|
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
|
||||||
// path) so a booth attempt on a bad ticket is auditable.
|
// path) so a booth attempt on a bad ticket is auditable.
|
||||||
if (!view || !view.open) {
|
if (!view || !view.open) {
|
||||||
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for ticket";
|
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
|
||||||
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
|
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
|
||||||
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
|
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
|
||||||
return { ok: false, status: view ? "closed" : "no_session", 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.
|
// 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;
|
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
|
||||||
if (!freeGrace && (!paid || !withinGrace)) {
|
if (!freeGrace && (!paid || !withinGrace)) {
|
||||||
const reason = !paid
|
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
|
||||||
? "exit refused — not paid (take payment first)"
|
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
|
||||||
: "exit refused — walk-back grace expired (top-up required)";
|
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
|
||||||
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
|
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||||
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
|
|
||||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||||
@@ -130,7 +128,7 @@ export class ExitFlow {
|
|||||||
currency: view.freeGrace.currency,
|
currency: view.freeGrace.currency,
|
||||||
tariffVersionId: view.freeGrace.tariffVersionId,
|
tariffVersionId: view.freeGrace.tariffVersionId,
|
||||||
graceExitMin: view.freeGrace.graceExitMin,
|
graceExitMin: view.freeGrace.graceExitMin,
|
||||||
reason: "free entry-grace (no charge)",
|
...reasonPayload("exit.freeGrace"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -144,18 +142,18 @@ export class ExitFlow {
|
|||||||
|
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
await this.#openFailedAnomaly(id, "no exit relay configured");
|
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);
|
const access = this.#buildAccess(resolved.controller);
|
||||||
if (!access) {
|
if (!access) {
|
||||||
await this.#openFailedAnomaly(id, "exit controller would not build");
|
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 {
|
try {
|
||||||
await access.pulseOpen(resolved.relay);
|
await access.pulseOpen(resolved.relay);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
|
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);
|
this.#fireExitSnapshot(id);
|
||||||
@@ -210,7 +208,7 @@ export class ExitFlow {
|
|||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
identity: id,
|
identity: id,
|
||||||
payload: {
|
payload: {
|
||||||
reason: "manual barrier open (human intervention)",
|
...reasonPayload("exit.manualOpen"),
|
||||||
source: "booth",
|
source: "booth",
|
||||||
barrierReopen: true,
|
barrierReopen: true,
|
||||||
...(operator ? { operator } : {}),
|
...(operator ? { operator } : {}),
|
||||||
@@ -229,18 +227,18 @@ export class ExitFlow {
|
|||||||
|
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
|
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);
|
const access = this.#buildAccess(resolved.controller);
|
||||||
if (!access) {
|
if (!access) {
|
||||||
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
|
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 {
|
try {
|
||||||
await access.pulseOpen(resolved.relay);
|
await access.pulseOpen(resolved.relay);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
|
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}` : ""}`);
|
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
|
||||||
return { ok: true, opened: true };
|
return { ok: true, opened: true };
|
||||||
@@ -270,14 +268,14 @@ export class ExitFlow {
|
|||||||
|
|
||||||
// No matching open session — unknown/duplicate ticket. Reject + log.
|
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||||
if (!view || !view.open) {
|
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({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
identity: e.value,
|
identity: e.value,
|
||||||
payload: { reason, exitRefused: true },
|
payload: { ...rp, exitRefused: true },
|
||||||
});
|
});
|
||||||
this.#logger.warn(`exit refused: no open session for ${e.value}`);
|
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
|
// 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,
|
currency: view.freeGrace.currency,
|
||||||
tariffVersionId: view.freeGrace.tariffVersionId,
|
tariffVersionId: view.freeGrace.tariffVersionId,
|
||||||
graceExitMin: view.freeGrace.graceExitMin,
|
graceExitMin: view.freeGrace.graceExitMin,
|
||||||
reason: "free entry-grace (no charge)",
|
...reasonPayload("exit.freeGrace"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.#logger.info(`exit free within entry-grace (${e.value})`);
|
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;
|
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
|
||||||
if (!paid || !withinGrace) {
|
if (!paid || !withinGrace) {
|
||||||
const reason = !paid
|
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
|
||||||
? "exit refused — not paid (pay at the station)"
|
|
||||||
: "exit refused — walk-back grace expired (top-up required)";
|
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
identity: e.value,
|
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}`);
|
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
|
||||||
return { accepted: false, direction: "exit", reason };
|
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid (a real payment within walk-back grace): sign + open.
|
// Valid (a real payment within walk-back grace): sign + open.
|
||||||
@@ -352,7 +348,7 @@ export class ExitFlow {
|
|||||||
identity,
|
identity,
|
||||||
payload: {
|
payload: {
|
||||||
sessionRef: identity,
|
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({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
identity,
|
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}`);
|
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { desc, gte, ledgerEvents, type Db } from "@parking/db";
|
import { desc, gte, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { enrichEvent } from "../event-enrich.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
// Read access to the append-only signed event log. NO write/update/delete routes
|
// 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))
|
.orderBy(desc(ledgerEvents.index))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all();
|
.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 };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
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";
|
import { requirePermission } from "../auth.js";
|
||||||
|
|
||||||
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
// 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");
|
const guard = requirePermission("session:read");
|
||||||
|
|
||||||
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
|
// 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 } }>(
|
app.get<{ Params: { identity: string } }>(
|
||||||
"/api/snapshots/by-identity/:identity",
|
"/api/snapshots/by-identity/:identity",
|
||||||
{ preHandler: guard },
|
{ preHandler: guard },
|
||||||
async (req) => {
|
async (req) => {
|
||||||
|
const identity = req.params.identity;
|
||||||
const rows = db
|
const rows = db
|
||||||
.select({
|
.select({
|
||||||
id: snapshots.id,
|
id: snapshots.id,
|
||||||
@@ -27,10 +31,44 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
|
|||||||
capturedAt: snapshots.capturedAt,
|
capturedAt: snapshots.capturedAt,
|
||||||
})
|
})
|
||||||
.from(snapshots)
|
.from(snapshots)
|
||||||
.where(eq(snapshots.identity, req.params.identity))
|
.where(eq(snapshots.identity, identity))
|
||||||
.orderBy(desc(snapshots.capturedAt))
|
.orderBy(desc(snapshots.capturedAt))
|
||||||
.all();
|
.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 };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||||
import { NoPrinterAvailableError } from "@parking/devices";
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { invalidateHolder } from "../event-enrich.js";
|
||||||
import { printSubscriptionCard } from "../booth-print.js";
|
import { printSubscriptionCard } from "../booth-print.js";
|
||||||
import type { CredentialCapture } from "../credential-capture.js";
|
import type { CredentialCapture } from "../credential-capture.js";
|
||||||
import { directionOf } from "../device-resolve.js";
|
import { directionOf } from "../device-resolve.js";
|
||||||
@@ -310,6 +311,8 @@ export async function subscriptionRoutes(
|
|||||||
.where(eq(subscriptions.id, req.params.id))
|
.where(eq(subscriptions.id, req.params.id))
|
||||||
.run();
|
.run();
|
||||||
writeChildren(req.params.id, b);
|
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);
|
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" });
|
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(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
|
||||||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.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();
|
return reply.code(204).send();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
import { roleHasPermissions } from "../auth.js";
|
||||||
import { deviceEvents } from "../device-events.js";
|
import { deviceEvents } from "../device-events.js";
|
||||||
|
import { enrichEvent } from "../event-enrich.js";
|
||||||
import type { DeviceMonitor } from "../device-monitor.js";
|
import type { DeviceMonitor } from "../device-monitor.js";
|
||||||
import { getOccupancy } from "../occupancy.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
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||||
const offLedger = deviceEvents.onLedger((event) => {
|
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) => {
|
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
||||||
send({ kind: "printer-status", event });
|
send({ kind: "printer-status", event });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type DeviceRow,
|
type DeviceRow,
|
||||||
} from "@parking/db";
|
} from "@parking/db";
|
||||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
|
import { reasonPayload, type ReasonCode } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
@@ -98,7 +99,7 @@ export class SubscriptionFlow {
|
|||||||
|
|
||||||
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
|
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
|
||||||
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
|
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.
|
// Validity: active + within the coverage window.
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -107,8 +108,7 @@ export class SubscriptionFlow {
|
|||||||
(sub.validFrom != null && now < sub.validFrom) ||
|
(sub.validFrom != null && now < sub.validFrom) ||
|
||||||
(sub.validTo != null && now > sub.validTo);
|
(sub.validTo != null && now > sub.validTo);
|
||||||
if (invalid) {
|
if (invalid) {
|
||||||
const reason = `subscription ${sub.status}/out-of-window`;
|
const reason = await this.#reject(m, "sub.refused.outOfWindow", { status: sub.status });
|
||||||
await this.#reject(m, reason);
|
|
||||||
return { accepted: false, reason };
|
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.
|
// subscription has NOTHING open, an exit read is a no-op anti-passback signal.
|
||||||
const oldest = open[0];
|
const oldest = open[0];
|
||||||
if (!oldest) {
|
if (!oldest) {
|
||||||
const reason = "subscription exit with no open session (already out / never entered)";
|
const reason = await this.#reject(m, "sub.refused.noSession");
|
||||||
await this.#reject(m, reason);
|
|
||||||
return { accepted: false, direction: "exit", reason };
|
return { accepted: false, direction: "exit", reason };
|
||||||
}
|
}
|
||||||
const occurrenceId = oldest.identity;
|
const occurrenceId = oldest.identity;
|
||||||
@@ -157,8 +156,10 @@ export class SubscriptionFlow {
|
|||||||
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a
|
// 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.
|
// fresh per-occurrence id so a fleet can have several open at once.
|
||||||
if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) {
|
if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) {
|
||||||
const reason = `subscription at capacity (${open.length}/${sub.maxConcurrent} cars in)`;
|
const reason = await this.#reject(m, "sub.refused.atCapacity", {
|
||||||
await this.#reject(m, reason);
|
inUse: open.length,
|
||||||
|
max: sub.maxConcurrent,
|
||||||
|
});
|
||||||
return { accepted: false, direction: "entry", reason };
|
return { accepted: false, direction: "entry", reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,14 +227,22 @@ export class SubscriptionFlow {
|
|||||||
return open;
|
return open;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #reject(m: SubscriptionMatch, reason: string): Promise<void> {
|
/** 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<string, string | number>,
|
||||||
|
): Promise<string> {
|
||||||
|
const rp = reasonPayload(code, params);
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
identity: m.carKey,
|
identity: m.carKey,
|
||||||
// `permitId`/`permitRefused` are the on-chain field names (immutable).
|
// `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<void> {
|
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||||
|
import { formatMoney } from "./lib/format.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useLiveStore } from "./lib/live-store.js";
|
import { useLiveStore } from "./lib/live-store.js";
|
||||||
import { useShift } from "./lib/use-shift.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 { StatusDot } from "./ui/StatusDot.js";
|
||||||
import { BoothPayModal } from "./BoothPayModal.js";
|
import { BoothPayModal } from "./BoothPayModal.js";
|
||||||
import { ActiveSessions } from "./ActiveSessions.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
|
// 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
|
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
||||||
@@ -70,17 +74,223 @@ 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 { t } = useTranslation();
|
||||||
const style = EVENT_STYLE[e.type];
|
const style = EVENT_STYLE[e.type];
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-3 border-b border-term-border/50 py-1 text-[12px] tabular-nums">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(e)}
|
||||||
|
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||||
|
isAnomaly ? "bg-term-red/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
<span className={`w-20 shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||||
<span className="truncate text-term-text">{e.identity ?? "—"}</span>
|
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||||
<span className="ml-auto text-term-muted">#{e.index}</span>
|
<span className="text-term-muted">#{e.index}</span>
|
||||||
|
{showDetail && (
|
||||||
|
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
{badges.map((k) => (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||||
|
>
|
||||||
|
{t(k)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{detail && (
|
||||||
|
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One label/value line in the event-detail modal. */
|
||||||
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||||
|
<span className="min-w-0 break-words text-term-text">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
||||||
|
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||||
|
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
||||||
|
{(reason || money) && (
|
||||||
|
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||||
|
{reason ?? money}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!reason && !money && isAnomaly && (
|
||||||
|
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
||||||
|
)}
|
||||||
|
{badges.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{badges.map((k) => (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||||
|
>
|
||||||
|
{t(k)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
||||||
|
<div>
|
||||||
|
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
||||||
|
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
||||||
|
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
||||||
|
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
||||||
|
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
|
||||||
|
{/* 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 && (
|
||||||
|
<DetailRow label={t("booth.edOccurrence")}>
|
||||||
|
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
{money && (
|
||||||
|
<DetailRow label={t("booth.edAmount")}>
|
||||||
|
<span className="text-term-cyan">{money}</span>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
||||||
|
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||||
|
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||||
|
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||||
|
{sessionRef && sessionRef !== e.identity && (
|
||||||
|
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
||||||
|
)}
|
||||||
|
{tariffVersionId && (
|
||||||
|
<DetailRow label={t("booth.edTariffVersion")}>
|
||||||
|
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The entry/exit evidence images for this session's identity. */}
|
||||||
|
{e.identity && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
||||||
|
<SnapshotStrip identity={e.identity} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<details className="rounded-term border border-term-border bg-term-panel-2">
|
||||||
|
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||||
|
{t("booth.edAuditData")}
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
||||||
|
<DetailRow label={t("booth.edSignature")}>
|
||||||
|
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label={t("booth.edKeyId")}>
|
||||||
|
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label={t("booth.edPrevHash")}>
|
||||||
|
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
||||||
|
</DetailRow>
|
||||||
|
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||||
|
{t("booth.edRawPayload")}
|
||||||
|
</div>
|
||||||
|
{p && Object.keys(p).length > 0 ? (
|
||||||
|
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
||||||
|
{JSON.stringify(p, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +348,8 @@ export function BoothScreen() {
|
|||||||
|
|
||||||
// The ticket currently open in the pay/exit modal (null = no modal).
|
// The ticket currently open in the pay/exit modal (null = no modal).
|
||||||
const [activeTicket, setActiveTicket] = useState<string | null>(null);
|
const [activeTicket, setActiveTicket] = useState<string | null>(null);
|
||||||
|
// The ledger event open in the read-only detail modal (null = closed).
|
||||||
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||||
|
|
||||||
// Live overlays from the WS store.
|
// Live overlays from the WS store.
|
||||||
const liveOcc = useLiveStore((s) => s.occupancy);
|
const liveOcc = useLiveStore((s) => s.occupancy);
|
||||||
@@ -195,12 +407,13 @@ export function BoothScreen() {
|
|||||||
) : events.length === 0 ? (
|
) : events.length === 0 ? (
|
||||||
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
|
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
|
||||||
) : (
|
) : (
|
||||||
events.map((e) => <EventRow key={e.id} e={e} />)
|
events.map((e) => <EventRow key={e.id} e={e} onOpen={setDetailEvent} />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
|
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
|
||||||
|
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -747,9 +747,20 @@ export interface SnapshotMeta {
|
|||||||
capturedAt: string;
|
capturedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Snapshot metadata for a session identity (newest first). Image bytes are at
|
/** A capture that was ATTEMPTED but failed (camera offline, config) — surfaced so a
|
||||||
* `/api/snapshots/:id` — use that URL directly as an <img src>. */
|
* missing image isn't a silent gap. From snapshot telemetry, not the image store. */
|
||||||
export function fetchSnapshots(identity: string): Promise<{ snapshots: SnapshotMeta[] }> {
|
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 <img src>. */
|
||||||
|
export function fetchSnapshots(
|
||||||
|
identity: string,
|
||||||
|
): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[] }> {
|
||||||
return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`);
|
return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,67 @@ export const en: Catalog = {
|
|||||||
evtShiftZ: "SHIFT Z",
|
evtShiftZ: "SHIFT Z",
|
||||||
evtCashMovement: "CASH",
|
evtCashMovement: "CASH",
|
||||||
evtAnomaly: "ANOMALY",
|
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: {
|
tariff: {
|
||||||
title: "Tariff",
|
title: "Tariff",
|
||||||
@@ -446,5 +507,8 @@ export const en: Catalog = {
|
|||||||
reprinting: "printing…",
|
reprinting: "printing…",
|
||||||
noSnapshots: "no snapshots",
|
noSnapshots: "no snapshots",
|
||||||
loadingSnapshots: "loading snapshots…",
|
loadingSnapshots: "loading snapshots…",
|
||||||
|
snapEntry: "entry",
|
||||||
|
snapExit: "exit",
|
||||||
|
snapFailed: "camera unreachable",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,6 +105,66 @@ export const sq = {
|
|||||||
evtShiftZ: "TURN Z",
|
evtShiftZ: "TURN Z",
|
||||||
evtCashMovement: "ARKË",
|
evtCashMovement: "ARKË",
|
||||||
evtAnomaly: "ANOMALI",
|
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: {
|
tariff: {
|
||||||
title: "Tarifa",
|
title: "Tarifa",
|
||||||
@@ -459,6 +519,9 @@ export const sq = {
|
|||||||
// snapshots
|
// snapshots
|
||||||
noSnapshots: "asnjë foto",
|
noSnapshots: "asnjë foto",
|
||||||
loadingSnapshots: "duke ngarkuar fotot…",
|
loadingSnapshots: "duke ngarkuar fotot…",
|
||||||
|
snapEntry: "hyrje",
|
||||||
|
snapExit: "dalje",
|
||||||
|
snapFailed: "kamera e paarritshme",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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.<code>` 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<string>(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<string, unknown>);
|
||||||
|
}
|
||||||
|
// No code (legacy) or an unknown code (forward-compat) → the signed English fallback.
|
||||||
|
return typeof payload.reason === "string" ? payload.reason : null;
|
||||||
|
}
|
||||||
@@ -17,20 +17,26 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
const [zoom, setZoom] = useState<string | null>(null);
|
const [zoom, setZoom] = useState<string | null>(null);
|
||||||
|
|
||||||
const shots = data?.snapshots ?? [];
|
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 <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
||||||
if (shots.length === 0) return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
if (shots.length === 0 && failures.length === 0)
|
||||||
|
return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{shots.map((s) => (
|
{shots.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setZoom(s.id)}
|
onClick={() => setZoom(s.id)}
|
||||||
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
||||||
title={`${s.direction ?? "snapshot"} · ${new Date(s.capturedAt).toLocaleString()}`}
|
title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={snapshotImageUrl(s.id)}
|
src={snapshotImageUrl(s.id)}
|
||||||
@@ -43,10 +49,25 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s.direction ?? "—"}
|
{dirLabel(s.direction)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 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) => (
|
||||||
|
<div
|
||||||
|
key={`fail-${f.direction ?? "both"}-${i}`}
|
||||||
|
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
|
||||||
|
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none text-term-amber">⚠</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||||
|
<span className="px-1 text-[9px] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{zoom && (
|
{zoom && (
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ export interface LedgerEvent {
|
|||||||
readonly signature: string;
|
readonly signature: string;
|
||||||
/** Which signer/key produced `signature` (verifiable across a signer swap). */
|
/** Which signer/key produced `signature` (verifiable across a signer swap). */
|
||||||
readonly keyId: string;
|
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. */
|
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */
|
||||||
@@ -134,8 +139,17 @@ export interface LedgerPayload {
|
|||||||
readonly discountMinor?: number;
|
readonly discountMinor?: number;
|
||||||
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
||||||
readonly fxRate?: number | null;
|
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;
|
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). */
|
/** plate/vehicle from the vision service (advisory). */
|
||||||
readonly plate?: string;
|
readonly plate?: string;
|
||||||
readonly plateConfidence?: number;
|
readonly plateConfidence?: number;
|
||||||
@@ -146,6 +160,103 @@ export interface LedgerPayload {
|
|||||||
readonly [k: string]: unknown;
|
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. */
|
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
|
||||||
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user