import type { FastifyInstance } from "fastify"; import { eq, siteConfig, type Db } from "@parking/db"; import { requirePermission } from "../auth.js"; import type { EventLog } from "../event-log.js"; import { getOccupancy } from "../occupancy.js"; // Site config (capacity) + live occupancy. Occupancy is a fold over the signed // ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at // capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md. // Optional park-metadata text fields (all nullable). Trimmed; "" → null. const TEXT_FIELDS = [ "parkName", "operatorName", "nius", "address", "phone", "email", // IANA timezone for tariff wall-clock windows (copied into each published version). "timezone", // Default vehicle/customer category frozen onto each transient entry. "defaultVehicleCategory", ] as const; type TextField = (typeof TEXT_FIELDS)[number]; interface SiteConfigBody extends Partial> { /** Nominal capacity; null = no limit. */ capacity?: number | null; /** Default for the booth "print exit ticket" checkbox (booth-geography knob). */ exitVoucherDefault?: boolean; /** Site default monthly subscription price in minor units (pre-fills the form). */ subscriptionMonthlyPriceMinor?: number | null; /** Reserve a spot in occupancy for each active subscriber's car(s), even when not * parked — so transients see "full" sooner and the subscriber's spot is held. */ reserveSubscriberSpots?: boolean; /** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's * plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */ anprEntryEnabled?: boolean; } /** Shape returned by GET/PUT: capacity + the booth flag + the subscription default * + the entry presence-bypass flags + every metadata field. */ type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean; subscriptionMonthlyPriceMinor: number | null; reserveSubscriberSpots: boolean; anprEntryEnabled: boolean; bypassPresenceRadar: boolean; bypassPresenceCamera: boolean; } & Record; function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { const out = { capacity: row?.capacity ?? null, exitVoucherDefault: row?.exitVoucherDefault ?? false, subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null, reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false, anprEntryEnabled: row?.anprEntryEnabled ?? true, bypassPresenceRadar: row?.bypassPresenceRadar ?? false, bypassPresenceCamera: row?.bypassPresenceCamera ?? false, } as SiteConfig; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; return out; } /** Trim a text field; empty string becomes null so blank input clears it. */ function normText(v: unknown): string | null { if (v == null) return null; const s = String(v).trim(); return s === "" ? null : s; } export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog | null): Promise { const readGuard = requirePermission("site:read"); const writeGuard = requirePermission("site:update"); // Live occupancy: cars inside, capacity, free, full. Any signed-in role. app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db)); // Read site config (capacity + park metadata). app.get("/api/site-config", { preHandler: readGuard }, async () => { const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); return toSiteConfig(row); }); // Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text // (only the fields PRESENT in the body are updated; absent fields are untouched). app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => { const body = req.body ?? ({} as SiteConfigBody); const patch: Partial = {}; if ("capacity" in body) { const c = body.capacity; if (c != null && (!Number.isInteger(c) || c < 0)) { return reply.code(400).send({ error: "capacity must be a non-negative integer or null" }); } patch.capacity = c ?? null; } if ("exitVoucherDefault" in body) { if (typeof body.exitVoucherDefault !== "boolean") { return reply.code(400).send({ error: "exitVoucherDefault must be a boolean" }); } patch.exitVoucherDefault = body.exitVoucherDefault; } if ("subscriptionMonthlyPriceMinor" in body) { const p = body.subscriptionMonthlyPriceMinor; if (p != null && (!Number.isInteger(p) || p < 0)) { return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" }); } patch.subscriptionMonthlyPriceMinor = p ?? null; } if ("reserveSubscriberSpots" in body) { if (typeof body.reserveSubscriberSpots !== "boolean") { return reply.code(400).send({ error: "reserveSubscriberSpots must be a boolean" }); } patch.reserveSubscriberSpots = body.reserveSubscriberSpots; } if ("anprEntryEnabled" in body) { if (typeof body.anprEntryEnabled !== "boolean") { return reply.code(400).send({ error: "anprEntryEnabled must be a boolean" }); } patch.anprEntryEnabled = body.anprEntryEnabled; } for (const f of TEXT_FIELDS) { if (f in body) patch[f] = normText(body[f]); } const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const updatedAt = new Date().toISOString(); if (existing) { db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run(); } else { db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run(); } const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); return toSiteConfig(row); }); // Entry presence-gate BYPASS — a DEDICATED, SIGNED endpoint (not the generic PUT above), // because dropping a radar/camera requirement weakens an anti-fraud gate. The admin is not // the adversary (a faulty device blocks legit entry until support fixes it), but the change // must be attributed + auditable: each toggled signal appends a signed `config_change` // {setting, value, prev, operator}. Granular per signal. See wiki/concepts/entry-presence-bypass.md. app.put<{ Body: { radar?: boolean; camera?: boolean } }>( "/api/site-config/presence-bypass", { preHandler: writeGuard }, async (req, reply) => { const body = req.body ?? {}; for (const k of ["radar", "camera"] as const) { if (k in body && typeof body[k] !== "boolean") { return reply.code(400).send({ error: `${k} must be a boolean` }); } } if (!("radar" in body) && !("camera" in body)) { return reply.code(400).send({ error: "nothing to change (send radar and/or camera)" }); } const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const prev = { radar: existing?.bypassPresenceRadar ?? false, camera: existing?.bypassPresenceCamera ?? false, }; const next = { radar: "radar" in body ? (body.radar as boolean) : prev.radar, camera: "camera" in body ? (body.camera as boolean) : prev.camera, }; // Sign a config_change for each signal that ACTUALLY changed (before persisting, so the // audit record exists whether or not a later write hiccups). No-op toggles sign nothing. const operator = req.user?.username ?? "unknown"; for (const signal of ["radar", "camera"] as const) { if (next[signal] !== prev[signal]) { await eventLog?.append({ type: "config_change", source: "manual", identity: `presence-bypass:${signal}`, payload: { setting: `entryPresenceBypass.${signal}`, value: next[signal], prev: prev[signal], operator, }, }); } } const updatedAt = new Date().toISOString(); const patch = { bypassPresenceRadar: next.radar, bypassPresenceCamera: next.camera, updatedAt }; if (existing) { db.update(siteConfig).set(patch).where(eq(siteConfig.id, 1)).run(); } else { db.insert(siteConfig).values({ id: 1, ...patch }).run(); } const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); return toSiteConfig(row); }, ); }