import type { FastifyInstance } from "fastify"; import { eq, siteConfig, type Db } from "@parking/db"; import { MODULES, effectiveModules, isModuleId, resolveModuleActivation, type ModuleId } from "@parking/shared"; import { requirePermission } from "../auth.js"; import type { EventLog } from "../event-log.js"; import { activatedModulesOf, entitledModules } from "../modules.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; /** Venue modules to ACTIVATE (full desired set). Validated against the entitlement * and the registry's dependency rules; required modules are always included. Each * module that actually flips signs a config_change. See wiki/decisions/venue-modules.md. */ modules?: unknown; } /** 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; /** Effective venue modules = entitled ∩ activated (what the server enforces). */ modules: ModuleId[]; /** What this deployment is entitled to (MODULES_ENTITLED env) — the Setup → Site * panel offers exactly these to toggle. */ modulesEntitled: ModuleId[]; /** What the site admin has activated (null in storage = everything entitled). */ modulesActivated: ModuleId[]; } & 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, ...moduleView(row), } as SiteConfig; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; return out; } function moduleView(row: typeof siteConfig.$inferSelect | undefined) { const entitled = entitledModules(); const activated = activatedModulesOf(row) ?? entitled; return { modules: effectiveModules(entitled, activated), modulesEntitled: entitled, modulesActivated: activated, }; } /** 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)); // Running build version ("-", matching the Komodo Stack's TAG in // komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile // BUILD_VERSION ARG), read here from the running process env. null on a local/dev // build with no CI-supplied value. Purely informational (Setup nav display); not // site config, so it isn't stored in site_config. app.get("/api/version", { preHandler: readGuard }, async () => ({ buildVersion: process.env.BUILD_VERSION?.trim() || null, })); // 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(); // Venue-module activation. The body carries the full DESIRED set; the shared rules // (required always on, must be entitled, dependencies effective) decide, and every // module whose effective state actually flips is signed as a config_change — the // same attribution pattern as the presence-bypass endpoint below. Disabling never // deletes anything: tables/history/grants stay, routes 403, UI hides. if ("modules" in body) { const requested = body.modules; if (!Array.isArray(requested) || !requested.every(isModuleId)) { return reply.code(400).send({ error: `modules must be an array of module ids (${MODULES.map((m) => m.id).join(", ")})`, }); } const entitled = entitledModules(); const result = resolveModuleActivation(entitled, requested); if (!result.ok) return reply.code(400).send({ error: result.error }); const prevEffective = new Set(effectiveModules(entitled, activatedModulesOf(existing) ?? entitled)); const nextEffective = new Set(effectiveModules(entitled, result.modules)); const operator = req.user?.username ?? "unknown"; for (const m of MODULES) { const was = prevEffective.has(m.id); const now = nextEffective.has(m.id); if (was !== now) { await eventLog?.append({ type: "config_change", source: "manual", identity: `module:${m.id}`, payload: { setting: `modules.${m.id}`, value: now, prev: was, operator }, }); } } patch.modulesJson = JSON.stringify(result.modules); } 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); }, ); }