import { type AdminLoginInput, type AdminUserView, type ApiErrorResponse, type CatalogPlaceholderGroupView, type CorrectionAdminView, ErrorCode, type MarkPlaceholdersReviewedInput, type MetricsView, type MonitoringView, type PruneOrphansResultView, type RetrainRequestInput, type RetrainResultView, type TrainingDataSnippetView, type TrainingSuggestionAdminView, type TrainingSuggestionGroupView, type UpdateTrainingSuggestionInput, } from "@batch-cooking/shared"; /** Builds a `?a=b&c=d` string from defined values only. */ function query(params: Record): string { const entries = Object.entries(params).filter( (entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== "", ); return entries.length === 0 ? "" : `?${new URLSearchParams(entries).toString()}`; } /** * Base URL of the API — the same `VITE_API_URL` the user-facing * {@link apiClient} reads (see `api/client.ts`). Defaults to `""` (same * origin) — correct behind a shared reverse proxy; native dev overrides it * to `http://localhost:3000` in `apps/web/.env`. The `/admin/*` surface is * served by the same API process as the rest of the app. */ const ADMIN_API_BASE_URL: string = import.meta.env.VITE_API_URL ?? ""; /** * Thrown by {@link AdminApiClient} on any non-2xx response — carries the * {@link ErrorCode} the API returned. Distinct from `api/client.ts`'s * `ApiError` (same shape) so a file importing both never has a name clash; * the admin transport layer stays independent of the user-facing one. */ export class AdminApiError 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 = "AdminApiError"; 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 {AdminApiError} 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 AdminApiError( 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"); } /** Usage metrics for the dashboard — snapshot totals + `days` (7–365) of daily time series. */ public getMetrics(days: number): Promise { return this._request(`/admin/metrics?days=${days}`); } /** Live health of Postgres, the API, the intent-service and the LLM worker — polled by the monitoring board. */ public getMonitoring(): Promise { return this._request("/admin/monitoring"); } /** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */ public getSuggestions(filters: { status?: string; sourceType?: string; techStepKey?: string; locale?: string; }): Promise { return this._request(`/admin/tech-steps/suggestions${query(filters)}`); } /** Raw user corrections, including the "no technique here" removals. */ public getCorrections(filters: { consumed?: string; hasCorrectedTechStep?: string; }): Promise { return this._request(`/admin/tech-steps/corrections${query(filters)}`); } /** Edits a suggestion's proposed synonyms/utterances and/or its status. */ public updateSuggestion( id: number, body: UpdateTrainingSuggestionInput, ): Promise { return this._request(`/admin/tech-steps/suggestions/${id}`, { method: "PATCH", body: JSON.stringify(body), }); } /** The ready-to-paste `training_data.py` block aggregating suggestions for one technique/locale/status. */ public getTrainingDataSnippet(params: { techStepKey: string; locale?: string; status?: string; }): Promise { return this._request(`/admin/tech-steps/training-data-snippet${query(params)}`); } /** Runs the F1 gate + backfill (+ marks suggestion ids). Rejects with `RETRAIN_ALREADY_RUNNING` if one is in flight. */ public retrain(body: RetrainRequestInput): Promise { return this._request("/admin/tech-steps/retrain", { method: "POST", body: JSON.stringify(body), }); } /** Off-catalog ingredient "placeholders" users typed, grouped by normalized name. `reviewed` omitted/`"false"` = the still-to-triage list, `"true"` = the archive. */ public getPlaceholders(reviewed?: "true" | "false"): Promise { return this._request(`/admin/catalog/placeholders${query({ reviewed })}`); } /** Marks the given placeholder ingredient ids as triaged (`reviewedAt`). */ public markPlaceholdersReviewed( body: MarkPlaceholdersReviewedInput, ): Promise<{ reviewed: number }> { return this._request("/admin/catalog/placeholders/mark-reviewed", { method: "PATCH", body: JSON.stringify(body), }); } /** Deletes placeholder rows no recipe references any more. */ public pruneOrphanPlaceholders(): Promise { return this._request("/admin/catalog/placeholders/prune-orphans", { method: "POST" }); } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ export const adminApiClient = new AdminApiClient();