import { type AdminLoginInput, type AdminUserView, type ApiErrorResponse, ErrorCode, } from "@batch-cooking/shared"; /** * Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL` * (see `.env.example`). Defaults to `""` (same origin) — correct behind a * shared reverse proxy; native dev overrides it to `http://localhost:3000` * in `apps/admin-web/.env` since the Vite dev server (5174) and the API * (3000) are different origins. */ const ADMIN_API_BASE_URL: string = import.meta.env.VITE_ADMIN_API_URL ?? ""; /** * Thrown by {@link AdminApiClient} on any non-2xx response — carries the * same {@link ErrorCode} the API returned. Same shape as apps/web's * `ApiError`; kept separate rather than shared so the two apps' transport * layers stay independent. */ export class ApiError extends Error { public readonly status: number; public readonly code: ErrorCode; public readonly fieldErrors?: Record; public constructor(status: number, body: ApiErrorResponse) { super(body.message); this.name = "ApiError"; this.status = status; this.code = body.code; this.fieldErrors = body.details; } } /** * Thin fetch wrapper around the `/admin/*` endpoints — same design as * apps/web's `ApiClient` (a class for cohesion/extensibility, one shared * stateless instance). Every request sends credentials so the * `admin_session` httpOnly cookie round-trips. */ export class AdminApiClient { /** * Performs a JSON request against the admin API and returns the parsed body. * * @throws {ApiError} if the response status is not in the 2xx range. */ private async _request( path: string, options: RequestInit = {}, ): Promise { try { const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, { ...options, credentials: "include", headers: { "Content-Type": "application/json", ...options.headers }, }); if (!response.ok) { const body = (await response.json().catch(() => null)) as ApiErrorResponse | null; throw new ApiError( response.status, body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" }, ); } if (response.status === 204) { return undefined as TResponseBody; } return (await response.json()) as TResponseBody; } catch (err) { // Rethrown as-is — callers surface it their own way; this is just the // one place the fetch/`await` sits in a try/catch per the repo's rule. throw err; } } /** Verifies admin credentials and starts an admin session. */ public login(input: AdminLoginInput): Promise { return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) }); } /** Ends the current admin session. */ public logout(): Promise { return this._request("/admin/auth/logout", { method: "POST" }); } /** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */ public me(): Promise { return this._request("/admin/auth/me"); } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ export const adminApiClient = new AdminApiClient();