batchCooking/packages/shared/src/schemas/admin.ts
Nicolas 2beff139a3
All checks were successful
CI / lint (push) Successful in 2m57s
CI / build (push) Successful in 2m59s
CI / intent-service-test (push) Successful in 4m22s
CI / e2e (push) Successful in 9m4s
CI / test (push) Successful in 17m32s
feat(api): CRUD admin des scénarios de didacticiel (#44)
Deuxième volet backend du tutoriel guidé, sur le socle de #42/#44 : permet
à un admin de créer/éditer/supprimer les scénarios que
`GET /reference/tours` sert au moteur front.

`/admin/api/tours` (agrégateur `admin.routes.ts`, tout derrière
`requireAdmin`) :
- `GET /` — tous les scénarios (activés **et** désactivés) + étapes
  ordonnées.
- `GET /targets` — le catalogue `TourTarget` pour le sélecteur de cible de
  l'éditeur.
- `POST /` — crée un scénario vide (`key` + `name`) ; 409
  `TOUR_KEY_ALREADY_IN_USE` (nouveau code 4025) sur une clé prise.
- `PATCH /:id` — édite `name`/`enabled` **et remplace toute la liste
  d'étapes en bloc** (delete-all puis recreate avec `order` = index), même
  convention que `updateRecipe` pour les étapes d'une recette. `key`
  immuable. 404 `TOUR_NOT_FOUND` (id inconnu), 404 `TOUR_TARGET_NOT_FOUND`
  (nouveau code 4054, `targetKey` d'une étape hors catalogue — vérifié
  avant d'écrire, les étapes réelles restent intactes si la requête est
  rejetée).
- `DELETE /:id` — 204 ; les étapes et les `TourProgress` des utilisateurs
  cascadent.

Tests Mocha (`admin-tours.test.ts`) : 401 sans session admin sur chaque
route ; list (activés+désactivés) ; targets ; create→patch pour remplir
les étapes ; remplacement en bloc ; 409 clé dupliquée ; 404 cible
inconnue (sans toucher aux étapes existantes) ; 404 id inconnu ; delete +
cascade vérifiée ; un scénario désactivé disparaît de `/reference/tours`
mais reste dans la liste admin.

Refs #44
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:39:37 +02:00

171 lines
7.8 KiB
TypeScript

import { z } from "zod";
// Shared between apps/api (server-side validation, source of truth) and
// apps/admin-web (client-side validation for instant feedback). Same
// rationale as schemas/auth.ts — one set of rules, French messages surfaced
// as-is in the admin login form.
/**
* Payload accepted by `POST /admin/api/auth/login`. Deliberately its own schema
* (not a re-export of `loginSchema`): the admin surface is a separate
* contract from the end-user one even though the shape currently matches.
*/
export const adminLoginSchema = z.object({
email: z.string().trim().toLowerCase().email("Email invalide"),
password: z.string().min(1, "Le mot de passe est requis"),
});
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
export type AdminLoginInput = z.infer<typeof adminLoginSchema>;
/**
* Query params for `GET /admin/api/metrics` — `?days=` bounds how far back the
* time-series go (and how many daily buckets they carry). Coerced from the
* query string; clamped to a sane window so a huge value can't make the
* dashboard scan the whole history.
*/
export const getMetricsSchema = z.object({
days: z.coerce.number().int().min(7).max(365).default(30),
});
/** Inferred TS type for {@link getMetricsSchema}'s validated output. */
export type GetMetricsInput = z.infer<typeof getMetricsSchema>;
/**
* Payload of `POST /internal/tech-steps/heartbeat` — `services/tech-step-llm-worker`
* (which has no inbound HTTP of its own) reporting that it's alive. Sent on
* `boot`, on every scheduler `tick`, and after each `job` (with that job's
* name, outcome and counts). The worker key is fixed server-side (only one
* worker exists), so it isn't in the payload.
*/
export const workerHeartbeatSchema = z.object({
event: z.enum(["boot", "tick", "job"]),
/** The job that just ran — present only when `event === "job"`. */
job: z.string().max(100).optional(),
/** Whether that job succeeded — present only when `event === "job"`. */
ok: z.boolean().optional(),
/** Small `{ label: number }` summary of that job (e.g. `{ suggestions: 3 }`). */
counts: z.record(z.string(), z.number()).optional(),
});
/** Inferred TS type for {@link workerHeartbeatSchema}'s validated output. */
export type WorkerHeartbeatInput = z.infer<typeof workerHeartbeatSchema>;
/** Statuses a `TechStepTrainingSuggestion` can be filtered by / set to. */
export const TRAINING_SUGGESTION_STATUSES = ["pending", "applied", "rejected"] as const;
/** One of {@link TRAINING_SUGGESTION_STATUSES}. */
export type TrainingSuggestionStatus = (typeof TRAINING_SUGGESTION_STATUSES)[number];
/** Query params for `GET /admin/api/tech-steps/suggestions` — every filter optional. */
export const listSuggestionsQuerySchema = z.object({
status: z.enum(TRAINING_SUGGESTION_STATUSES).optional(),
sourceType: z.enum(["correction", "llm_audit"]).optional(),
techStepKey: z.string().min(1).optional(),
locale: z.string().min(1).optional(),
});
/** Inferred TS type for {@link listSuggestionsQuerySchema}. */
export type ListSuggestionsQuery = z.infer<typeof listSuggestionsQuerySchema>;
/** Query params for `GET /admin/api/tech-steps/corrections`. `consumed`/`hasCorrectedTechStep` are tri-state (omitted = no filter). */
export const listCorrectionsQuerySchema = z.object({
consumed: z.enum(["true", "false"]).optional(),
hasCorrectedTechStep: z.enum(["true", "false"]).optional(),
});
/** Inferred TS type for {@link listCorrectionsQuerySchema}. */
export type ListCorrectionsQuery = z.infer<typeof listCorrectionsQuerySchema>;
/**
* Body of `PATCH /admin/api/tech-steps/suggestions/:id` — curate a suggestion
* before it feeds a `training_data.py` edit. Every field optional; at least
* one must be present (enforced service-side).
*/
export const updateTrainingSuggestionSchema = z.object({
status: z.enum(TRAINING_SUGGESTION_STATUSES).optional(),
suggestedSynonyms: z.array(z.string().min(1)).max(200).optional(),
suggestedUtterances: z.array(z.string().min(1)).max(200).optional(),
});
/** Inferred TS type for {@link updateTrainingSuggestionSchema}. */
export type UpdateTrainingSuggestionInput = z.infer<typeof updateTrainingSuggestionSchema>;
/**
* Body of `POST /admin/api/tech-steps/retrain` — runs the F1 regression gate
* then, if it passes, backfills every step and marks the given suggestion
* ids. Both id lists optional (an empty run just re-gates + backfills).
*/
export const retrainRequestSchema = z.object({
appliedIds: z.array(z.number().int().positive()).max(500).optional(),
rejectedIds: z.array(z.number().int().positive()).max(500).optional(),
});
/** Inferred TS type for {@link retrainRequestSchema}. */
export type RetrainRequestInput = z.infer<typeof retrainRequestSchema>;
/** Query params for `GET /admin/api/tech-steps/training-data-snippet`. */
export const trainingDataSnippetQuerySchema = z.object({
techStepKey: z.string().min(1),
locale: z.string().min(1).default("fr"),
status: z.enum(TRAINING_SUGGESTION_STATUSES).default("applied"),
});
/** Inferred TS type for {@link trainingDataSnippetQuerySchema}. */
export type TrainingDataSnippetQuery = z.infer<typeof trainingDataSnippetQuerySchema>;
/**
* Query params for `GET /admin/api/catalog/placeholders` — the off-catalog
* ingredient review. `reviewed` is tri-state: omitted / `"false"` hides
* groups every row of which has already been triaged (the default working
* view), `"true"` shows only fully-triaged groups.
*/
export const listPlaceholdersQuerySchema = z.object({
reviewed: z.enum(["true", "false"]).optional(),
});
/** Inferred TS type for {@link listPlaceholdersQuerySchema}. */
export type ListPlaceholdersQuery = z.infer<typeof listPlaceholdersQuerySchema>;
/**
* Body of `PATCH /admin/api/catalog/placeholders/mark-reviewed` — stamps
* `reviewedAt` on the given placeholder `Ingredient` ids (a maintainer has
* seen this catalog gap and, if warranted, added the real entry by hand).
* Bounded so one call can't sweep an unbounded set.
*/
export const markPlaceholdersReviewedSchema = z.object({
ingredientIds: z.array(z.number().int().positive()).min(1).max(500),
});
/** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */
export type MarkPlaceholdersReviewedInput = z.infer<typeof markPlaceholdersReviewedSchema>;
// --- Guided-tour scenario CRUD (`/admin/api/tours`, issue #44) ----------
/** One step of a scenario, as sent by the admin editor. `route` is `null` to stay on the previous step's route; `title`/`content` are free text. */
export const adminTourStepSchema = z.object({
route: z.string().trim().min(1).max(200).nullable(),
targetKey: z.string().trim().min(1).max(60),
title: z.string().trim().min(1).max(120),
content: z.string().trim().min(1).max(600),
});
/**
* Body of `POST /admin/api/tours` — creating a scenario. `key` is a stable
* slug (same rule as a diet/source key: lowerCamel, letters/digits only)
* and is set once at creation; steps are added afterwards via `PATCH`.
*/
export const createTourScenarioSchema = z.object({
key: z
.string()
.trim()
.min(1)
.max(50)
.regex(/^[a-z][a-zA-Z0-9]*$/, "Clé invalide (lettres et chiffres, commence par une minuscule)"),
name: z.string().trim().min(1).max(100),
});
/** Inferred TS type for {@link createTourScenarioSchema}. */
export type CreateTourScenarioInput = z.infer<typeof createTourScenarioSchema>;
/**
* Body of `PATCH /admin/api/tours/:id` — edits `name`/`enabled` and
* **replaces the whole step list in one shot** (delete-all + recreate,
* same convention as `recipe.service.ts`'s `updateRecipe` for a recipe's
* steps). `key` is immutable, not accepted here.
*/
export const updateTourScenarioSchema = z.object({
name: z.string().trim().min(1).max(100),
enabled: z.boolean(),
steps: z.array(adminTourStepSchema).max(30),
});
/** Inferred TS type for {@link updateTourScenarioSchema}. */
export type UpdateTourScenarioInput = z.infer<typeof updateTourScenarioSchema>;