apiClient.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. /**
  2. * Minimal frontend-facing API client for the Lieferscheine backend.
  3. *
  4. * Goals:
  5. * - Centralize fetch defaults (credentials + no-store) to match backend caching rules.
  6. * - Provide a single, predictable error shape via ApiClientError.
  7. * - Offer thin domain helpers (login/logout/me/branches/...).
  8. *
  9. * Notes:
  10. * - JavaScript only (no TypeScript). We use JSDoc for "typed-by-convention".
  11. * - This client is intentionally small. It is not an SDK.
  12. */
  13. /**
  14. * @typedef {Object} ApiErrorPayload
  15. * @property {{ message: string, code: string, details?: any }} error
  16. */
  17. export class ApiClientError extends Error {
  18. /**
  19. * @param {{
  20. * status: number,
  21. * code: string,
  22. * message: string,
  23. * details?: any,
  24. * url?: string,
  25. * method?: string,
  26. * cause?: any
  27. * }} input
  28. */
  29. constructor({ status, code, message, details, url, method, cause }) {
  30. super(message, cause ? { cause } : undefined);
  31. this.name = "ApiClientError";
  32. this.status = status;
  33. this.code = code;
  34. if (details !== undefined) this.details = details;
  35. if (url) this.url = url;
  36. if (method) this.method = method;
  37. }
  38. }
  39. const DEFAULT_HEADERS = { Accept: "application/json" };
  40. function resolveUrl(path, baseUrl) {
  41. if (/^https?:\/\//i.test(path)) return path;
  42. const base = (baseUrl || "").trim();
  43. if (!base) return path;
  44. return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
  45. }
  46. function isJsonResponse(response) {
  47. const ct = response.headers.get("content-type") || "";
  48. return ct.toLowerCase().includes("application/json");
  49. }
  50. export async function apiFetch(path, options = {}) {
  51. const {
  52. method = "GET",
  53. headers = {},
  54. body,
  55. baseUrl,
  56. fetchImpl = fetch,
  57. } = options;
  58. const url = resolveUrl(path, baseUrl);
  59. const init = {
  60. method,
  61. credentials: "include",
  62. cache: "no-store",
  63. headers: { ...DEFAULT_HEADERS, ...headers },
  64. };
  65. if (body !== undefined) {
  66. if (typeof body === "string") {
  67. init.body = body;
  68. } else {
  69. init.body = JSON.stringify(body);
  70. if (!init.headers["Content-Type"]) {
  71. init.headers["Content-Type"] = "application/json";
  72. }
  73. }
  74. }
  75. let response;
  76. try {
  77. response = await fetchImpl(url, init);
  78. } catch (err) {
  79. throw new ApiClientError({
  80. status: 0,
  81. code: "CLIENT_NETWORK_ERROR",
  82. message: "Network error",
  83. url,
  84. method,
  85. cause: err,
  86. });
  87. }
  88. if (response.status === 204) return null;
  89. if (isJsonResponse(response)) {
  90. let payload;
  91. try {
  92. payload = await response.json();
  93. } catch (err) {
  94. throw new ApiClientError({
  95. status: response.status,
  96. code: "CLIENT_INVALID_JSON",
  97. message: "Invalid JSON response",
  98. url,
  99. method,
  100. cause: err,
  101. });
  102. }
  103. if (response.ok) return payload;
  104. const maybeError = payload;
  105. if (maybeError?.error?.code && maybeError?.error?.message) {
  106. throw new ApiClientError({
  107. status: response.status,
  108. code: maybeError.error.code,
  109. message: maybeError.error.message,
  110. details: maybeError.error.details,
  111. url,
  112. method,
  113. });
  114. }
  115. throw new ApiClientError({
  116. status: response.status,
  117. code: "CLIENT_HTTP_ERROR",
  118. message: `Request failed with status ${response.status}`,
  119. details: payload,
  120. url,
  121. method,
  122. });
  123. }
  124. const text = await response.text().catch(() => "");
  125. if (response.ok) return text || null;
  126. throw new ApiClientError({
  127. status: response.status,
  128. code: "CLIENT_HTTP_ERROR",
  129. message: text || `Request failed with status ${response.status}`,
  130. url,
  131. method,
  132. });
  133. }
  134. /* -------------------------------------------------------------------------- */
  135. /* Domain helpers */
  136. /* -------------------------------------------------------------------------- */
  137. export function login(input, options) {
  138. return apiFetch("/api/auth/login", {
  139. method: "POST",
  140. body: input,
  141. ...options,
  142. });
  143. }
  144. export function logout(options) {
  145. return apiFetch("/api/auth/logout", { method: "GET", ...options });
  146. }
  147. export function getMe(options) {
  148. return apiFetch("/api/auth/me", { method: "GET", ...options });
  149. }
  150. export function changePassword(input, options) {
  151. return apiFetch("/api/auth/change-password", {
  152. method: "POST",
  153. body: input,
  154. ...options,
  155. });
  156. }
  157. export function getBranches(options) {
  158. return apiFetch("/api/branches", { method: "GET", ...options });
  159. }
  160. export function getYears(branch, options) {
  161. return apiFetch(`/api/branches/${encodeURIComponent(branch)}/years`, {
  162. method: "GET",
  163. ...options,
  164. });
  165. }
  166. export function getMonths(branch, year, options) {
  167. return apiFetch(
  168. `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(year)}/months`,
  169. { method: "GET", ...options },
  170. );
  171. }
  172. export function getDays(branch, year, month, options) {
  173. return apiFetch(
  174. `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(
  175. year,
  176. )}/${encodeURIComponent(month)}/days`,
  177. { method: "GET", ...options },
  178. );
  179. }
  180. export function getFiles(branch, year, month, day, options) {
  181. const qs = new URLSearchParams({
  182. branch: String(branch),
  183. year: String(year),
  184. month: String(month),
  185. day: String(day),
  186. });
  187. return apiFetch(`/api/files?${qs.toString()}`, { method: "GET", ...options });
  188. }
  189. export function search(input, options) {
  190. const { q, branch, scope, branches, from, to, limit, cursor } = input || {};
  191. const params = new URLSearchParams();
  192. if (typeof q === "string" && q.trim()) params.set("q", q.trim());
  193. if (typeof scope === "string" && scope.trim())
  194. params.set("scope", scope.trim());
  195. if (typeof branch === "string" && branch.trim())
  196. params.set("branch", branch.trim());
  197. if (Array.isArray(branches) && branches.length > 0) {
  198. const cleaned = branches.map((b) => String(b).trim()).filter(Boolean);
  199. if (cleaned.length > 0) params.set("branches", cleaned.join(","));
  200. }
  201. if (typeof from === "string" && from.trim()) params.set("from", from.trim());
  202. if (typeof to === "string" && to.trim()) params.set("to", to.trim());
  203. if (limit !== undefined && limit !== null) {
  204. const raw = String(limit).trim();
  205. if (raw) params.set("limit", raw);
  206. }
  207. if (typeof cursor === "string" && cursor.trim())
  208. params.set("cursor", cursor.trim());
  209. const qs = params.toString();
  210. return apiFetch(qs ? `/api/search?${qs}` : "/api/search", {
  211. method: "GET",
  212. ...options,
  213. });
  214. }
  215. export function adminListUsers(input, options) {
  216. const { q, role, branchId, limit, cursor } = input || {};
  217. const params = new URLSearchParams();
  218. if (typeof q === "string" && q.trim()) params.set("q", q.trim());
  219. if (typeof role === "string" && role.trim()) params.set("role", role.trim());
  220. if (typeof branchId === "string" && branchId.trim())
  221. params.set("branchId", branchId.trim());
  222. if (limit !== undefined && limit !== null) {
  223. const raw = String(limit).trim();
  224. if (raw) params.set("limit", raw);
  225. }
  226. if (typeof cursor === "string" && cursor.trim())
  227. params.set("cursor", cursor.trim());
  228. const qs = params.toString();
  229. return apiFetch(qs ? `/api/admin/users?${qs}` : "/api/admin/users", {
  230. method: "GET",
  231. ...options,
  232. });
  233. }
  234. export function adminCreateUser(input, options) {
  235. return apiFetch("/api/admin/users", {
  236. method: "POST",
  237. body: input,
  238. ...options,
  239. });
  240. }
  241. export function adminUpdateUser(userId, patch, options) {
  242. if (typeof userId !== "string" || !userId.trim()) {
  243. throw new Error("adminUpdateUser requires a userId string");
  244. }
  245. return apiFetch(`/api/admin/users/${encodeURIComponent(userId.trim())}`, {
  246. method: "PATCH",
  247. body: patch || {},
  248. ...options,
  249. });
  250. }
  251. export function adminDeleteUser(userId, options) {
  252. if (typeof userId !== "string" || !userId.trim()) {
  253. throw new Error("adminDeleteUser requires a userId string");
  254. }
  255. return apiFetch(`/api/admin/users/${encodeURIComponent(userId.trim())}`, {
  256. method: "DELETE",
  257. ...options,
  258. });
  259. }