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; /** * 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; /** * 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; /** 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; /** 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; /** * 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; /** * 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; /** 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; /** * 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; /** * 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; // --- 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; /** * 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;