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>
225 lines
8.6 KiB
TypeScript
225 lines
8.6 KiB
TypeScript
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();
|