diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index 271d262..e8984ba 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -11,6 +11,7 @@ import { } from "@parking/devices"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceInputEvent } from "./device-events.js"; +import { getOccupancy } from "./occupancy.js"; import type { EventLog } from "./event-log.js"; import type { LaneMap } from "./lane-map.js"; @@ -77,6 +78,22 @@ export class EntryFlow { } async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise { + // CAPACITY GATE (transient only). When the lot is full, refuse transient entry: + // no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT + // gated here (their flow ignores site-full; their own maxConcurrent applies), so + // subscribers aren't locked out. "Full" is a soft policy seam for valet over- + // capacity later. See wiki/concepts/capacity-occupancy.md. + const occ = getOccupancy(this.#db); + if (occ.full) { + await this.#log.append({ + type: "anomaly", + lane, + payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true }, + }); + this.#logger.warn(`transient entry REFUSED on lane ${lane}: full (${occ.count}/${occ.capacity})`); + return; + } + const ticketId = newTicketId(); const issuedAt = new Date().toISOString(); const printers = await this.#loadPrinters(lane); diff --git a/apps/server/src/occupancy.ts b/apps/server/src/occupancy.ts new file mode 100644 index 0000000..e1f3fe0 --- /dev/null +++ b/apps/server/src/occupancy.ts @@ -0,0 +1,49 @@ +import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db"; + +// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events +// with no matching vehicle_exit. Never a hand-maintained counter (which is +// editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md. + +export interface Occupancy { + /** Cars currently inside (open sessions). */ + readonly count: number; + /** Admin-set nominal capacity, or null = no limit. */ + readonly capacity: number | null; + /** capacity − count, or null when uncapped. Can read 0 (or below) when full. */ + readonly free: number | null; + /** True when count ≥ capacity (always false when uncapped). */ + readonly full: boolean; +} + +/** Count cars inside: entries minus exits, per identity, over the ledger. */ +export function occupancyCount(db: Db): number { + const rows = db + .select({ type: ledgerEvents.type, identity: ledgerEvents.identity }) + .from(ledgerEvents) + .all(); + const balance = new Map(); + for (const r of rows) { + if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1); + else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1); + } + let open = 0; + for (const v of balance.values()) if (v > 0) open += 1; + return open; +} + +/** Admin-set capacity (null = uncapped). */ +export function siteCapacity(db: Db): number | null { + const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); + return row?.capacity ?? null; +} + +export function getOccupancy(db: Db): Occupancy { + const count = occupancyCount(db); + const capacity = siteCapacity(db); + return { + count, + capacity, + free: capacity == null ? null : capacity - count, + full: capacity != null && count >= capacity, + }; +} diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts new file mode 100644 index 0000000..d86b63c --- /dev/null +++ b/apps/server/src/routes/site.ts @@ -0,0 +1,43 @@ +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 { + 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 }; + }); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 54f79cc..33e957e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -21,6 +21,7 @@ import { eventRoutes } from "./routes/events.js"; import { payRoutes } from "./routes/pay.js"; import { permitRoutes } from "./routes/permits.js"; import { shiftRoutes } from "./routes/shift.js"; +import { siteRoutes } from "./routes/site.js"; import { tariffRoutes } from "./routes/tariffs.js"; import { printerRoutes } from "./routes/printers.js"; import { setupRoutes } from "./routes/setup.js"; @@ -129,6 +130,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise { // Resolve which lane the device belongs to. -1 marks "device fired but isn't // mapped to a lane" (assigned without a lane, or a stale id) — still recorded diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e942b11..73ad0dd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { Login } from "./Login.js"; import { PermitManager } from "./PermitManager.js"; import { SetupWizard } from "./SetupWizard.js"; import { ShiftControl } from "./ShiftControl.js"; +import { SiteSettings } from "./SiteSettings.js"; import { TariffComposer } from "./TariffComposer.js"; // Operator UI shell. Plain React (no admin framework) — the operator UI is @@ -41,6 +42,7 @@ export function App() { + {user.role !== "readonly" && } {user.role === "admin" ? ( <> diff --git a/apps/web/src/SiteSettings.tsx b/apps/web/src/SiteSettings.tsx new file mode 100644 index 0000000..f694a88 --- /dev/null +++ b/apps/web/src/SiteSettings.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; +import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js"; + +// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the +// signed ledger); the capacity field is admin-editable. The FULL gate (refuse +// transient entry at capacity) is enforced server-side in the entry flow. +// See wiki/concepts/capacity-occupancy.md. + +export function SiteSettings({ canEdit }: { canEdit: boolean }) { + const [occ, setOcc] = useState(null); + const [capInput, setCapInput] = useState(""); + const [msg, setMsg] = useState(null); + + function reload() { + fetchOccupancy().then(setOcc).catch(() => {}); + } + useEffect(() => { + reload(); + fetchSiteConfig() + .then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity))) + .catch(() => {}); + }, []); + + async function save() { + setMsg(null); + const raw = capInput.trim(); + const capacity = raw === "" ? null : Math.round(Number(raw)); + try { + await setCapacity(capacity); + reload(); + setMsg("Capacity saved."); + } catch (e) { + setMsg((e as Error).message); + } + } + + return ( +
+ Occupancy:{" "} + {occ == null ? ( + "…" + ) : ( + <> + {occ.count} + {occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"} + {occ.capacity != null && ( + · {occ.free} free + )} + {occ.full && FULL}{" "} + + + )} + {canEdit && ( +
+ {" "} + + {msg && {msg}} +
+ )} +
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 97dbfe8..d1415a0 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -304,3 +304,22 @@ export function openShift(): Promise<{ startedAt: string }> { export function closeShift(): Promise { return apiFetch("/api/shift/close", { method: "POST" }); } + +// --- Site config / occupancy ---------------------------------------------- + +export interface Occupancy { + count: number; + capacity: number | null; + free: number | null; + full: boolean; +} + +export function fetchOccupancy(): Promise { + return apiFetch("/api/occupancy"); +} +export function fetchSiteConfig(): Promise<{ capacity: number | null }> { + return apiFetch("/api/site-config"); +} +export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> { + return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) }); +} diff --git a/packages/db/drizzle/0001_site_config.sql b/packages/db/drizzle/0001_site_config.sql new file mode 100644 index 0000000..9b1a8cf --- /dev/null +++ b/packages/db/drizzle/0001_site_config.sql @@ -0,0 +1,5 @@ +CREATE TABLE `site_config` ( + `id` integer PRIMARY KEY NOT NULL, + `capacity` integer, + `updated_at` text DEFAULT (current_timestamp) NOT NULL +); diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..71eeaad --- /dev/null +++ b/packages/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,724 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "538cd745-2fe9-410b-bb9f-e4d3c2086211", + "prevId": "cd09c11f-4306-4ac8-a335-7c050d080ab6", + "tables": { + "blocklist": { + "name": "blocklist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_events": { + "name": "device_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lane": { + "name": "lane", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lane_devices": { + "name": "lane_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lane": { + "name": "lane", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "driver_id": { + "name": "driver_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ledger_events": { + "name": "ledger_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lane": { + "name": "lane", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ledger_events_index_unique": { + "name": "ledger_events_index_unique", + "columns": [ + "index" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "permit_credentials": { + "name": "permit_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "permit_id": { + "name": "permit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "permit_plates": { + "name": "permit_plates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "permit_id": { + "name": "permit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plate": { + "name": "plate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "permits": { + "name": "permits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "holder_name": { + "name": "holder_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_concurrent": { + "name": "max_concurrent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "valid_from": { + "name": "valid_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "valid_to": { + "name": "valid_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lane": { + "name": "lane", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permit_id": { + "name": "permit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entered_at": { + "name": "entered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exited_at": { + "name": "exited_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "last_event_index": { + "name": "last_event_index", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setup_state": { + "name": "setup_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_config": { + "name": "site_config", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tariff_versions": { + "name": "tariff_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tariff_id": { + "name": "tariff_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_from": { + "name": "effective_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tariffs": { + "name": "tariffs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'site'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 45476f9..42dc9f6 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1781539958008, "tag": "0000_baseline", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1781590195573, + "tag": "0001_site_config", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e628e7c..71a9daf 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -101,6 +101,17 @@ export const setupState = sqliteTable("setup_state", { completedAt: text("completed_at"), }); +// Single-row site settings (admin-configurable). The home for site-wide knobs; +// `capacity` is the nominal space count the FULL gate refuses transient entry at +// (null = no cap). See wiki/concepts/capacity-occupancy.md. +export const siteConfig = sqliteTable("site_config", { + id: integer("id").primaryKey(), // always 1 + capacity: integer("capacity"), // null = no capacity limit + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), +}); + // --- Tariffs (composable, versioned) ------------------------------------- // A `tariffs` row is a logical rate card; its pricing lives in immutable, effective- // dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one. @@ -215,6 +226,7 @@ export type LedgerEventRow = typeof ledgerEvents.$inferSelect; export type DeviceEventRow = typeof deviceEvents.$inferSelect; export type LaneDeviceRow = typeof laneDevices.$inferSelect; export type SetupStateRow = typeof setupState.$inferSelect; +export type SiteConfigRow = typeof siteConfig.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect; export type TariffVersionRow = typeof tariffVersions.$inferSelect; export type PermitRow = typeof permits.$inferSelect; diff --git a/wiki/concepts/capacity-occupancy.md b/wiki/concepts/capacity-occupancy.md index 6cc3556..50325f5 100644 --- a/wiki/concepts/capacity-occupancy.md +++ b/wiki/concepts/capacity-occupancy.md @@ -41,8 +41,27 @@ over, stacked beyond the marked count) instead of refusing. So the FULL gate is (refuse vs. valet-accept), set by the operator per site. Valet is a manned-mode feature with its own custody/session shape — see [[valet-overcapacity]] (deferred). +## As-built (2026-06-16) + +- **Occupancy** = `occupancyCount` (`apps/server/src/occupancy.ts`): a fold over the ledger — + entries minus exits per identity, count those `> 0`. `getOccupancy` returns `{count, capacity, + free, full}`. +- **Capacity** is a single-row `site_config` table (admin-set; `null` = uncapped). Routes + (`routes/site.ts`): `GET /api/occupancy` + `GET /api/site-config` (any role), `PUT /api/site-config` + (admin; non-negative int or null). +- **FULL gate** is in the **transient entry flow**: `occupancy.full` → refuse (no ticket, no + `vehicle_entry`, no open) + signed `anomaly`. **Permit entry is NOT gated** here — subscribers are + admitted past transient-full (their own `maxConcurrent` still applies); occupancy can read + over-capacity (`free` negative) when permits enter a full lot, as intended. +- **UI** `SiteSettings`: live occupancy + FULL badge (everyone); capacity editor (admin). +- Verified: fill to cap → 3rd transient refused; permit still admitted past full; exit frees a + slot; RBAC (operator can't set capacity); verifyChain ok. Physical FULL-sign relay output is + **deferred** (needs a sign device). + ## Open - Zone/level granularity at launch vs. single capacity number. -- Reserve-for-permits threshold. +- Reserve-for-permits **threshold** (a soft transient cap below the hard capacity) — currently + permits are simply ungated; a tunable threshold is the richer version. +- Physical FULL-sign relay output (a sign-device role). - The valet over-capacity mode + custody model ([[valet-overcapacity]]). diff --git a/wiki/log.md b/wiki/log.md index 3bd051a..f9093ee 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -592,3 +592,18 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). - VERIFIED: open→double-open 409→payments (cash+card; one dated outside the window excluded)→close totals (cash 500/card 250/3)→close-again 409→re-open ok; readonly 403; verifyChain ok. Full build 5/5. - Updated [[shift]] (as-built). + +## [2026-06-16] build | Capacity / FULL gate (occupancy fold + transient refuse) +- Occupancy = fold over the ledger (entries−exits per identity; `apps/server/src/occupancy.ts`), + `getOccupancy` → {count, capacity, free, full}. Capacity = single-row `site_config` table (admin, + null=uncapped); migration 0001 (additive, no prompt). +- FULL gate in the TRANSIENT entry flow: occupancy.full → refuse (no ticket/entry/open) + signed + anomaly. Permit entry NOT gated (subscribers admitted past transient-full; their maxConcurrent + still applies) — occupancy can read over-capacity by design. +- Routes (`routes/site.ts`): GET /api/occupancy + GET /api/site-config (any role), PUT + /api/site-config (admin; non-neg int or null). UI `SiteSettings`: live occupancy + FULL badge + (all), capacity editor (admin). +- VERIFIED: fill to cap=2 → 3rd transient refused (anomaly, no open); permit still admitted (occ 3/2, + free −1); exit frees a slot; routes RBAC (op can't set, −5→400, set/clear ok); verifyChain ok. + Full build 5/5. Physical FULL-sign relay output deferred. +- Updated [[capacity-occupancy]] (as-built).