e579fe5b6e
Occupancy is a fold over the signed ledger (entries minus exits per identity);
getOccupancy returns {count, capacity, free, full}. Capacity is a single-row
site_config table (admin-set; null = uncapped; migration 0001, additive).
FULL gate lives in the transient entry flow: when full, refuse (no ticket, no
vehicle_entry, no open) and sign an anomaly. Permit entry is NOT gated --
subscribers are admitted past transient-full (their own maxConcurrent still
applies), so occupancy can read over capacity by design (reserve-for-permits).
Routes: GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-negative int or null). Web SiteSettings: live
occupancy + FULL badge (everyone), capacity editor (admin).
Verified: fill to cap -> 3rd transient refused; permit admitted past full; exit
frees a slot; RBAC (operator can't set, -5 -> 400); verifyChain ok. Physical
FULL-sign relay output deferred.
44 lines
1.9 KiB
TypeScript
44 lines
1.9 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { eq, siteConfig, type Db } from "@parking/db";
|
|
import { requireRole } from "../auth.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.
|
|
|
|
interface SiteConfigBody {
|
|
/** Nominal capacity; null = no limit. */
|
|
capacity: number | null;
|
|
}
|
|
|
|
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
|
const writeGuard = requireRole("admin");
|
|
|
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
|
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
|
|
|
// Read site config (capacity).
|
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
return { capacity: row?.capacity ?? null };
|
|
});
|
|
|
|
// Set capacity (admin). null or 0+ integer.
|
|
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
|
|
const { capacity } = req.body ?? ({} as SiteConfigBody);
|
|
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
|
|
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
|
|
}
|
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
const updatedAt = new Date().toISOString();
|
|
if (existing) {
|
|
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
|
} else {
|
|
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run();
|
|
}
|
|
return { capacity: capacity ?? null };
|
|
});
|
|
}
|