// 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. import { logFailedRequest } from "./lib/logger.js"; import { apiUrl } from "./lib/origin.js"; import type { AppLogRecord } from "@parking/shared"; 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(apiUrl(path), { ...init, headers, credentials: "include" }); if (!res.ok) { const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown }; const error = msg.error ?? `${path}: ${res.status}`; // Ship the failed request to the backend log store (best-effort, loop-safe — the // logger itself never logs the /api/logs call). 401s are normal pre-login churn, // so we don't report them as errors. See lib/logger.ts. if (res.status !== 401) { logFailedRequest({ path, method, status: res.status, error }); } throw new ApiError(error, res.status, msg.problems, msg); } if (res.status === 204) return undefined as T; return res.json() as Promise; } export class ApiError extends Error { constructor( message: string, readonly status: number, /** Field-level problems from a validation error (e.g. tariff publish), if any. */ readonly problems?: string[], /** The full parsed error body, for callers that need extra fields (e.g. a booth * exit's plate-swap detail: { status, plate, otherIdentity, otherEnteredAt }). */ readonly body?: Record, ) { super(message); } } // --- Auth ----------------------------------------------------------------- export type Lang = "sq" | "en"; export type Theme = "dark" | "light"; /** A `resource:action` permission string (the server is the source of truth for * the full grid; the role composer fetches it via /api/roles). */ export type Permission = string; export interface SessionUser { id: string; username: string; roleId: string; roleName: string; /** The permissions this user's role grants — the UI gates nav/routes on these. */ permissions: Permission[]; /** Preferred UI language (loaded from the server on login). */ language: Lang; /** Preferred UI theme (loaded from the server on login). */ theme: Theme; /** Preferred UI font scale, percent of base (100 = base; clamped 80–160). */ fontScale: number; /** Optional display name (profile metadata); null if unset. */ fullName: string | null; /** Optional contact email (profile metadata); null if unset. */ email: string | null; } /** Does this session grant the permission? Central authz check for the SPA. */ export function can(user: SessionUser | null, perm: Permission): boolean { return !!user && user.permissions.includes(perm); } 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" }); } /** Persist the current user's UI language preference (restored on next login). */ export function setLanguagePref(language: Lang): Promise<{ language: Lang }> { return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) }); } /** Persist the current user's UI theme preference (restored on next login). */ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> { return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) }); } /** Allowed font-scale band (percent of base) + step. The header control clamps to these. */ export const FONT_SCALE_MIN = 80; export const FONT_SCALE_MAX = 160; export const FONT_SCALE_STEP = 10; /** Persist the current user's UI font scale (percent; restored on next login). */ export function setFontScalePref(fontScale: number): Promise<{ fontScale: number }> { return apiFetch("/api/auth/font-scale", { method: "PUT", body: JSON.stringify({ fontScale }) }); } /** Edit MY own profile (display name / email). Returns the refreshed session. * Self-service — touches only the signed-in user; no `user:*` permission needed. */ export function updateMyProfile(patch: { fullName?: string | null; email?: string | null; }): Promise { return apiFetch("/api/auth/profile", { method: "PUT", body: JSON.stringify(patch), }); } /** Change MY own password — proves the current one first (server enforces). */ export function changeMyPassword( currentPassword: string, newPassword: string, ): Promise<{ ok: boolean }> { return apiFetch("/api/auth/password", { method: "PUT", body: JSON.stringify({ currentPassword, newPassword }), }); } /** 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; } } // --- User & role management (RBAC) ---------------------------------------- /** Optional profile metadata on a managed user (all nullable). */ export interface UserProfile { fullName: string | null; phone: string | null; email: string | null; address: string | null; } export interface ManagedUser extends UserProfile { id: string; username: string; roleId: string; roleName: string; language: Lang; createdAt: string; } export interface ManagedRole { id: string; name: string; builtin: boolean; permissions: Permission[]; userCount: number; } export function fetchUsers(): Promise<{ users: ManagedUser[] }> { return apiFetch("/api/users"); } export function createUser( body: { username: string; password: string; roleId: string } & Partial, ): Promise { return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) }); } export function updateUser( id: string, body: { username?: string; roleId?: string } & Partial, ): Promise { return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) }); } export function resetUserPassword(id: string, password: string): Promise<{ ok: boolean }> { return apiFetch(`/api/users/${id}/password`, { method: "PUT", body: JSON.stringify({ password }) }); } export function deleteUser(id: string): Promise<{ ok: boolean }> { return apiFetch(`/api/users/${id}`, { method: "DELETE" }); } /** Roles + the full permission catalog (for the composer checkbox grid). */ export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> { return apiFetch("/api/roles"); } export function createRole(body: { name: string; permissions: Permission[] }): Promise { return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) }); } export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise { return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) }); } export function deleteRole(id: string): Promise<{ ok: boolean }> { return apiFetch(`/api/roles/${id}`, { method: "DELETE" }); } // --- Application logs (app_logs) ------------------------------------------ /** Read recent diagnostic logs (gated server-side by log:read). */ export function fetchLogs(params: { limit?: number; level?: string; source?: string; since?: string; } = {}): Promise<{ logs: AppLogRecord[] }> { const q = new URLSearchParams(); if (params.limit) q.set("limit", String(params.limit)); if (params.level) q.set("level", params.level); if (params.source) q.set("source", params.source); if (params.since) q.set("since", params.since); const qs = q.toString(); return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`); } // --- Backup --------------------------------------------------------------- // On-site encrypted DB backup. See wiki/concepts/backup-recovery.md. export interface BackupStatus { configured: boolean; /** Admin-chosen target directory (null = not set). */ targetDir: string | null; /** Admin-tuned retention (resolved value: DB or code default). */ keepLast: number; keepDailyDays: number; /** Whether the env encryption key is present (a missing key is flagged distinctly). */ keyPresent: boolean; running: boolean; lastSuccessAt: string | null; lastResult: { path: string; bytes: number; prunedFiles: number } | null; lastErrorAt: string | null; lastError: string | null; } export async function fetchBackupStatus(): Promise { return apiFetch("/api/backup/status"); } export interface BackupConfigPatch { /** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */ targetDir?: string | null; keepLast?: number | null; keepDailyDays?: number | null; } /** Update backup config (target dir and/or retention). Returns the new status. */ export async function setBackupConfig(patch: BackupConfigPatch): Promise { return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify(patch) }); } export interface TargetCheck { ok: boolean; /** "empty" | "missing" | "not_a_dir" | "not_writable" when !ok. */ reason?: string; } /** Probe a candidate target path server-side (exists / is a dir / is writable). */ export async function testBackupTarget(targetDir: string): Promise { return apiFetch("/api/backup/test", { method: "POST", body: JSON.stringify({ targetDir }) }); } export interface BackupRunResult { ok: true; path: string; bytes: number; prunedFiles: number; } /** Trigger a manual "back up now". Throws on 409 (not configured) / 500 (run failed). */ export async function runBackup(): Promise { return apiFetch("/api/backup/run", { method: "POST" }); } // --- 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"; /** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a * non-barrier alert lamp (blink while its trigger input is active, SOLID once the camera * confirms a car). The action is implied by the event. */ export type RelayEvent = Direction | "radarAlert"; /** What a controller input terminal means: a transient-entry `button`, a one-car-one-ticket * `presence` sensor (loop/radar), or an `alertTrigger` for a radarAlert lamp. */ export type InputRole = "button" | "presence" | "alertTrigger"; /** One INPUT terminal the host reads (the twin of RelaySpec). An exit radar is just another * `presence` row serving the exit relay. */ export interface InputSpec { input: number; role: InputRole; /** The barrier relay this input serves (required for button/presence; optional for * alertTrigger). */ relay?: number; /** presence only — induction LOOP or RADAR (label only). */ kind?: "loop" | "radar"; /** This terminal idles HIGH / is active-LOW (e.g. a radar wired opposite the button). */ activeLow?: boolean; /** button only — presence-less fallback cooldown (seconds). */ cooldownSec?: number; } /** One relay on an access controller: the event it reacts to. Input wiring lives in * `config.inputs[]`; the legacy per-relay button/presence fields are still read for * back-compat but no longer written. */ export interface RelaySpec { relay: number; /** The event this relay reacts to (UI label: "Event"). */ direction: RelayEvent; // ── legacy input fields (read-only back-compat; superseded by config.inputs[]) ── button?: number; presenceInput?: number; presenceKind?: "loop" | "radar"; presenceActiveLow?: boolean; entryCooldownSec?: number; // ── radarAlert-only ── /** Input terminal whose active edge starts the blink (the radar). */ triggerInput?: number; /** Which lane's camera locks this lamp SOLID (default entry). An exit radar locks on exit. */ lockLane?: "entry" | "exit"; /** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */ blinkOnMs?: number; blinkOffMs?: 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. Pass the * device `id` when editing an existing one so the server re-merges its stored * machine secrets (e.g. the relay password redacted from the client). */ export function testDevice(driverId: string, config: DeviceConfig, id?: string): Promise { return apiFetch("/api/setup/test", { method: "POST", body: JSON.stringify({ driverId, config, ...(id ? { id } : {}) }), }); } /** Result of an end-to-end ANPR probe on a camera: snapshot → vision analyze. */ export type AnprTestResult = | { ok: true; plate: string; confidence: number; region: string | null; lowConfidence: boolean; modelVersion: string; tookMs: number; } | { ok: false; /** vision-disabled | snapshot-failed | no-plate */ reason: string; detail?: string; tookMs?: number; }; /** Take a live snapshot off the camera and run ANPR on it — without saving. Reports * whether a plate was extracted, the read, and how long it took. */ export function testAnpr(driverId: string, config: DeviceConfig): Promise { return apiFetch("/api/setup/test-anpr", { method: "POST", body: JSON.stringify({ driverId, config }), }); } /** Result of a physical print test: a real test slip is pushed to the printer. */ export type PrintTestResult = | { ok: true; tookMs: number } | { ok: false; reason: string; detail?: string; tookMs?: number }; /** Print a real test slip on the printer — without saving. Confirms the printer * actually feeds paper + fires the head (healthCheck only opens the transport). */ export function testPrint( driverId: string, config: DeviceConfig, id?: string, ): Promise { return apiFetch("/api/setup/test-print", { method: "POST", body: JSON.stringify({ driverId, config, id }), }); } export type RelayTestResult = | { ok: true; firedAt: string; tookMs: number } | { ok: false; reason: string; detail?: string; tookMs?: number }; /** Pulse a SAVED controller's barrier relay to test the wiring — physically opens the * barrier. The server signs a `barrier_open_command` (reason setup.relayTest) before * firing, so the open is explained, not a reconciliation anomaly. Saved controller only * (needs a persisted id for attribution). */ export function testRelay(id: string, relay: number): Promise { return apiFetch("/api/setup/test-relay", { method: "POST", body: JSON.stringify({ id, relay }), }); } // --- Admin reports ------------------------------------------------------- export type ReportBucket = "hour" | "day" | "month"; export interface ReportSeriesPoint { bucket: string; entries: number; exits: number; revenueMinor: number; payments: number; } export interface ReportTotals { entries: number; exits: number; payments: number; revenueMinor: number; cashMinor: number; cardMinor: number; ticketMinor: number; subscriptionSalesMinor: number; subscriptionWindowMinor: number; closedSessions: number; totalParkedMinutes: number; avgParkedMinutes: number; medianParkedMinutes: number; } export interface ReportSubscriptionStats { active: number; suspended: number; revoked: number; currentlyValid: number; coveredCars: number; } export interface ReportSummary { from: string; to: string; bucket: ReportBucket; tz: string; currency: string | null; totals: ReportTotals; series: ReportSeriesPoint[]; entriesByHour: number[]; subscriptions: ReportSubscriptionStats; } /** The whole admin dashboard (totals + series + peak-hours + subscriptions) for a range. */ export function fetchReport(from: string, to: string, bucket: ReportBucket): Promise { const qs = new URLSearchParams({ from, to, bucket }).toString(); return apiFetch(`/api/reports/summary?${qs}`); } /** URL for the CSV export of the per-bucket series (opened/downloaded directly; the * auth cookie rides along same-origin). */ export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): string { const qs = new URLSearchParams({ from, to, bucket }).toString(); return apiUrl(`/api/reports/summary.csv?${qs}`); } // --- Recycle bin (soft-deleted master data) ------------------------------ export type RecycleKind = "user" | "role" | "subscription" | "plan" | "tariff"; export interface RecycleBinItem { kind: RecycleKind; id: string; label: string; deletedAt: string; deletedBy: string | null; } export interface RecycleBin { items: RecycleBinItem[]; retentionDays: number; } /** Everything currently in the recycle bin + the retention window (days). */ export function fetchRecycleBin(): Promise { return apiFetch("/api/recycle-bin"); } /** Restore a soft-deleted item (back to its catalog). 409 if a live row would collide. */ export function restoreRecycleItem(kind: RecycleKind, id: string): Promise<{ restored: boolean }> { return apiFetch<{ restored: boolean }>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}/restore`, { method: "POST", }); } /** Permanently purge a soft-deleted item. Irreversible. */ export function purgeRecycleItem(kind: RecycleKind, id: string): Promise { return apiFetch(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}`, { method: "DELETE" }); } 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) }); } /** Re-configure an existing device in place, keeping its id (and so its push * URL). Category/driver are fixed at create time, so only config changes. */ export function editDevice( id: string, body: Omit, ): Promise { return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", 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; } // Mirrors @parking/shared. Two shapes: V1 (bare ladder) and V2 (default + windowed // cards by time-of-day / dow / date / category, flat or laddered). The discriminant // is the presence of `defaultCard`. See wiki/concepts/tariff-time-tiers.md. export interface TariffStructureV1 { gracePeriodEntryMin: number; incrementMin: number; blocks: TariffBlock[]; /** STEPPED ("up-to") total-by-duration table; when non-empty it replaces `blocks`. */ steps?: TariffStep[]; dailyCapMinor: number | null; lostTicketMinor: number; gracePeriodExitMin: number; overstay: "reprice"; } export interface TariffWindow { dow?: number[]; dateFrom?: string; dateTo?: string; fromHour?: string; toHour?: string; } export interface TariffCard { name: string; priority: number; category?: string; window?: TariffWindow; flatMinor?: number; blocks?: TariffBlock[]; /** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */ steps?: TariffStep[]; dailyCapMinor?: number | null; } /** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including * `uptoMin` minutes (cumulative, not marginal). Mirrors @parking/shared TariffStep. */ export interface TariffStep { uptoMin: number; totalMinor: number; } export interface TariffStructureV2 { version: 2; tz: string; gracePeriodEntryMin: number; incrementMin: number; lostTicketMinor: number; gracePeriodExitMin: number; overstay: "reprice"; defaultCard: TariffCard; windowedCards?: TariffCard[]; } export type TariffStructure = TariffStructureV1 | TariffStructureV2; /** True when a structure is the windowed V2 shape (mirrors @parking/shared isTariffV2). */ export function isTariffV2(t: TariffStructure): t is TariffStructureV2 { return (t as TariffStructureV2).defaultCard != null; } 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) }); } // --- Tariff Lab (simulator) ----------------------------------------------- export interface SimPayment { paidAt: string; graceExitMin: number | null; } export interface SimSessionPricing { periodStart: string; amountMinor: number; overstay: boolean; withinGrace: boolean; graceExpiresAt: string | null; } export interface SimulateResult { currency: string | null; pricing: SimSessionPricing; curve: { minutes: number; amountMinor: number }[]; gracePeriodExitMin: number; } export interface SimulateBody { enteredAt: string; asOf: string; payments?: SimPayment[]; category?: string; tariffVersionId?: string; structure?: TariffStructure; currency?: string; } /** Price a hypothetical session — pure, no ledger write. See Tariff Lab. */ export function simulateTariff(body: SimulateBody): Promise { return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) }); } export interface SimSessionLoad { identity: string; enteredAt: string; exitedAt: string | null; payments: SimPayment[]; category: string | null; tariffVersionId: string | null; } /** Prefill the lab from a real ledger session. */ export function loadSimSession(identity: string): Promise { return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`); } // --- Subscriptions -------------------------------------------------------- export interface SubscriptionCredential { kind: "rf" | "qr"; value: string; } export type SubscriptionPeriod = "day" | "week" | "month"; /** A subscriber's allowed parking window (minutes-from-local-midnight) on selected days. * A scan outside the window is charged the transient tariff for the gap. days: 0=Sun..6=Sat * (empty = every day); the window [fromMin,toMin) wraps past midnight when toMin ≤ fromMin. */ export interface PlanTimeframes { days?: number[]; fromMin: number; toMin: number; graceMin?: number; tz?: string; } /** A subscription PLAN version — admin-composed, versioned config the operator sells * from (so they never type a price). */ export interface SubscriptionPlan { id: string; planId: string; name: string; period: SubscriptionPeriod; pricePerPeriodMinor: number; currency: string; effectiveFrom: string; active: boolean; /** Allowed-time windows (tariff bridge); null/absent = 24/7, no time charge. */ timeframes?: PlanTimeframes | null; } export interface Subscription { id: string; holderName: string | null; contact: string | null; /** Price billed for the window in minor units — DERIVED from the plan. null = comp. */ priceMinor: number | null; period: SubscriptionPeriod; currency: string | null; /** Which plan + immutable version priced this sale (null for legacy/comp). */ planId: string | null; planVersionId: string | null; /** Cars covered by this one subscription (price was ×N). Default 1. */ quantity: number; maxConcurrent: number | null; validFrom: string | null; validTo: string | null; status: "active" | "suspended" | "revoked"; credentials: SubscriptionCredential[]; plates: string[]; } /** A credential as SENT to the server: a QR value may be omitted/blank → the server * auto-generates an unguessable code. RF must carry the card id. */ export interface SubscriptionCredentialInput { kind: "rf" | "qr"; value?: string; } export type SubscriptionInput = { holderName: string | null; contact: string | null; /** PRICED SALE: the plan selected. Price is looked up server-side (never typed). * Omit for a comp subscription. */ planId?: string | null; /** Coverage window. Priced sale: validFrom defaults to now, validTo required. */ validFrom: string | null; validTo: string | null; /** Cars covered (price ×N). Default 1. */ quantity?: number; maxConcurrent: number | null; status?: Subscription["status"]; /** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a * plan is sold (the sale appends a signed payment); ignored on update. */ tender?: "cash" | "card"; credentials: SubscriptionCredentialInput[]; plates: string[]; /** UPDATE-only correction: move the sub to a different VERSION of its SAME plan. Price * stays frozen; only the access rules change going forward. Requires subscription:plan. */ planVersionId?: string; }; /** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */ export interface SubscriptionQuote { periods: number; amountMinor: number; currency: string; period: SubscriptionPeriod; quantity?: number; plan: SubscriptionPlan; } /** The create response = the saved subscription + the auto-print outcome, plus the * recorded SALE (the signed payment) when a price was collected. */ export type SubscriptionCreated = Subscription & { printed: boolean; printedBy?: string; printError?: string; /** Present when a priced subscription was sold: the signed payment just appended. */ sale?: { amountMinor: number; currency: string | null; tender: "cash" | "card"; periods: number; inShift: boolean; }; }; export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> { return apiFetch("/api/subscriptions"); } // --- Subscription plan catalog (admin-composed; operator sells from it) ------ export function fetchSubscriptionPlans(all = false): Promise<{ plans: SubscriptionPlan[] }> { return apiFetch(`/api/subscription-plans${all ? "?all=1" : ""}`); } export function createSubscriptionPlan(body: { planId?: string; name: string; period: SubscriptionPeriod; pricePerPeriodMinor: number; currency: string; effectiveFrom?: string; timeframes?: PlanTimeframes | null; }): Promise { return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) }); } export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> { return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" }); } export function reactivateSubscriptionPlan(planId: string): Promise<{ planId: string; reactivated: boolean }> { return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/reactivate`, { method: "POST" }); } /** Delete a plan (all versions). Rejects (409 plan_in_use) if any subscription uses it. */ export function deleteSubscriptionPlan(planId: string): Promise<{ planId: string; deleted: boolean }> { return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}`, { method: "DELETE" }); } /** Live quote for the sell form (server-computed; the operator can't override it). */ export function quoteSubscription(body: { planId: string; validFrom: string | null; validTo: string; quantity?: number; }): Promise { return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) }); } export function createSubscription(body: SubscriptionInput): Promise { return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) }); } /** Re-print a subscription's QR card (failed auto-print / lost card). */ export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> { return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" }); } // --- Credential capture ("enroll a card" on a chosen reader) --------------- export interface ReaderInfo { id: string; driverId: string; direction: "entry" | "exit" | "both"; } export type CaptureState = | { status: "idle" } | { status: "armed"; deviceId: string; armedAt: number; expiresAt: number } | { status: "captured"; deviceId: string; value: string; capturedAt: number } | { status: "expired"; deviceId: string }; export function fetchReaders(): Promise<{ readers: ReaderInfo[] }> { return apiFetch("/api/subscriptions/readers"); } export function armCapture(deviceId: string): Promise<{ expiresAt: number }> { return apiFetch("/api/subscriptions/capture/arm", { method: "POST", body: JSON.stringify({ deviceId }) }); } export function pollCapture(): Promise { return apiFetch("/api/subscriptions/capture"); } export function cancelCapture(): Promise<{ ok: boolean }> { return apiFetch("/api/subscriptions/capture/cancel", { method: "POST" }); } export function updateSubscription(id: string, body: SubscriptionInput): Promise { return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) }); } export function revokeSubscription(id: string): Promise { return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" }); } export function deleteSubscription(id: string): Promise { return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" }); } // --- Shifts --------------------------------------------------------------- export interface ShiftStatus { /** The requesting (logged-in) operator. */ operator: string; /** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */ open: { startedAt: string; operator: string | null } | null; /** True iff the open shift belongs to the requesting operator (can close it). */ isMine: boolean; /** Live physical drawer balance (cash payments + cash movements). */ drawerMinor: number; currency: string | null; } /** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales + * out-of-window charges). Cash+card combined; the per-tender totals stay separate for * the drawer. Shared by the X-report, the close Z-report, and the history summary. */ export interface ShiftSourceSplit { ticketTotalMinor: number; subscriptionTotalMinor: number; subscriptionSalesMinor: number; subscriptionWindowMinor: number; } export interface ShiftReport extends ShiftSourceSplit { operator: string; startedAt: string; endedAt: string; cashTotalMinor: number; cardTotalMinor: number; currency: string | null; paymentCount: number; // Drawer (carries across shifts). openingFloatMinor: number; cashAddedMinor: number; cashRemovedMinor: number; expectedDrawerMinor: number; printed: boolean; } export function fetchShift(): Promise { return apiFetch("/api/shift/current"); } export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> { return apiFetch("/api/shift/open", { method: "POST" }); } export function closeShift(): Promise { return apiFetch("/api/shift/close", { method: "POST" }); } /** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no * event is appended). Same figures the Z-report will print at close. `asOf` is the * snapshot instant. */ export interface XReport extends ShiftSourceSplit { operator: string; startedAt: string; endedAt: string; // = asOf asOf: string; cashTotalMinor: number; cardTotalMinor: number; currency: string | null; paymentCount: number; openingFloatMinor: number; cashAddedMinor: number; cashRemovedMinor: number; expectedDrawerMinor: number; } /** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */ export async function fetchShiftReport(): Promise { return (await apiFetch("/api/shift/report")) ?? null; } // --- Drawer cash movements (operator records, admin reviews) --------------------- // Redesigned 2026-07-01: an operator RECORDS a receipt/disbursement freely; an admin // REVIEWS it after the fact (authorize/deny — a flag, never a cash reversal). See // wiki/concepts/shift.md. export type MovementStatus = "pending" | "authorized" | "denied"; /** A drawer movement with its admin-review status. */ export interface DrawerMovement { id: string; type: "cash_in" | "cash_out"; /** Positive magnitude; direction is the type. */ amountMinor: number; currency: string | null; reason: string | null; operator: string; voucherNo: string | null; at: string; status: MovementStatus; reviewedBy: string | null; reviewNote: string | null; reviewedAt: string | null; } /** Operator RECORDS a drawer movement — cash_in (Mandat Arkëtimi / pay-IN) or cash_out * (Mandat Pagese / pay-OUT). Direction is the TYPE; amountMinor a positive magnitude. * No admin sign-off at creation — it's reviewed afterward. */ export function recordDrawerMovement(args: { type: "cash_in" | "cash_out"; amountMinor: number; reason: string; currency?: string; }): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean; }> { return apiFetch("/api/drawer/movement", { method: "POST", body: JSON.stringify(args) }); } /** List drawer movements + review status. Operators get their OWN; a reviewer gets all * and may filter by status (the pending review queue). */ export function fetchDrawerMovements(status?: MovementStatus): Promise<{ movements: DrawerMovement[]; scope: "all" | "self"; }> { const qs = status ? `?status=${encodeURIComponent(status)}` : ""; return apiFetch(`/api/drawer/movements${qs}`); } /** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */ export function reviewDrawerMovement(args: { refId: string; decision: "authorize" | "deny"; note?: string; }): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> { return apiFetch("/api/drawer/review", { method: "POST", body: JSON.stringify(args) }); } /** A completed shift (reconstructed from its signed Z-report). */ export interface ShiftSummary extends ShiftSourceSplit { id: string; index: number; operator: string; startedAt: string; endedAt: string; cashTotalMinor: number; cardTotalMinor: number; currency: string | null; paymentCount: number; openingFloatMinor: number; cashAddedMinor: number; cashRemovedMinor: number; expectedDrawerMinor: number; } /** Completed shift history. The server scopes by permission: operators get their * own shifts only (filter args ignored); admins (shift:cash) get all, optionally * filtered by operator + a from/to window over the shift start. `scope` echoes * which the server applied, so the UI can show/hide the filter. */ export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{ shifts: ShiftSummary[]; scope: "all" | "self"; }> { const qs = new URLSearchParams(); if (params.operator) qs.set("operator", params.operator); if (params.from) qs.set("from", params.from); if (params.to) qs.set("to", params.to); const q = qs.toString(); return apiFetch(`/api/shifts${q ? `?${q}` : ""}`); } // --- Site config / occupancy ---------------------------------------------- export interface Occupancy { count: number; capacity: number | null; free: number | null; full: boolean; } /** Capacity + optional park metadata (all nullable). Mirrors site_config. */ export interface SiteConfig { capacity: number | null; /** Default for the booth "print exit ticket" checkbox (booth-geography knob). */ exitVoucherDefault: boolean; /** Site default monthly subscription price (minor units); pre-fills the form. */ subscriptionMonthlyPriceMinor: number | null; /** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */ reserveSubscriberSpots: boolean; /** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */ anprEntryEnabled: boolean; parkName: string | null; operatorName: string | null; /** NIUS — Albanian tax/identification number. */ nius: string | null; address: string | null; phone: string | null; email: string | null; /** IANA timezone for tariff wall-clock windows (e.g. "Europe/Tirane"). Copied into * each published tariff version so its windows are frozen. */ timezone: string | null; /** Default vehicle/customer category frozen onto each transient entry (V2 pricing). */ defaultVehicleCategory: string | null; } export function fetchOccupancy(): Promise { return apiFetch("/api/occupancy"); } // --- Device status (the booth footer) ------------------------------------- /** Live status of one configured device — mirrors the server's DeviceStatusEvent. * Every enabled device is polled (printers via rich readStatus, the rest via * healthCheck) and flattened to one traffic-light. Pushed over the WS; the REST * snapshot below is the initial load / fallback. */ export interface DeviceStatus { deviceId: string; driverId: string; category: "access" | "reader" | "camera" | "printer" | "vision"; /** Role/direction token for the footer label (NOT the vendor) — the client * localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */ roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null; state: "ready" | "degraded" | "offline"; detail?: string; checkedAt: string; } export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> { return apiFetch("/api/devices/status"); } // --- Ledger events (the signed audit trail; read-only) -------------------- /** A persisted ledger row. Re-exported from shared so UI code has one source of * truth for the event shape (the same type the WS pushes). */ export type { LedgerEvent, LogLevel, LogSource } from "@parking/shared"; export type { AppLogRecord }; /** Recent ledger events, newest first (default 100, max 1000). Used for the * booth feed's initial load; live updates then arrive over the WS. `since` (ISO) * scopes to events at/after that instant — the booth passes the current shift's * start so the feed shows ONLY this shift's activity. */ export function fetchEvents( limit = 100, since?: string, until?: string, ): Promise<{ events: import("@parking/shared").LedgerEvent[] }> { const qs = new URLSearchParams({ limit: String(limit) }); if (since) qs.set("since", since); if (until) qs.set("until", until); return apiFetch(`/api/events?${qs.toString()}`); } // --- Booth: session lookup, payment, exit --------------------------------- /** One-read session view for the booth pay/exit modal (mirrors server SessionLookup). */ export interface SessionLookup { identity: string; found: boolean; open: boolean; enteredAt: string | null; exitedAt: string | null; paidAt: string | null; amountMinor: number | null; currency: string | null; /** Amount actually PAID (sum of payment events), independent of what's owed now. */ paidMinor: number | null; paidCurrency: string | null; withinGrace: boolean; graceExpiresAt: string | null; /** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began; * owes a fresh top-up (amountMinor); cannot exit for free. */ overstay: boolean; /** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */ subscription: boolean; subscriptionId: string | null; subscriptionHolder: string | null; /** Advisory licence plate recognized for this session (ANPR). Null when none. */ plate: string | null; } /** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */ export function lookupSession(identity: string): Promise { return apiFetch(`/api/session/${encodeURIComponent(identity)}`); } /** One row in the booth Active Sessions list (mirrors server ActiveSession). */ export interface ActiveSession { identity: string; source: string | null; enteredAt: string; exitedAt: string | null; open: boolean; paidAt: string | null; amountMinor: number | null; currency: string | null; withinGrace: boolean; graceExpiresAt: string | null; /** OVERSTAY: paid transient whose walk-back grace lapsed with no signed exit — a new * period began (re-parked) or the car is faulty/abandoned. Owes a fresh top-up; * flagged so the operator reconciles, never a free exit. */ overstay: boolean; /** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */ subscription: boolean; subscriptionId: string | null; subscriptionHolder: string | null; /** Advisory licence plate recognized for this session (ANPR). Null when none. */ plate: string | null; } /** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */ export function fetchActiveSessions(): Promise<{ sessions: ActiveSession[] }> { return apiFetch("/api/sessions/active"); } /** Human-intervention barrier re-open for a paid active session (damaged ticket / * phantom re-close). Signs an audited anomaly; never a 2nd exit. */ export function reopenBarrier(identity: string): Promise<{ ok: true; opened: boolean; reason?: string }> { return apiFetch("/api/barrier/reopen", { method: "POST", body: JSON.stringify({ identity }) }); } /** Take payment for a session → signed payment event. `overrideMinor` sets an * operator amount (lost ticket / dispute). */ export function paySession( identity: string, tender: "cash" | "card", overrideMinor?: number, ): Promise<{ amountMinor: number; currency: string }> { return apiFetch("/api/pay", { method: "POST", body: JSON.stringify({ identity, tender, ...(overrideMinor != null ? { overrideMinor } : {}) }), }); } /** Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event with * the operator + a required reason; the entry itself is never edited (append-only). * Refuses a subscription / already-exited / already-voided / paid ticket (409). */ export function voidTicket(identity: string, reason: string): Promise<{ ok: boolean; identity?: string }> { return apiFetch("/api/tickets/void", { method: "POST", body: JSON.stringify({ identity, reason }) }); } /** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't * open (payment stands; operator opens manually). `swapSuspected` = the exiting car's * plate is already inside under a DIFFERENT ticket (possible ticket-swap); the operator * must review and re-call with override:true to release. See plate-reconciliation.md. */ export type BoothExitResult = | { ok: true; opened: boolean; reason?: string } | { ok: false; swapSuspected: true; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }; /** Validate + open the barrier for a session from the booth (when near the exit). * Pass override:true to consciously release a suspected plate-swap exit. */ export async function boothExit(identity: string, override = false): Promise { try { return await apiFetch<{ ok: true; opened: boolean; reason?: string }>("/api/exit", { method: "POST", body: JSON.stringify({ identity, ...(override ? { override: true } : {}) }), }); } catch (e) { // A suspected plate-swap comes back 409 with status:"swap_suspected" + detail — surface // it as a structured result (not a thrown error) so the modal can warn + offer override. if (e instanceof ApiError && e.body?.status === "swap_suspected") { const b = e.body; return { ok: false, swapSuspected: true, reason: String(b.error ?? ""), plate: String(b.plate ?? ""), otherIdentity: String(b.otherIdentity ?? ""), otherEnteredAt: (b.otherEnteredAt as string | null) ?? null, }; } throw e; } } /** Operator issues an entry ticket when the physical button is broken. A FLAGGED mint, * server-gated on real vehicle presence (radar + camera). Returns the new ticket id. */ export function issueEntryTicket(): Promise<{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean }> { return apiFetch("/api/entry/issue", { method: "POST", body: JSON.stringify({}) }); } /** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail, * for self-exit at a distant exit. Requires the session to be paid. */ export function printVoucher(identity: string): Promise<{ ok: boolean; printedBy: string }> { return apiFetch("/api/voucher", { method: "POST", body: JSON.stringify({ identity }) }); } /** Print a standalone PAYMENT RECEIPT (entry/paid/duration/amount, no barcode). * Auto-printed after a payment when no voucher is issued; also the "reprint" * action. Requires the session to be paid. */ export function printReceipt(identity: string): Promise<{ ok: boolean; printedBy: string }> { return apiFetch("/api/receipt", { method: "POST", body: JSON.stringify({ identity }) }); } // --- Snapshots (entry/exit evidence images) ------------------------------- export interface SnapshotMeta { id: string; direction: "entry" | "exit" | null; deviceId: string; identity: string; contentType: string; capturedAt: string; } /** A capture that was ATTEMPTED but failed (camera offline, config) — surfaced so a * missing image isn't a silent gap. From snapshot telemetry, not the image store. */ export interface SnapshotFailure { direction: "entry" | "exit" | null; deviceId: string; error: string; occurredAt: string; } /** A licence plate recognized for this session by the ANPR-on-snapshot path (advisory * record — see opencv-anpr-service.md). `snapshotId` links to the image it was read from. */ export interface PlateRead { plate: string; confidence: number | null; region: string | null; direction: "entry" | "exit" | null; snapshotId: string | null; at: string; } /** Snapshot metadata for a session identity (newest first) PLUS failed capture * attempts PLUS any recognized plates. Image bytes are at `/api/snapshots/:id`. */ export function fetchSnapshots( identity: string, ): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[]; plates?: PlateRead[] }> { return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`); } /** URL for a snapshot's image bytes (cookie-authed; usable as ). */ export function snapshotImageUrl(id: string): string { return `/api/snapshots/${encodeURIComponent(id)}`; } export function fetchSiteConfig(): Promise { return apiFetch("/api/site-config"); } /** PUT a partial config — only the fields supplied are changed. */ export function saveSiteConfig(patch: Partial): Promise { return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) }); } export function setCapacity(capacity: number | null): Promise { return saveSiteConfig({ capacity }); }