e14e31a840
Closes the three known follow-ups of the Tills decision (venue-modules.md): - Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events by payload till, other events by their owning module's till, everything else booth), applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today" panel and the booth feed (history + live pushes). The events route admits a role that holds a module feed permission without event:read and returns only that module's event types — the live-socket rule. - Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by module; the ticket bucket excludes them (Bileta = parking only); printed "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:". - Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the one reading of the role field (the entry/booth loaders treated any non-booth role as an entry dispenser). Footer label "at wash desk". Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new requireAnyPermission) — the Wash operator job could not load the desk's category and service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules, log) updated. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
97 lines
4.9 KiB
TypeScript
97 lines
4.9 KiB
TypeScript
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<void> {
|
|
// 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(),
|
|
);
|
|
}
|