Files
parking_solution/apps/web/src/api.ts
T
julian 33c4ea1e91
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 37s
feat(entry): operator-issued entry + exit plate-swap reconciliation
Two halves of one anti-fraud design.

(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
  - PRESENCE-GATED like the physical button: a real car must be present (radar/
    loop AND camera busy). Enforced BOTH sides — the server re-checks current
    presence so a direct POST can't bypass a disabled button; no presence loop
    => feature unavailable; a no-presence attempt signs an anomaly.
  - FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
    companion entry.operatorIssued anomaly (the adversary path always leaves a
    red-flag row).
  - capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
    a legit car).
  New session:create permission (migration 0019 -> operator role, admin-
  revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
  issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
  factored into one shared #issueTicket (button + operator). UI: the entry
  BarrierLight becomes a clickable issue-control when presence+permission+shift
  meet (confirm -> issue).

(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
  - BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
    pay/exit modal shows a red warning + "Override & release" (override signs an
    attributed exit.plateSwapOverride). Flag+override, never a silent hard block
    (exit fails-open; a plate is never the sole gate).
  - READER path (no operator): log-only anomaly + fail-open.
  Extended BoothExitResult + /api/exit (override); boothExit client returns a
  structured swap result.

Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 12:17:52 +02:00

1395 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<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(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<T>;
}
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<string, unknown>,
) {
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<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" });
}
/** 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<SessionUser> {
return apiFetch<SessionUser>("/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<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;
}
}
// --- 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<UserProfile>,
): Promise<ManagedUser> {
return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) });
}
export function updateUser(
id: string,
body: { username?: string; roleId?: string } & Partial<UserProfile>,
): Promise<ManagedUser> {
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<ManagedRole> {
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
}
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> {
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<BackupStatus> {
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<BackupStatus> {
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<TargetCheck> {
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<BackupRunResult> {
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<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";
/** 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<TestResult> {
return apiFetch<TestResult>("/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<AnprTestResult> {
return apiFetch<AnprTestResult>("/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<PrintTestResult> {
return apiFetch<PrintTestResult>("/api/setup/test-print", {
method: "POST",
body: JSON.stringify({ driverId, config, id }),
});
}
// --- 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<ReportSummary> {
const qs = new URLSearchParams({ from, to, bucket }).toString();
return apiFetch<ReportSummary>(`/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<RecycleBin> {
return apiFetch<RecycleBin>("/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<void> {
return apiFetch<void>(`/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<AssignResult> {
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<AssignBody, "category" | "driverId">,
): Promise<AssignResult> {
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<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;
}
// 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<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) });
}
// --- 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<SimulateResult> {
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<SimSessionLoad> {
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<SubscriptionPlan> {
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<SubscriptionQuote> {
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
}
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
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<CaptureState> {
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<Subscription> {
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
export function revokeSubscription(id: string): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" });
}
export function deleteSubscription(id: string): Promise<void> {
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<ShiftStatus> {
return apiFetch("/api/shift/current");
}
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
return apiFetch("/api/shift/open", { method: "POST" });
}
export function closeShift(): Promise<ShiftReport> {
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<XReport | null> {
return (await apiFetch<XReport | undefined>("/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<Occupancy> {
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<SessionLookup> {
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<BoothExitResult> {
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 <img src>). */
export function snapshotImageUrl(id: string): string {
return `/api/snapshots/${encodeURIComponent(id)}`;
}
export function fetchSiteConfig(): Promise<SiteConfig> {
return apiFetch("/api/site-config");
}
/** PUT a partial config — only the fields supplied are changed. */
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
}
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
return saveSiteConfig({ capacity });
}