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,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<ReadOutcome> {
|
||||
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<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({
|
||||
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<void> {
|
||||
|
||||
Reference in New Issue
Block a user