420542ce10
The relay control password (relay_pw) was read by the driver but had NO form field, so Test connection sent it as 0 → the device ignored the probe → a controller showed "offline" even though it pinged. Add a "Relay control password" config field (secret; blank keeps the stored value). Because relayPassword is redacted from the client, the edit form can't resend it — so the test endpoint now re-merges the stored secret by device id (mirroring save). It is re-merged ONLY when the submitted config addresses the SAME device: matching driverId and every connection-identity field it sets (host/port/binaryPort/httpPort/serial). A redirected host/port or mismatched driver yields NO secret, so a probe can't exfiltrate the password to an attacker host (the booth operator is the threat-model adversary). testDevice() now passes the device id; setup-secrets.test.ts covers the identity guard. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
1212 lines
43 KiB
TypeScript
1212 lines
43 KiB
TypeScript
// Thin API client for the operator/admin UI.
|
||
//
|
||
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
|
||
// automatically (credentials: 'include'). For mutations we echo the readable
|
||
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
||
// wiki/entities/local-jwt-auth.md.
|
||
|
||
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[] };
|
||
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);
|
||
}
|
||
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[],
|
||
) {
|
||
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;
|
||
/** 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 }) });
|
||
}
|
||
|
||
/** 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}` : ""}`);
|
||
}
|
||
|
||
// --- Device setup ---------------------------------------------------------
|
||
|
||
export interface ConfigField {
|
||
key: string;
|
||
label: string;
|
||
type: "string" | "number" | "boolean" | "host" | "port" | "secret" | "select";
|
||
required: boolean;
|
||
default?: string | number | boolean;
|
||
options?: { value: string; label: string }[];
|
||
help?: string;
|
||
}
|
||
|
||
export interface CatalogEntry {
|
||
id: string;
|
||
label: string;
|
||
description: string;
|
||
transports: string[];
|
||
configFields: ConfigField[];
|
||
}
|
||
|
||
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||
/** Driver ids that support LAN discovery. */
|
||
discoverable: string[];
|
||
/** Driver ids that push to the backend (need a backend IP at assign time). */
|
||
pushCapable: string[];
|
||
};
|
||
|
||
export function fetchCatalog(): Promise<Catalog> {
|
||
return apiFetch<Catalog>("/api/setup/catalog");
|
||
}
|
||
|
||
export interface DiscoveredDevice {
|
||
id: string;
|
||
label: string;
|
||
config: Record<string, string | number | boolean>;
|
||
info?: Record<string, string>;
|
||
health: { status: string; detail?: string };
|
||
}
|
||
|
||
/** Scan the LAN for devices a driver can discover. Admin-only. */
|
||
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
|
||
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
|
||
`/api/setup/discover/${driverId}`,
|
||
);
|
||
return body.devices;
|
||
}
|
||
|
||
export type ConfigValue =
|
||
| string
|
||
| number
|
||
| boolean
|
||
| null
|
||
| ConfigValue[]
|
||
| { [k: string]: ConfigValue };
|
||
export type DeviceConfig = Record<string, ConfigValue>;
|
||
|
||
/** Direction a barrier/relay (or a device bound to it) serves. */
|
||
export type Direction = "entry" | "exit" | "both";
|
||
|
||
/** One relay on an access controller: which barrier it opens, in which direction,
|
||
* and (optionally) the input terminal its entry button is wired to. */
|
||
export interface RelaySpec {
|
||
relay: number;
|
||
direction: Direction;
|
||
/** Input terminal of the entry button that fires this relay (transient entry). */
|
||
button?: number;
|
||
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
|
||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
||
presenceInput?: number;
|
||
/** Sensor on the presence input: induction LOOP or a RADAR (label only). */
|
||
presenceKind?: "loop" | "radar";
|
||
/** The presence terminal is active-LOW (idles HIGH) — e.g. a radar wired opposite
|
||
* the button. Maps to the driver's per-input active-level override. */
|
||
presenceActiveLow?: boolean;
|
||
entryCooldownSec?: number;
|
||
}
|
||
|
||
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button light),
|
||
* driven by the radar input vs. the camera lane status. */
|
||
export interface ButtonLightSpec {
|
||
/** 1-based spare relay the lamp is on. */
|
||
relay: number;
|
||
/** 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 }),
|
||
});
|
||
}
|
||
|
||
// --- 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;
|
||
}
|
||
|
||
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
|
||
* Operator-raised, admin-authorized (authorizedBy + their password). */
|
||
export function recordCashVoucher(args: {
|
||
type: "cash_in" | "cash_out";
|
||
amountMinor: number;
|
||
reason: string;
|
||
authorizedBy: string;
|
||
authorizerPassword: string;
|
||
}): Promise<{
|
||
type: "cash_in" | "cash_out";
|
||
amountMinor: number;
|
||
voucherNo: string;
|
||
balanceMinor: number;
|
||
printed: boolean;
|
||
}> {
|
||
return apiFetch("/api/cash-voucher", {
|
||
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;
|
||
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). */
|
||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||
|
||
/** Validate + open the barrier for a session from the booth (when near the exit). */
|
||
export function boothExit(identity: string): Promise<BoothExitResult> {
|
||
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
|
||
}
|
||
|
||
/** 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 });
|
||
}
|