/** * Minimal frontend-facing API client for the Lieferscheine backend. * * Goals: * - Centralize fetch defaults (credentials + no-store) to match backend caching rules. * - Provide a single, predictable error shape via ApiClientError. * - Offer thin domain helpers (login/logout/me/branches/...). * * Notes: * - JavaScript only (no TypeScript). We use JSDoc for "typed-by-convention". * - This client is intentionally small. It is not an SDK. */ /** * @typedef {Object} ApiErrorPayload * @property {{ message: string, code: string, details?: any }} error */ /** * A standardized client-side error type for API failures. * - status: HTTP status code (e.g. 401, 403, 404, 500) * - code: machine-readable error code (e.g. AUTH_UNAUTHENTICATED) * - message: human-readable message (safe to show in UI) * - details: optional structured payload (validation params etc.) */ export class ApiClientError extends Error { /** * @param {{ * status: number, * code: string, * message: string, * details?: any, * url?: string, * method?: string, * cause?: any * }} input */ constructor({ status, code, message, details, url, method, cause }) { // We attach `cause` to preserve error chains (supported in modern Node). super(message, cause ? { cause } : undefined); this.name = "ApiClientError"; this.status = status; this.code = code; // Only attach optional properties when provided (keeps error objects clean). if (details !== undefined) this.details = details; if (url) this.url = url; if (method) this.method = method; } } const DEFAULT_HEADERS = { Accept: "application/json", }; /** * Resolve a request URL. * - If `path` is absolute (http/https), return as-is. * - If `baseUrl` is provided, resolve relative to it. * - Otherwise return the relative path (browser-friendly: "/api/..."). * * Why this exists: * - The same client can be used: * - in the browser (relative "/api/..." calls) * - in Node scripts (absolute baseUrl like "http://127.0.0.1:3000") * * @param {string} path * @param {string=} baseUrl * @returns {string} */ function resolveUrl(path, baseUrl) { // If someone passes a full URL, keep it unchanged. if (/^https?:\/\//i.test(path)) return path; const base = (baseUrl || "").trim(); // Browser usage: baseUrl omitted -> use relative path. if (!base) return path; // Ensure baseUrl ends with a slash so URL() resolves correctly. return new URL(path, base.endsWith("/") ? base : `${base}/`).toString(); } /** * Best-effort detection if response is JSON. * * Why we need this: * - Our API is intended to always respond with JSON. * - But in error cases (misconfig, reverse proxy, 404 HTML), we might receive non-JSON. * - We want robust parsing behavior and a predictable client-side error. * * @param {Response} response * @returns {boolean} */ function isJsonResponse(response) { const ct = response.headers.get("content-type") || ""; return ct.toLowerCase().includes("application/json"); } /** * Core fetch helper with: * - credentials: "include" (cookie-based session) * - cache: "no-store" (match backend freshness strategy) * - standardized error mapping into ApiClientError * * Clean code rule: * - UI code should NOT call fetch directly. * - Instead, it should call domain helpers that route through apiFetch(). * * @param {string} path * @param {{ * method?: string, * headers?: Record, * body?: any, * baseUrl?: string, * fetchImpl?: typeof fetch * }=} options * @returns {Promise} parsed JSON payload (or null for empty responses) */ export async function apiFetch(path, options = {}) { const { method = "GET", headers = {}, body, baseUrl, fetchImpl = fetch, } = options; const url = resolveUrl(path, baseUrl); // Build request init. We always set credentials + no-store. // For JSON bodies, we serialize and set Content-Type. const init = { method, credentials: "include", cache: "no-store", headers: { ...DEFAULT_HEADERS, ...headers }, }; if (body !== undefined) { // If the caller passes a string, we assume it is already serialized. // Otherwise we serialize as JSON. if (typeof body === "string") { init.body = body; } else { init.body = JSON.stringify(body); // Only set Content-Type if caller didn't provide it. // This allows callers to send non-JSON payloads later if needed. if (!init.headers["Content-Type"]) { init.headers["Content-Type"] = "application/json"; } } } let response; try { response = await fetchImpl(url, init); } catch (err) { // Network errors, DNS errors, connection refused, etc. // We use status=0 to indicate "no HTTP response". throw new ApiClientError({ status: 0, code: "CLIENT_NETWORK_ERROR", message: "Network error", url, method, cause: err, }); } // Handle empty responses explicitly (e.g. some endpoints might return 204 later). if (response.status === 204) return null; // Prefer JSON when the server says it's JSON. if (isJsonResponse(response)) { let payload; try { payload = await response.json(); } catch (err) { // Server said JSON but response body was not parseable JSON. throw new ApiClientError({ status: response.status, code: "CLIENT_INVALID_JSON", message: "Invalid JSON response", url, method, cause: err, }); } // Happy path: return parsed JSON. if (response.ok) return payload; /** @type {ApiErrorPayload|any} */ const maybeError = payload; // Map standardized backend errors into ApiClientError if (maybeError?.error?.code && maybeError?.error?.message) { throw new ApiClientError({ status: response.status, code: maybeError.error.code, message: maybeError.error.message, details: maybeError.error.details, url, method, }); } // Fallback: error is JSON but not in our standardized shape. throw new ApiClientError({ status: response.status, code: "CLIENT_HTTP_ERROR", message: `Request failed with status ${response.status}`, details: payload, url, method, }); } // Non-JSON response fallback (should be rare for current endpoints) const text = await response.text().catch(() => ""); if (response.ok) return text || null; throw new ApiClientError({ status: response.status, code: "CLIENT_HTTP_ERROR", message: text || `Request failed with status ${response.status}`, url, method, }); } /* -------------------------------------------------------------------------- */ /* Domain helpers (thin wrappers) */ /* -------------------------------------------------------------------------- */ /** * Login: * - Sends credentials to the backend. * - Backend sets an HTTP-only cookie when successful. * * @param {{ username: string, password: string }} input * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function login(input, options) { return apiFetch("/api/auth/login", { method: "POST", body: input, ...options, }); } /** * Logout: * - Clears the session cookie (idempotent). * * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function logout(options) { return apiFetch("/api/auth/logout", { method: "GET", ...options }); } /** * Get current session identity (frontend-friendly): * - Always returns HTTP 200 with: * - { user: null } when unauthenticated * - { user: { userId, role, branchId } } when authenticated * * Why we want this: * - The UI should not use 401 as basic control-flow to determine "am I logged in?" * - This endpoint enables a clean "session check" UX (RHL-020 AuthGate). * * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getMe(options) { return apiFetch("/api/auth/me", { method: "GET", ...options }); } /** * Change password (RHL-009): * - Requires an active session cookie. * - Body: { currentPassword, newPassword } * * @param {{ currentPassword: string, newPassword: string }} input * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function changePassword(input, options) { return apiFetch("/api/auth/change-password", { method: "POST", body: input, ...options, }); } /** * List branches visible to the current session (RBAC is enforced server-side). * * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getBranches(options) { return apiFetch("/api/branches", { method: "GET", ...options }); } /** * @param {string} branch * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getYears(branch, options) { return apiFetch(`/api/branches/${encodeURIComponent(branch)}/years`, { method: "GET", ...options, }); } /** * @param {string} branch * @param {string} year * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getMonths(branch, year, options) { return apiFetch( `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent( year, )}/months`, { method: "GET", ...options }, ); } /** * @param {string} branch * @param {string} year * @param {string} month * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getDays(branch, year, month, options) { return apiFetch( `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent( year, )}/${encodeURIComponent(month)}/days`, { method: "GET", ...options }, ); } /** * @param {string} branch * @param {string} year * @param {string} month * @param {string} day * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getFiles(branch, year, month, day, options) { const qs = new URLSearchParams({ branch: String(branch), year: String(year), month: String(month), day: String(day), }); return apiFetch(`/api/files?${qs.toString()}`, { method: "GET", ...options }); } /** * Search delivery notes (RHL-024). * * Notes: * - This endpoint is JSON and can be called via apiFetch. * - Cursor is intentionally not stored in shareable URLs by default; the UI can keep it in state. * * @param {{ * q?: string|null, * branch?: string|null, * scope?: "branch"|"multi"|"all"|string|null, * branches?: string[]|null, * from?: string|null, * to?: string|null, * limit?: number|null, * cursor?: string|null * }} input * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function search(input, options) { const { q, branch, scope, branches, from, to, limit, cursor } = input || {}; const params = new URLSearchParams(); // Stable insertion order (helps debugging and makes URLs readable). if (typeof q === "string" && q.trim()) params.set("q", q.trim()); if (typeof scope === "string" && scope.trim()) params.set("scope", scope.trim()); if (typeof branch === "string" && branch.trim()) params.set("branch", branch.trim()); if (Array.isArray(branches) && branches.length > 0) { const cleaned = branches.map((b) => String(b).trim()).filter(Boolean); if (cleaned.length > 0) params.set("branches", cleaned.join(",")); } if (typeof from === "string" && from.trim()) params.set("from", from.trim()); if (typeof to === "string" && to.trim()) params.set("to", to.trim()); if (limit !== undefined && limit !== null) { const raw = String(limit).trim(); if (raw) params.set("limit", raw); } if (typeof cursor === "string" && cursor.trim()) { params.set("cursor", cursor.trim()); } const qs = params.toString(); const path = qs ? `/api/search?${qs}` : "/api/search"; return apiFetch(path, { method: "GET", ...options }); }