apiClient.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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. /**
  138. * @typedef {Object} MeUser
  139. * @property {string} userId
  140. * @property {string} role
  141. * @property {string|null} branchId
  142. * @property {string|null} email
  143. * @property {boolean} mustChangePassword
  144. */
  145. export function login(input, options) {
  146. return apiFetch("/api/auth/login", {
  147. method: "POST",
  148. body: input,
  149. ...options,
  150. });
  151. }
  152. export function logout(options) {
  153. return apiFetch("/api/auth/logout", { method: "GET", ...options });
  154. }
  155. /**
  156. * @returns {Promise<{ user: MeUser|null }>}
  157. */
  158. export function getMe(options) {
  159. return apiFetch("/api/auth/me", { method: "GET", ...options });
  160. }
  161. export function changePassword(input, options) {
  162. return apiFetch("/api/auth/change-password", {
  163. method: "POST",
  164. body: input,
  165. ...options,
  166. });
  167. }
  168. export function getBranches(options) {
  169. return apiFetch("/api/branches", { method: "GET", ...options });
  170. }
  171. export function getYears(branch, options) {
  172. return apiFetch(`/api/branches/${encodeURIComponent(branch)}/years`, {
  173. method: "GET",
  174. ...options,
  175. });
  176. }
  177. export function getMonths(branch, year, options) {
  178. return apiFetch(
  179. `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(year)}/months`,
  180. { method: "GET", ...options },
  181. );
  182. }
  183. export function getDays(branch, year, month, options) {
  184. return apiFetch(
  185. `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(
  186. year,
  187. )}/${encodeURIComponent(month)}/days`,
  188. { method: "GET", ...options },
  189. );
  190. }
  191. export function getFiles(branch, year, month, day, options) {
  192. const qs = new URLSearchParams({
  193. branch: String(branch),
  194. year: String(year),
  195. month: String(month),
  196. day: String(day),
  197. });
  198. return apiFetch(`/api/files?${qs.toString()}`, { method: "GET", ...options });
  199. }
  200. export function search(input, options) {
  201. const { q, branch, scope, branches, from, to, limit, cursor } = input || {};
  202. const params = new URLSearchParams();
  203. if (typeof q === "string" && q.trim()) params.set("q", q.trim());
  204. if (typeof scope === "string" && scope.trim())
  205. params.set("scope", scope.trim());
  206. if (typeof branch === "string" && branch.trim())
  207. params.set("branch", branch.trim());
  208. if (Array.isArray(branches) && branches.length > 0) {
  209. const cleaned = branches.map((b) => String(b).trim()).filter(Boolean);
  210. if (cleaned.length > 0) params.set("branches", cleaned.join(","));
  211. }
  212. if (typeof from === "string" && from.trim()) params.set("from", from.trim());
  213. if (typeof to === "string" && to.trim()) params.set("to", to.trim());
  214. if (limit !== undefined && limit !== null) {
  215. const raw = String(limit).trim();
  216. if (raw) params.set("limit", raw);
  217. }
  218. if (typeof cursor === "string" && cursor.trim())
  219. params.set("cursor", cursor.trim());
  220. const qs = params.toString();
  221. return apiFetch(qs ? `/api/search?${qs}` : "/api/search", {
  222. method: "GET",
  223. ...options,
  224. });
  225. }
  226. export function adminListUsers(input, options) {
  227. const { q, role, branchId, sort, limit, cursor } = input || {};
  228. const params = new URLSearchParams();
  229. if (typeof q === "string" && q.trim()) params.set("q", q.trim());
  230. if (typeof role === "string" && role.trim()) params.set("role", role.trim());
  231. if (typeof branchId === "string" && branchId.trim())
  232. params.set("branchId", branchId.trim());
  233. if (typeof sort === "string" && sort.trim()) params.set("sort", sort.trim());
  234. if (limit !== undefined && limit !== null) {
  235. const raw = String(limit).trim();
  236. if (raw) params.set("limit", raw);
  237. }
  238. if (typeof cursor === "string" && cursor.trim())
  239. params.set("cursor", cursor.trim());
  240. const qs = params.toString();
  241. return apiFetch(qs ? `/api/admin/users?${qs}` : "/api/admin/users", {
  242. method: "GET",
  243. ...options,
  244. });
  245. }
  246. export function adminCreateUser(input, options) {
  247. return apiFetch("/api/admin/users", {
  248. method: "POST",
  249. body: input,
  250. ...options,
  251. });
  252. }
  253. export function adminUpdateUser(userId, patch, options) {
  254. if (typeof userId !== "string" || !userId.trim()) {
  255. throw new Error("adminUpdateUser requires a userId string");
  256. }
  257. return apiFetch(`/api/admin/users/${encodeURIComponent(userId.trim())}`, {
  258. method: "PATCH",
  259. body: patch || {},
  260. ...options,
  261. });
  262. }
  263. export function adminDeleteUser(userId, options) {
  264. if (typeof userId !== "string" || !userId.trim()) {
  265. throw new Error("adminDeleteUser requires a userId string");
  266. }
  267. return apiFetch(`/api/admin/users/${encodeURIComponent(userId.trim())}`, {
  268. method: "DELETE",
  269. ...options,
  270. });
  271. }
  272. export function adminResetUserPassword(userId, options) {
  273. if (typeof userId !== "string" || !userId.trim()) {
  274. throw new Error("adminResetUserPassword requires a userId string");
  275. }
  276. return apiFetch(`/api/admin/users/${encodeURIComponent(userId.trim())}`, {
  277. method: "POST",
  278. ...options,
  279. });
  280. }