5697137c52
The "permit/lejet" feature is really a subscription. Full rename of the mutable master data, plus a recurring monthly price. - DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions, permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id. - Pricing: per-subscription priceMinor + period(monthly) + currency, with a site default (site_config.subscription_monthly_price_minor) pre-filling the form. - Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en). - The signed ledger `permitId` payload is intentionally kept — immutable hash-chained history; renaming it would break verification of past events. Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber). Also carries the device-footer UI surface (api DeviceStatus, router mount, i18n devices) due to shared-file overlap with the preceding footer commit. Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions preserved). Live DB migrated. Full monorepo builds clean. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
|
import { registry, type CameraDevice } from "@parking/devices";
|
|
import type { FastifyBaseLogger } from "fastify";
|
|
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
|
|
|
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
|
// open path (decision 2026-06-16): a snapshot is EVIDENCE, not a gate. A camera
|
|
// failure must never delay or prevent an open — the signed ledger is the decision,
|
|
// the image is an independent, prunable record stored as a BLOB in `snapshots`.
|
|
// See wiki/concepts/entry-exit-points.md and append-only-event-chain.md.
|
|
//
|
|
// Every camera serving the firing direction (entry/exit, or both) snapshots. Each
|
|
// capture is independent — one camera down doesn't stop the others. A captured image
|
|
// → a `snapshots` row + a `kind:"snapshot"` telemetry device_event; a failure → a
|
|
// telemetry device_event only. The caller passes the session `identity` so the image
|
|
// links to the signed vehicle_entry/exit.
|
|
|
|
interface SnapshotJob {
|
|
readonly db: Db;
|
|
readonly direction: FlowDirection;
|
|
/** Session/credential ref (ticket id, plate, subscription car key) — links to the ledger. */
|
|
readonly identity: string;
|
|
readonly logger: FastifyBaseLogger;
|
|
}
|
|
|
|
/**
|
|
* Fire snapshots for the directional camera set. Returns immediately with a promise
|
|
* the caller MAY ignore (fire-and-forget) — it resolves to the captured snapshot ids.
|
|
* The caller must NOT block its open path on this.
|
|
*/
|
|
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
|
const { db, direction, identity, logger } = job;
|
|
const rows = devicesByDirection(db, "camera", direction);
|
|
if (rows.length === 0) return Promise.resolve([]);
|
|
|
|
return Promise.all(
|
|
rows.map(async (row): Promise<string | null> => {
|
|
const camera = buildCamera(row);
|
|
if (!camera) {
|
|
recordFailure(db, direction, row.id, identity, "camera config won't build", logger);
|
|
return null;
|
|
}
|
|
try {
|
|
const shot = await camera.captureSnapshot({ direction });
|
|
const id: string = randomUUID();
|
|
db.insert(snapshots)
|
|
.values({
|
|
id,
|
|
direction,
|
|
deviceId: row.id,
|
|
identity,
|
|
contentType: shot.contentType,
|
|
bytes: shot.bytes,
|
|
capturedAt: shot.capturedAt,
|
|
})
|
|
.run();
|
|
// Telemetry breadcrumb pointing at the stored image (NOT the bytes).
|
|
recordEvent(db, direction, row.id, identity, { snapshotId: id, ok: true }, logger);
|
|
return id;
|
|
} catch (err) {
|
|
recordFailure(db, direction, row.id, identity, (err as Error).message, logger);
|
|
return null;
|
|
}
|
|
}),
|
|
).then((ids) => ids.filter((id): id is string => id != null));
|
|
}
|
|
|
|
/** Build a live camera adapter from a resolved devices row, or null. */
|
|
function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
|
const driver = registry.get(row.driverId);
|
|
if (!driver) return null;
|
|
try {
|
|
return driver.create(row.config as never) as CameraDevice;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function recordFailure(
|
|
db: Db,
|
|
direction: FlowDirection,
|
|
deviceId: string,
|
|
identity: string,
|
|
error: string,
|
|
logger: FastifyBaseLogger,
|
|
): void {
|
|
logger.warn(`snapshot failed (${direction}, ${identity}): ${error}`);
|
|
recordEvent(db, direction, deviceId, identity, { ok: false, error }, logger);
|
|
}
|
|
|
|
function recordEvent(
|
|
db: Db,
|
|
direction: FlowDirection,
|
|
deviceId: string,
|
|
identity: string,
|
|
detail: Record<string, unknown>,
|
|
logger: FastifyBaseLogger,
|
|
): void {
|
|
try {
|
|
db.insert(deviceEventsTable)
|
|
.values({
|
|
id: randomUUID(),
|
|
deviceId,
|
|
category: "camera",
|
|
kind: "snapshot",
|
|
detail: { ...detail, direction, identity },
|
|
occurredAt: new Date().toISOString(),
|
|
})
|
|
.run();
|
|
} catch (err) {
|
|
// Telemetry is best-effort; never let it surface on the (already-open) path.
|
|
logger.error(`snapshot device-event insert failed: ${(err as Error).message}`);
|
|
}
|
|
}
|