/** * 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 */ 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 }) { super(message, cause ? { cause } : undefined); this.name = "ApiClientError"; this.status = status; this.code = code; if (details !== undefined) this.details = details; if (url) this.url = url; if (method) this.method = method; } } const DEFAULT_HEADERS = { Accept: "application/json" }; function resolveUrl(path, baseUrl) { if (/^https?:\/\//i.test(path)) return path; const base = (baseUrl || "").trim(); if (!base) return path; return new URL(path, base.endsWith("/") ? base : `${base}/`).toString(); } function isJsonResponse(response) { const ct = response.headers.get("content-type") || ""; return ct.toLowerCase().includes("application/json"); } export async function apiFetch(path, options = {}) { const { method = "GET", headers = {}, body, baseUrl, fetchImpl = fetch, } = options; const url = resolveUrl(path, baseUrl); const init = { method, credentials: "include", cache: "no-store", headers: { ...DEFAULT_HEADERS, ...headers }, }; if (body !== undefined) { if (typeof body === "string") { init.body = body; } else { init.body = JSON.stringify(body); if (!init.headers["Content-Type"]) { init.headers["Content-Type"] = "application/json"; } } } let response; try { response = await fetchImpl(url, init); } catch (err) { throw new ApiClientError({ status: 0, code: "CLIENT_NETWORK_ERROR", message: "Network error", url, method, cause: err, }); } if (response.status === 204) return null; if (isJsonResponse(response)) { let payload; try { payload = await response.json(); } catch (err) { throw new ApiClientError({ status: response.status, code: "CLIENT_INVALID_JSON", message: "Invalid JSON response", url, method, cause: err, }); } if (response.ok) return payload; const maybeError = payload; 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, }); } throw new ApiClientError({ status: response.status, code: "CLIENT_HTTP_ERROR", message: `Request failed with status ${response.status}`, details: payload, url, method, }); } 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 */ /* -------------------------------------------------------------------------- */ /** * @typedef {Object} MeUser * @property {string} userId * @property {string} role * @property {string|null} branchId * @property {string|null} email * @property {boolean} mustChangePassword */ export function login(input, options) { return apiFetch("/api/auth/login", { method: "POST", body: input, ...options, }); } export function logout(options) { return apiFetch("/api/auth/logout", { method: "GET", ...options }); } /** * @returns {Promise<{ user: MeUser|null }>} */ export function getMe(options) { return apiFetch("/api/auth/me", { method: "GET", ...options }); } export function changePassword(input, options) { return apiFetch("/api/auth/change-password", { method: "POST", body: input, ...options, }); } export function getBranches(options) { return apiFetch("/api/branches", { method: "GET", ...options }); } export function getYears(branch, options) { return apiFetch(`/api/branches/${encodeURIComponent(branch)}/years`, { method: "GET", ...options, }); } export function getMonths(branch, year, options) { return apiFetch( `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(year)}/months`, { method: "GET", ...options }, ); } export function getDays(branch, year, month, options) { return apiFetch( `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent( year, )}/${encodeURIComponent(month)}/days`, { method: "GET", ...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 }); } export function search(input, options) { const { q, branch, scope, branches, from, to, limit, cursor } = input || {}; const params = new URLSearchParams(); 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(); return apiFetch(qs ? `/api/search?${qs}` : "/api/search", { method: "GET", ...options, }); } export function adminListUsers(input, options) { const { q, role, branchId, sort, limit, cursor } = input || {}; const params = new URLSearchParams(); if (typeof q === "string" && q.trim()) params.set("q", q.trim()); if (typeof role === "string" && role.trim()) params.set("role", role.trim()); if (typeof branchId === "string" && branchId.trim()) params.set("branchId", branchId.trim()); if (typeof sort === "string" && sort.trim()) params.set("sort", sort.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(); return apiFetch(qs ? `/api/admin/users?${qs}` : "/api/admin/users", { method: "GET", ...options, }); } export function adminCreateUser(input, options) { return apiFetch("/api/admin/users", { method: "POST", body: input, ...options, }); } export function adminUpdateUser(userId, patch, options) { if (typeof userId !== "string" || !userId.trim()) { throw new Error("adminUpdateUser requires a userId string"); } return apiFetch(`/api/admin/users/${encodeURIComponent(userId.trim())}`, { method: "PATCH", body: patch || {}, ...options, }); } export function adminDeleteUser(userId, options) { if (typeof userId !== "string" || !userId.trim()) { throw new Error("adminDeleteUser requires a userId string"); } return apiFetch(`/api/admin/users/${encodeURIComponent(userId.trim())}`, { method: "DELETE", ...options, }); } export function adminResetUserPassword(userId, options) { if (typeof userId !== "string" || !userId.trim()) { throw new Error("adminResetUserPassword requires a userId string"); } return apiFetch(`/api/admin/users/${encodeURIComponent(userId.trim())}`, { method: "POST", ...options, }); }