import type { FastifyInstance } from "fastify"; import { and, desc, gte, inArray, lte, sql, ledgerEvents, type Db } from "@parking/db"; import { BOOTH_TILL, MODULES, feedPermissionFor, isTillId, type LedgerEvent, type LedgerEventType } from "@parking/shared"; import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js"; import { effectiveModulesFor } from "../modules.js"; import { enrichEvents } 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 { // Reading the log (the audit trail). `event:read` reads everything; a role WITHOUT it // may still hold a module's feed permission (a wash operator's `carwash:read`) and // then reads ONLY that module's event types — the same rule the live socket applies // (feedPermissionFor; venue-modules.md §Permissions matrix, move 3). /** The event types a role may read, or null for "everything" (event:read). Empty = * the role reads nothing → 403 at the route. */ function readableTypes(roleId: string): LedgerEventType[] | null { if (roleHasPermissions(roleId, ["event:read"])) return null; const effective = effectiveModulesFor(db); const out: LedgerEventType[] = []; for (const m of MODULES) { if (!m.feedPermission || !effective.includes(m.id)) continue; if (roleHasPermissions(roleId, [m.feedPermission])) out.push(...m.ledgerEventTypes); } return out; } /** SQL form of the shared `tillOfEvent` rule: the payload's `till`, else the till of * the module owning the event type, else the booth. Computed in the query so the * page limit applies AFTER the till filter (a shift's window can hold thousands of * device events). */ const tillExpr = (() => { const cases = MODULES.filter((m) => m.till && m.till !== BOOTH_TILL && m.ledgerEventTypes.length > 0).map( (m) => sql`when ${ledgerEvents.type} in (${sql.join(m.ledgerEventTypes.map((t) => sql`${t}`), sql`, `)}) then ${m.till}`, ); return sql`coalesce(json_extract(${ledgerEvents.payload}, '$.till'), case ${sql.join(cases, sql` `)} else ${BOOTH_TILL} end)`; })(); // Recent events, newest first. `limit` caps the page (default 100, max 1000). // Optional `since` (ISO) scopes to events at/after that instant — the booth passes // the current shift's start so the live feed shows ONLY this shift's activity. An // optional `until` (ISO) closes the upper bound — the shift-history screen passes a // selected shift's [start, end] to show just that shift's signed activity log. // (logs are per-shift, not all history). An optional `till` keeps only that till's // activity (tillOfEvent) — a booth shift's log no longer shows the wash desk's, and // vice versa. See wiki/concepts/shift.md §Tills. app.get<{ Querystring: { limit?: string; since?: string; until?: string; till?: string } }>( "/api/events", { preHandler: requireAuth }, async (req, reply) => { const types = readableTypes(req.user?.roleId ?? ""); if (types && types.length === 0) return reply.code(403).send({ error: "forbidden" }); const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const since = (req.query.since ?? "").trim(); const until = (req.query.until ?? "").trim(); const till = (req.query.till ?? "").trim(); if (till && !isTillId(till)) return reply.code(400).send({ error: "unknown till", code: "bad_till" }); const bounds = [ since ? gte(ledgerEvents.occurredAt, since) : undefined, until ? lte(ledgerEvents.occurredAt, until) : undefined, till ? sql`${tillExpr} = ${till}` : undefined, types ? inArray(ledgerEvents.type, types) : undefined, ].filter(Boolean); const rows = db .select() .from(ledgerEvents) .where(bounds.length ? and(...bounds) : undefined) .orderBy(desc(ledgerEvents.index)) .limit(limit) .all(); // Attach read-time display fields (subscriber name, advisory plate) without // touching the signed record. One plate scan for the whole page (enrichEvents). // The cast bridges the Drizzle row to the shared LedgerEvent. const events = enrichEvents(db, rows 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(), ); }