import { type AdminLoginInput, type AdminUserView, type ApiErrorResponse, type CatalogPlaceholderGroupView, type CorrectionAdminView, ErrorCode, type MarkPlaceholdersReviewedInput, type MetricsView, 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 admin API — the same `VITE_API_URL` the user-facing * {@link apiClient} reads (see `api/client.ts`), plus the `/admin/api` * prefix every route below is relative to. That prefix (not just bare * `/admin`) exists because this API is served from the same origin/process * as apps/web's own `/admin/*` page routes (`/admin/monitoring`, * `/admin/login`...) — a bare `/admin` mount put `GET /admin/monitoring` * (API) and the page at that same path in direct conflict, so a hard * refresh on the monitoring page got answered with raw JSON instead of the * SPA (see `mountRouter("/admin/api", ...)`'s own comment in the API's * `app.ts` for the full incident). Defaults to `/admin/api` (same origin) * — correct behind a shared reverse proxy; native dev overrides * `VITE_API_URL` to `http://localhost:3000` in `apps/web/.env`. */ const ADMIN_API_BASE_URL: string = `${import.meta.env.VITE_API_URL ?? ""}/admin/api`; /** * 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/api/*` 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("/auth/login", { method: "POST", body: JSON.stringify(input) }); } /** Ends the current admin session. */ public logout(): Promise { return this._request("/auth/logout", { method: "POST" }); } /** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */ public me(): Promise { return this._request("/auth/me"); } /** Usage metrics for the dashboard — snapshot totals + `days` (7–365) of daily time series. */ public getMetrics(days: number): Promise { return this._request(`/metrics?days=${days}`); } /** 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(`/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(`/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(`/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(`/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("/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(`/catalog/placeholders${query({ reviewed })}`); } /** Marks the given placeholder ingredient ids as triaged (`reviewedAt`). */ public markPlaceholdersReviewed( body: MarkPlaceholdersReviewedInput, ): Promise<{ reviewed: number }> { return this._request("/catalog/placeholders/mark-reviewed", { method: "PATCH", body: JSON.stringify(body), }); } /** Deletes placeholder rows no recipe references any more. */ public pruneOrphanPlaceholders(): Promise { return this._request("/catalog/placeholders/prune-orphans", { method: "POST" }); } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ export const adminApiClient = new AdminApiClient();