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