f31e57b4ae
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
54 lines
2.2 KiB
TypeScript
54 lines
2.2 KiB
TypeScript
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
|
|
// exist by design — events are only ever appended internally (entry flow, device
|
|
// pushes). Corrections are new appended events, never edits. See
|
|
// wiki/concepts/append-only-event-chain.md.
|
|
|
|
export async function eventRoutes(
|
|
app: FastifyInstance,
|
|
db: Db,
|
|
eventLog: EventLog,
|
|
): Promise<void> {
|
|
// Reading the log (the audit trail).
|
|
const guard = requirePermission("event:read");
|
|
|
|
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
|
// Optional `since` (ISO) scopes the page to events at/after that instant — the
|
|
// booth passes the current shift's start so the live feed shows ONLY this shift's
|
|
// activity (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
|
app.get<{ Querystring: { limit?: string; since?: string } }>(
|
|
"/api/events",
|
|
{ preHandler: guard },
|
|
async (req) => {
|
|
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
|
const since = (req.query.since ?? "").trim();
|
|
const rows = db
|
|
.select()
|
|
.from(ledgerEvents)
|
|
.where(since ? gte(ledgerEvents.occurredAt, since) : undefined)
|
|
.orderBy(desc(ledgerEvents.index))
|
|
.limit(limit)
|
|
.all();
|
|
// 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 };
|
|
},
|
|
);
|
|
|
|
// Integrity self-check: walk the chain and verify hashes + signatures. Admin-
|
|
// only (it's an audit action). Returns the first break, or ok. This is what a
|
|
// reconciliation job / "is the log intact?" check calls.
|
|
app.get(
|
|
"/api/events/verify",
|
|
{ preHandler: requirePermission("event:read") },
|
|
async () => eventLog.verifyChain(),
|
|
);
|
|
}
|