// 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(path: string, init: RequestInit = {}): Promise { 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; } 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 { return apiFetch("/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 { try { return await apiFetch("/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 & { /** 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 { return apiFetch("/api/setup/catalog"); } export interface DiscoveredDevice { id: string; label: string; config: Record; info?: Record; health: { status: string; detail?: string }; } /** Scan the LAN for devices a driver can discover. Admin-only. */ export async function discoverDevices(driverId: string): Promise { 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; /** 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 { return apiFetch("/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 { 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 { return apiFetch("/api/setup/state"); } /** Remove one assigned device instance by id. */ export function unassignDevice(id: string): Promise { 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 { return apiFetch("/api/tariff"); } /** Publish a new immutable tariff version (becomes the active rate card). */ export function publishTariffVersion(body: { currency: string; structure: TariffStructure; effectiveFrom?: string; }): Promise { 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 & { status?: Permit["status"]; }; export function fetchPermits(): Promise<{ permits: Permit[] }> { return apiFetch("/api/permits"); } export function createPermit(body: PermitInput): Promise { return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) }); } export function updatePermit(id: string, body: PermitInput): Promise { return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) }); } export function revokePermit(id: string): Promise { return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" }); } export function deletePermit(id: string): Promise { 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 { return apiFetch("/api/shift/current"); } export function openShift(): Promise<{ startedAt: string }> { return apiFetch("/api/shift/open", { method: "POST" }); } 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 }) }); }