batchCooking/apps/web/src/api/admin-client.ts
Nicolas b39105ac27
All checks were successful
CI / lint (push) Successful in 2m10s
CI / build (push) Successful in 2m19s
CI / intent-service-test (push) Successful in 4m41s
CI / e2e (push) Successful in 7m19s
CI / test (push) Successful in 16m20s
feat(web): page admin de CRUD des didacticiels (#44)
L'UI par-dessus l'API `/admin/api/tours*`. Nouvelle entrée
`/admin/didacticiels` dans `AdminLayout` (i18n `admin.nav.tours`), route
sous `RequireAdmin`+`AdminLayout` dans `App.tsx`.

`ToursPage.tsx` : liste des scénarios à gauche (nom, état activé/désactivé,
nombre d'étapes) ; à droite l'éditeur du scénario sélectionné —
- renommer, activer/désactiver ;
- éditer la liste d'étapes (route, cible via `<select>` alimenté par
  `GET /tours/targets`, titre, contenu), réordonner (↑/↓), ajouter,
  retirer ;
- « Enregistrer » → un seul `PATCH /:id` qui remplace tout (name/enabled +
  étapes rénumérotées `order` = index), erreurs API affichées telles quelles ;
- « Supprimer le scénario » (confirmation `window.confirm`) ;
- « Nouveau scénario » → formulaire clé + nom → `POST /`.

`admin-client.ts` gagne `getTourScenarios` / `getTourTargets` /
`createTourScenario` / `updateTourScenario` / `deleteTourScenario`.

Tests Cypress (`admin-tours.cy.ts`, 4) : liste (activés + désactivés,
nombre d'étapes) ; édition nom + réordonnancement d'étapes → un seul PATCH
avec la liste complète réordonnée ; création → POST `{key,name}` ;
suppression après confirmation → DELETE. 4/4 en local ; `pnpm build` +
`pnpm lint` verts.

Closes #44
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 06:53:38 +02:00

225 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
type AdminLoginInput,
type AdminTourScenarioView,
type AdminUserView,
type ApiErrorResponse,
type CatalogPlaceholderGroupView,
type CorrectionAdminView,
type CreateTourScenarioInput,
ErrorCode,
type MarkPlaceholdersReviewedInput,
type MetricsView,
type PruneOrphansResultView,
type RetrainRequestInput,
type RetrainResultView,
type TourTargetView,
type TrainingDataSnippetView,
type TrainingSuggestionAdminView,
type TrainingSuggestionGroupView,
type UpdateTourScenarioInput,
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 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<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/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<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("/auth/login", { method: "POST", body: JSON.stringify(input) });
}
/** Ends the current admin session. */
public logout(): Promise<void> {
return this._request("/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("/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(`/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<TrainingSuggestionGroupView[]> {
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<CorrectionAdminView[]> {
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<TrainingSuggestionAdminView> {
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<TrainingDataSnippetView> {
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<RetrainResultView> {
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<CatalogPlaceholderGroupView[]> {
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<PruneOrphansResultView> {
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
}
/** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */
public getTourScenarios(): Promise<AdminTourScenarioView[]> {
return this._request("/tours");
}
/** The catalogue of valid highlight targets, for the scenario editor's per-step target picker. */
public getTourTargets(): Promise<TourTargetView[]> {
return this._request("/tours/targets");
}
/** Creates an empty tour scenario — steps are added afterwards via {@link updateTourScenario}. Rejects with `TOUR_KEY_ALREADY_IN_USE`. */
public createTourScenario(input: CreateTourScenarioInput): Promise<AdminTourScenarioView> {
return this._request("/tours", { method: "POST", body: JSON.stringify(input) });
}
/** Replaces a scenario's `name`/`enabled` and its whole step list. Rejects with `TOUR_NOT_FOUND` / `TOUR_TARGET_NOT_FOUND`. */
public updateTourScenario(
id: number,
input: UpdateTourScenarioInput,
): Promise<AdminTourScenarioView> {
return this._request(`/tours/${id}`, { method: "PATCH", body: JSON.stringify(input) });
}
/** Deletes a scenario — its steps and every user's progress row for it cascade away. */
public deleteTourScenario(id: number): Promise<void> {
return this._request(`/tours/${id}`, { method: "DELETE" });
}
}
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
export const adminApiClient = new AdminApiClient();