1efa77bf56
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.
Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)
Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
(v1 events won't verify under v2 — intentional, gated per-event by keyId)
Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]
Web:
- wizard: no lane selector; add controllers (relay map + entry-button
terminal) first, then bind readers/cameras/printers to a controller relay
Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
// Thin API client for the operator/admin UI.
|
|
//
|
|
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
|
|
// automatically (credentials: 'include'). For mutations we echo the readable
|
|
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
|
// wiki/entities/local-jwt-auth.md.
|
|
|
|
const CSRF_COOKIE = "parking_csrf";
|
|
const CSRF_HEADER = "X-CSRF-Token";
|
|
|
|
function readCookie(name: string): string | null {
|
|
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
|
return m ? decodeURIComponent(m[1]!) : null;
|
|
}
|
|
|
|
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
|
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
const method = (init.method ?? "GET").toUpperCase();
|
|
const headers = new Headers(init.headers);
|
|
if (init.body && !headers.has("content-type")) {
|
|
headers.set("content-type", "application/json");
|
|
}
|
|
if (method !== "GET" && method !== "HEAD") {
|
|
const csrf = readCookie(CSRF_COOKIE);
|
|
if (csrf) headers.set(CSRF_HEADER, csrf);
|
|
}
|
|
const res = await fetch(path, { ...init, headers, credentials: "include" });
|
|
if (!res.ok) {
|
|
const msg = (await res.json().catch(() => ({}))) as { error?: string };
|
|
throw new ApiError(msg.error ?? `${path}: ${res.status}`, res.status);
|
|
}
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly status: number,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
// --- Auth -----------------------------------------------------------------
|
|
|
|
export type Role = "admin" | "operator" | "cashier" | "readonly";
|
|
export interface SessionUser {
|
|
id: string;
|
|
username: string;
|
|
role: Role;
|
|
}
|
|
|
|
export function login(username: string, password: string): Promise<SessionUser> {
|
|
return apiFetch<SessionUser>("/api/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
}
|
|
|
|
export function logout(): Promise<{ ok: boolean }> {
|
|
return apiFetch("/api/auth/logout", { method: "POST" });
|
|
}
|
|
|
|
/** Returns the current user, or null if not authenticated. */
|
|
export async function fetchMe(): Promise<SessionUser | null> {
|
|
try {
|
|
return await apiFetch<SessionUser>("/api/auth/me");
|
|
} catch (e) {
|
|
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// --- Device setup ---------------------------------------------------------
|
|
|
|
export interface ConfigField {
|
|
key: string;
|
|
label: string;
|
|
type: "string" | "number" | "boolean" | "host" | "port" | "secret" | "select";
|
|
required: boolean;
|
|
default?: string | number | boolean;
|
|
options?: { value: string; label: string }[];
|
|
help?: string;
|
|
}
|
|
|
|
export interface CatalogEntry {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
transports: string[];
|
|
configFields: ConfigField[];
|
|
}
|
|
|
|
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
|
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
|
/** Driver ids that support LAN discovery. */
|
|
discoverable: string[];
|
|
/** Driver ids that push to the backend (need a backend IP at assign time). */
|
|
pushCapable: string[];
|
|
};
|
|
|
|
export function fetchCatalog(): Promise<Catalog> {
|
|
return apiFetch<Catalog>("/api/setup/catalog");
|
|
}
|
|
|
|
export interface DiscoveredDevice {
|
|
id: string;
|
|
label: string;
|
|
config: Record<string, string | number | boolean>;
|
|
info?: Record<string, string>;
|
|
health: { status: string; detail?: string };
|
|
}
|
|
|
|
/** Scan the LAN for devices a driver can discover. Admin-only. */
|
|
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
|
|
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
|
|
`/api/setup/discover/${driverId}`,
|
|
);
|
|
return body.devices;
|
|
}
|
|
|
|
export type ConfigValue =
|
|
| string
|
|
| number
|
|
| boolean
|
|
| null
|
|
| ConfigValue[]
|
|
| { [k: string]: ConfigValue };
|
|
export type DeviceConfig = Record<string, ConfigValue>;
|
|
|
|
/** Direction a barrier/relay (or a device bound to it) serves. */
|
|
export type Direction = "entry" | "exit" | "both";
|
|
|
|
/** One relay on an access controller: which barrier it opens, in which direction,
|
|
* and (optionally) the input terminal its entry button is wired to. */
|
|
export interface RelaySpec {
|
|
relay: number;
|
|
direction: Direction;
|
|
/** Input terminal of the entry button that fires this relay (transient entry). */
|
|
button?: number;
|
|
}
|
|
|
|
export interface TestResult {
|
|
health: { status: string; detail?: string };
|
|
preconditions: {
|
|
ok: boolean;
|
|
issues: { key: string; message: string; fixable: boolean }[];
|
|
};
|
|
}
|
|
|
|
/** Test a device config (reachability + preconditions) without saving. */
|
|
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
|
|
return apiFetch<TestResult>("/api/setup/test", {
|
|
method: "POST",
|
|
body: JSON.stringify({ driverId, config }),
|
|
});
|
|
}
|
|
|
|
export interface BackendIpCandidate {
|
|
ip: string;
|
|
iface: string;
|
|
onDeviceSubnet: boolean;
|
|
}
|
|
|
|
/** Local IPs the device could push to (on-subnet first), for the wizard to
|
|
* pre-fill/override. Matters on multi-NIC hosts. */
|
|
export function fetchBackendIps(
|
|
host: string,
|
|
): Promise<{ candidates: BackendIpCandidate[]; port: number }> {
|
|
return apiFetch(`/api/setup/backend-ips?host=${encodeURIComponent(host)}`);
|
|
}
|
|
|
|
export interface AssignBody {
|
|
category: DeviceCategory;
|
|
driverId: string;
|
|
// Direction/binding lives in config: access → config.relays=[{relay,direction,button?}];
|
|
// reader/camera → config.controllerId + config.relay.
|
|
config: DeviceConfig;
|
|
/** Backend IP the device should push to (overrides auto-pick). */
|
|
backendIp?: string;
|
|
}
|
|
|
|
/** Save + configure the device (preconditions, push setup), then persist. */
|
|
export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
|
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
|
}
|
|
|
|
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
|
export interface Assignment {
|
|
id: string;
|
|
category: DeviceCategory;
|
|
driverId: string;
|
|
config: DeviceConfig;
|
|
enabled: boolean;
|
|
createdAt?: string;
|
|
}
|
|
|
|
/** Assign response = the saved assignment plus any residual-risk warnings
|
|
* (e.g. "string protocol could not be disabled — finish in the device web UI"). */
|
|
export interface AssignResult extends Assignment {
|
|
warnings?: string[];
|
|
}
|
|
|
|
export interface SetupState {
|
|
completedAt: string | null;
|
|
assignments: Assignment[];
|
|
}
|
|
|
|
/** Current setup status + all assigned device instances. */
|
|
export function fetchState(): Promise<SetupState> {
|
|
return apiFetch<SetupState>("/api/setup/state");
|
|
}
|
|
|
|
/** Remove one assigned device instance by id. */
|
|
export function unassignDevice(id: string): Promise<void> {
|
|
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// --- Tariff composer ------------------------------------------------------
|
|
|
|
export interface TariffBlock {
|
|
uptoMin: number | null;
|
|
priceMinorPerIncrement: number;
|
|
}
|
|
export interface TariffStructure {
|
|
gracePeriodEntryMin: number;
|
|
incrementMin: number;
|
|
blocks: TariffBlock[];
|
|
dailyCapMinor: number | null;
|
|
lostTicketMinor: number;
|
|
gracePeriodExitMin: number;
|
|
overstay: "reprice";
|
|
}
|
|
export interface TariffVersion {
|
|
id: string;
|
|
tariffId: string;
|
|
effectiveFrom: string;
|
|
currency: string;
|
|
structure: TariffStructure;
|
|
createdBy?: string | null;
|
|
createdAt?: string;
|
|
}
|
|
export interface TariffState {
|
|
tariffId: string;
|
|
active: TariffVersion | null;
|
|
versions: TariffVersion[];
|
|
}
|
|
|
|
export function fetchTariff(): Promise<TariffState> {
|
|
return apiFetch<TariffState>("/api/tariff");
|
|
}
|
|
|
|
/** Publish a new immutable tariff version (becomes the active rate card). */
|
|
export function publishTariffVersion(body: {
|
|
currency: string;
|
|
structure: TariffStructure;
|
|
effectiveFrom?: string;
|
|
}): Promise<TariffVersion> {
|
|
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
|
}
|
|
|
|
// --- Permits --------------------------------------------------------------
|
|
|
|
export interface PermitCredential {
|
|
kind: "rf" | "qr";
|
|
value: string;
|
|
}
|
|
export interface Permit {
|
|
id: string;
|
|
holderName: string | null;
|
|
contact: string | null;
|
|
maxConcurrent: number | null;
|
|
validFrom: string | null;
|
|
validTo: string | null;
|
|
status: "active" | "suspended" | "revoked";
|
|
credentials: PermitCredential[];
|
|
plates: string[];
|
|
}
|
|
export type PermitInput = Omit<Permit, "id" | "status"> & {
|
|
status?: Permit["status"];
|
|
};
|
|
|
|
export function fetchPermits(): Promise<{ permits: Permit[] }> {
|
|
return apiFetch("/api/permits");
|
|
}
|
|
export function createPermit(body: PermitInput): Promise<Permit> {
|
|
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
|
|
}
|
|
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
|
|
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
|
}
|
|
export function revokePermit(id: string): Promise<Permit> {
|
|
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" });
|
|
}
|
|
export function deletePermit(id: string): Promise<void> {
|
|
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// --- Shifts ---------------------------------------------------------------
|
|
|
|
export interface ShiftStatus {
|
|
operator: string;
|
|
open: { startedAt: string } | null;
|
|
}
|
|
export interface ShiftReport {
|
|
operator: string;
|
|
startedAt: string;
|
|
endedAt: string;
|
|
cashTotalMinor: number;
|
|
cardTotalMinor: number;
|
|
currency: string | null;
|
|
paymentCount: number;
|
|
printed: boolean;
|
|
}
|
|
|
|
export function fetchShift(): Promise<ShiftStatus> {
|
|
return apiFetch("/api/shift/current");
|
|
}
|
|
export function openShift(): Promise<{ startedAt: string }> {
|
|
return apiFetch("/api/shift/open", { method: "POST" });
|
|
}
|
|
export function closeShift(): Promise<ShiftReport> {
|
|
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<Occupancy> {
|
|
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 }) });
|
|
}
|