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 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;
|
||||
|
||||
@@ -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 { 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}`);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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<void
|
||||
const guard = requirePermission("session:read");
|
||||
|
||||
// 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 } }>(
|
||||
"/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<void
|
||||
capturedAt: snapshots.capturedAt,
|
||||
})
|
||||
.from(snapshots)
|
||||
.where(eq(snapshots.identity, req.params.identity))
|
||||
.where(eq(snapshots.identity, identity))
|
||||
.orderBy(desc(snapshots.capturedAt))
|
||||
.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 { 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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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