import type { FastifyInstance } from "fastify"; 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 // packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence // tied to a signed vehicle_entry/exit by `identity`; the operator reviews them // next to the event. Read-only — images are written only by the flows (snapshot.ts), // never via the API. export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise { 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. 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, direction: snapshots.direction, deviceId: snapshots.deviceId, identity: snapshots.identity, contentType: snapshots.contentType, capturedAt: snapshots.capturedAt, }) .from(snapshots) .where(eq(snapshots.identity, identity)) .orderBy(desc(snapshots.capturedAt)) .all(); // 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(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(); 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 ?? "", }); } // Recognized PLATES for this session: kind="read" telemetry from the ANPR-on- // snapshot path (snapshot.ts → recognizePlate). Advisory — a record of the plate // observed for the session, shown beside the image. Newest first. const plateRows = db .select({ detail: deviceEvents.detail, occurredAt: deviceEvents.occurredAt }) .from(deviceEvents) .where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read"))) .orderBy(desc(deviceEvents.occurredAt)) .all(); const plates: { plate: string; confidence: number | null; region: string | null; direction: "entry" | "exit" | null; snapshotId: string | null; at: string; }[] = []; for (const row of plateRows) { const d = (row.detail ?? {}) as { identity?: string; plate?: string; confidence?: number; region?: string | null; direction?: string; snapshotId?: string; }; if (d.identity !== identity || !d.plate) continue; plates.push({ plate: d.plate, confidence: typeof d.confidence === "number" ? d.confidence : null, region: d.region ?? null, direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null, snapshotId: d.snapshotId ?? null, at: row.occurredAt ?? "", }); } return { snapshots: rows, failures, plates }; }, ); // Stream one snapshot's image bytes by id. Returns the stored content type. app.get<{ Params: { id: string } }>( "/api/snapshots/:id", { preHandler: guard }, async (req, reply) => { const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get(); if (!row) return reply.code(404).send({ error: "no such snapshot" }); reply.header("content-type", row.contentType); reply.header("cache-control", "private, max-age=31536000, immutable"); return reply.send(row.bytes); }, ); }