L'admin etait une 2e app front Vite independante (apps/admin-web, port 5174,
Dockerfile nginx, service compose dedie, job CI propre) non demandee. Toute
l'UI passe dans apps/web sous le prefixe /admin ; seul le frontend est
fusionne, l'authentification admin reste entierement separee.
Front (apps/web/src) :
- pages -> pages/admin/{login,dashboard,monitoring,corrections,catalog}/,
layout -> layouts/AdminLayout.tsx, contexte + garde -> features/admin/.
- client API -> api/admin-client.ts : classe AdminApiError (evite la
collision avec ApiError), lit VITE_API_URL (plus de VITE_ADMIN_API_URL).
- routes /admin/* dans App.tsx, enveloppees d'AdminAuthProvider +
RequireAdmin -> le probe GET /admin/auth/me ne tourne que sous /admin.
- reutilise l'i18n, lib/zod-errors, services/error-message.service et le
theme SCSS de apps/web ; bloc i18n admin.* fusionne dans la locale fr
(les cles errors etaient deja toutes presentes).
- corrige une race dans CatalogPage (reponse d'un onglet precedent qui
ecrasait l'onglet courant, exposee par le double-mount StrictMode) via
un ref requestSeq.
Auth admin inchangee : table AdminUser, cookie admin_session,
ADMIN_JWT_SECRET, script create-admin.ts.
Infra :
- docker-compose : service admin-web + ADMIN_WEB_PORT supprimes (l'app
`app` sert deja le front construit).
- ADMIN_CORS_ORIGIN retire (meme origine) : env.ts, app.ts, .env.example.
- job CI "Run admin-web E2E tests" supprime ; les specs admin-* tournent
dans le job web (apps/web/cypress/e2e/admin-*.{cy.ts,feature}).
- apps/api/.env.example : ajout ADMIN_JWT_SECRET / ADMIN_INITIAL_*.
- recharts ajoute a apps/web ; pnpm-lock regenere.
- specs/backend-architecture.md : section admin mise a jour.
Verifie : biome + tsc -b (web/api) + pnpm -r build verts ; Cypress web
102/103 (l'unique echec est le flake pre-existant recipe-form.feature
"Preloads ..." de clipping headless, sans rapport) ; 16/16 specs admin ;
45/45 composants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
192 lines
7 KiB
TypeScript
192 lines
7 KiB
TypeScript
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, string | undefined>): 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<string, string[] | undefined>;
|
||
|
||
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<TResponseBody>(
|
||
path: string,
|
||
options: RequestInit = {},
|
||
): Promise<TResponseBody> {
|
||
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<AdminUserView> {
|
||
return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) });
|
||
}
|
||
|
||
/** Ends the current admin session. */
|
||
public logout(): Promise<void> {
|
||
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<AdminUserView> {
|
||
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<MetricsView> {
|
||
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<MonitoringView> {
|
||
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<TrainingSuggestionGroupView[]> {
|
||
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<CorrectionAdminView[]> {
|
||
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<TrainingSuggestionAdminView> {
|
||
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<TrainingDataSnippetView> {
|
||
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<RetrainResultView> {
|
||
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<CatalogPlaceholderGroupView[]> {
|
||
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<PruneOrphansResultView> {
|
||
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();
|