feat(api): CRUD admin des scénarios de didacticiel (#44)
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

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>
This commit is contained in:
Nicolas 2026-09-06 05:08:21 +02:00
parent d1948954a6
commit 2beff139a3
8 changed files with 539 additions and 1 deletions

View file

@ -0,0 +1,80 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
createTourScenarioSchema,
ErrorCode,
updateTourScenarioSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
createTourScenario,
deleteTourScenario,
listTourScenarios,
listTourTargets,
updateTourScenario,
} from "./admin-tours.service.js";
/**
* Router mounted at `/admin/api/tours` (via `admin.routes.ts`) — every
* route behind {@link requireAdmin}. Full CRUD over the guided-tour
* scenarios the public `GET /reference/tours` serves to the web tour
* engine (issues #42/#44).
*/
export const adminToursRouter = Router();
/** Express types a numeric route param `string | undefined`; parse + validate here. */
function parseId(value: string | undefined): number {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, `Invalid tour scenario id "${value}"`);
}
return id;
}
adminToursRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await listTourScenarios());
}),
);
/** The catalogue of valid highlight targets — for the editor's per-step target picker. */
adminToursRouter.get(
"/targets",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await listTourTargets());
}),
);
adminToursRouter.post(
"/",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const scenario = await createTourScenario(createTourScenarioSchema.parse(req.body));
res.status(201).json(scenario);
}),
);
adminToursRouter.patch(
"/:id",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const scenario = await updateTourScenario(
parseId(req.params.id),
updateTourScenarioSchema.parse(req.body),
);
res.status(200).json(scenario);
}),
);
adminToursRouter.delete(
"/:id",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
await deleteTourScenario(parseId(req.params.id));
res.status(204).end();
}),
);

View file

@ -0,0 +1,175 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type AdminTourScenarioView,
type CreateTourScenarioInput,
ErrorCode,
type TourTargetView,
type UpdateTourScenarioInput,
} from "@batch-cooking/shared";
import { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
/** Shapes one Prisma `TourScenario` (with its steps) into the admin view. */
function toAdminView(scenario: {
id: number;
key: string;
name: string;
enabled: boolean;
steps: Array<{
order: number;
route: string | null;
targetKey: string;
title: string;
content: string;
}>;
}): AdminTourScenarioView {
return {
id: scenario.id,
key: scenario.key,
name: scenario.name,
enabled: scenario.enabled,
steps: scenario.steps
.slice()
.sort((a, b) => a.order - b.order)
.map((step) => ({
order: step.order,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
};
}
/** Every tour scenario — enabled *and* disabled — with its ordered steps. */
export async function listTourScenarios(): Promise<AdminTourScenarioView[]> {
try {
const scenarios = await prisma.tourScenario.findMany({
orderBy: { key: "asc" },
include: { steps: { orderBy: { order: "asc" } } },
});
return scenarios.map(toAdminView);
} catch (err) {
throw err; // see the repo's service-layer try/catch convention
}
}
/** The catalogue of valid highlight targets, for the editor's target picker. */
export async function listTourTargets(): Promise<TourTargetView[]> {
try {
return await prisma.tourTarget.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err;
}
}
/**
* Creates an empty scenario (no steps yet — the editor adds them via
* {@link updateTourScenario}).
*
* @throws {HttpError} `409 TOUR_KEY_ALREADY_IN_USE` if `key` is taken.
*/
export async function createTourScenario(
input: CreateTourScenarioInput,
): Promise<AdminTourScenarioView> {
try {
const scenario = await prisma.tourScenario.create({
data: { key: input.key, name: input.name },
include: { steps: true },
});
return toAdminView(scenario);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
throw new HttpError(
409,
ErrorCode.TOUR_KEY_ALREADY_IN_USE,
`A tour scenario with key "${input.key}" already exists`,
);
}
throw err;
}
}
/**
* Updates a scenario's `name`/`enabled` and **replaces its entire step
* list** (delete-all then recreate with `order` = array index) — the same
* block-replace `updateRecipe` uses for a recipe's steps, not a partial
* patch. `key` is immutable.
*
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `id` matches no scenario.
* @throws {HttpError} `404 TOUR_TARGET_NOT_FOUND` if a step's `targetKey`
* isn't a seeded `TourTarget`.
*/
export async function updateTourScenario(
id: number,
input: UpdateTourScenarioInput,
): Promise<AdminTourScenarioView> {
try {
const existing = await prisma.tourScenario.findUnique({ where: { id } });
if (!existing) {
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No tour scenario with id ${id}`);
}
const targetKeys = [...new Set(input.steps.map((step) => step.targetKey))];
if (targetKeys.length > 0) {
const known = await prisma.tourTarget.findMany({
where: { key: { in: targetKeys } },
select: { key: true },
});
const knownKeys = new Set(known.map((target) => target.key));
const unknown = targetKeys.find((key) => !knownKeys.has(key));
if (unknown !== undefined) {
throw new HttpError(
404,
ErrorCode.TOUR_TARGET_NOT_FOUND,
`No tour target with key "${unknown}"`,
);
}
}
const [, , scenario] = await prisma.$transaction([
prisma.tourScenario.update({
where: { id },
data: { name: input.name, enabled: input.enabled },
}),
prisma.tourScenarioStep.deleteMany({ where: { scenarioId: id } }),
prisma.tourScenario.update({
where: { id },
data: {
steps: {
create: input.steps.map((step, index) => ({
order: index,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
},
},
include: { steps: { orderBy: { order: "asc" } } },
}),
]);
return toAdminView(scenario);
} catch (err) {
throw err;
}
}
/**
* Deletes a scenario. Its steps and every user's `TourProgress` row for it
* cascade away (a scenario that no longer exists has no "seen" state worth
* keeping).
*
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `id` matches no scenario.
*/
export async function deleteTourScenario(id: number): Promise<void> {
try {
const existing = await prisma.tourScenario.findUnique({ where: { id } });
if (!existing) {
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No tour scenario with id ${id}`);
}
await prisma.tourScenario.delete({ where: { id } });
} catch (err) {
throw err;
}
}

View file

@ -3,6 +3,7 @@ import { adminAuthRouter } from "./admin-auth.routes.js";
import { adminCatalogRouter } from "./admin-catalog.routes.js"; import { adminCatalogRouter } from "./admin-catalog.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js"; import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
import { adminToursRouter } from "./admin-tours.routes.js";
/** /**
* Aggregator for the admin application's API surface, mounted at * Aggregator for the admin application's API surface, mounted at
@ -24,3 +25,4 @@ adminRouter.use("/auth", adminAuthRouter);
adminRouter.use("/metrics", adminMetricsRouter); adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/tech-steps", adminTechStepsRouter); adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter); adminRouter.use("/catalog", adminCatalogRouter);
adminRouter.use("/tours", adminToursRouter);

View file

@ -0,0 +1,215 @@
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/**
* `/admin/api/tours/*` — CRUD over the guided-tour scenarios that
* `GET /reference/tours` serves to the web tour engine (issues #42/#44).
* Every route behind `requireAdmin`. The DB starts seeded with the two
* default scenarios (`general`, `techStepMetadata`) and the `TourTarget`
* catalogue (see `reference-seed-data.ts`).
*/
describe("Admin tours — scenario CRUD", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
expect((await request(app).get("/admin/api/tours")).status).to.equal(401);
expect((await request(app).get("/admin/api/tours/targets")).status).to.equal(401);
expect(
(await request(app).post("/admin/api/tours").send({ key: "x", name: "X" })).status,
).to.equal(401);
expect((await request(app).patch("/admin/api/tours/1").send({})).status).to.equal(401);
expect((await request(app).delete("/admin/api/tours/1")).status).to.equal(401);
});
it("lists the seeded scenarios (enabled and disabled) with their ordered steps", async () => {
const agent = await adminAgent();
await prisma.tourScenario.update({
where: { key: "techStepMetadata" },
data: { enabled: false },
});
const res = await agent.get("/admin/api/tours");
expect(res.status).to.equal(200);
const byKey = (key: string) =>
res.body.find((scenario: { key: string }) => scenario.key === key);
expect(byKey("general").enabled).to.equal(true);
expect(byKey("techStepMetadata").enabled).to.equal(false);
expect(byKey("general").steps.length).to.be.greaterThan(0);
expect(byKey("general").steps[0]).to.have.keys([
"order",
"route",
"targetKey",
"title",
"content",
]);
});
it("lists the highlight-target catalogue", async () => {
const agent = await adminAgent();
const res = await agent.get("/admin/api/tours/targets");
expect(res.status).to.equal(200);
expect(res.body.map((t: { key: string }) => t.key)).to.include("planning-grid");
expect(res.body[0]).to.have.keys(["key", "description"]);
});
it("creates an empty scenario, then fills its steps with a block PATCH", async () => {
const agent = await adminAgent();
const created = await agent
.post("/admin/api/tours")
.send({ key: "welcomeBack", name: "Retour utilisateur" });
expect(created.status).to.equal(201);
expect(created.body).to.include({
key: "welcomeBack",
name: "Retour utilisateur",
enabled: true,
});
expect(created.body.steps).to.deep.equal([]);
const patched = await agent.patch(`/admin/api/tours/${created.body.id}`).send({
name: "Retour utilisateur",
enabled: true,
steps: [
{ route: "/", targetKey: "planning-grid", title: "Le planning", content: "Votre semaine." },
{
route: null,
targetKey: "planning-cook-button",
title: "Cuisiner",
content: "Le bouton pour lancer la séance.",
},
],
});
expect(patched.status).to.equal(200);
expect(patched.body.steps.map((s: { order: number }) => s.order)).to.deep.equal([0, 1]);
expect(patched.body.steps[1].targetKey).to.equal("planning-cook-button");
expect(patched.body.steps[1].route).to.equal(null);
});
it("replaces the whole step list on PATCH (delete-all then recreate)", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const res = await agent.patch(`/admin/api/tours/${general.id}`).send({
name: "Tour d'orientation",
enabled: true,
steps: [
{ route: null, targetKey: "nav-recipes", title: "Recettes", content: "Une seule étape." },
],
});
expect(res.status).to.equal(200);
expect(res.body.steps).to.have.length(1);
expect(await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } })).to.equal(1);
});
it("rejects a duplicate key with 409 TOUR_KEY_ALREADY_IN_USE", async () => {
const agent = await adminAgent();
const res = await agent.post("/admin/api/tours").send({ key: "general", name: "Doublon" });
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.TOUR_KEY_ALREADY_IN_USE);
});
it("rejects a PATCH whose step names an unknown target with 404 TOUR_TARGET_NOT_FOUND", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const res = await agent.patch(`/admin/api/tours/${general.id}`).send({
name: "x",
enabled: true,
steps: [{ route: null, targetKey: "not-a-real-target", title: "t", content: "c" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TOUR_TARGET_NOT_FOUND);
// The scenario's real steps are untouched when the PATCH is rejected.
expect(
await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } }),
).to.be.greaterThan(1);
});
it("404s a PATCH/DELETE against an unknown scenario id", async () => {
const agent = await adminAgent();
const patch = await agent
.patch("/admin/api/tours/999999")
.send({ name: "x", enabled: true, steps: [] });
expect(patch.status).to.equal(404);
expect(patch.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
const del = await agent.delete("/admin/api/tours/999999");
expect(del.status).to.equal(404);
});
it("deletes a scenario and cascades its steps and users' progress", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const user = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
await prisma.tourProgress.create({
data: { userProfileId: user.id, scenarioId: general.id },
});
const res = await agent.delete(`/admin/api/tours/${general.id}`);
expect(res.status).to.equal(204);
expect(await prisma.tourScenario.findUnique({ where: { id: general.id } })).to.equal(null);
expect(await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } })).to.equal(0);
expect(await prisma.tourProgress.count({ where: { scenarioId: general.id } })).to.equal(0);
});
it("hides a disabled scenario from the public GET /reference/tours but keeps it in the admin list", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
await agent
.patch(`/admin/api/tours/${general.id}`)
.send({ name: "Tour d'orientation", enabled: false, steps: [] });
const publicRes = await request(app).get("/reference/tours");
expect(publicRes.body.map((t: { key: string }) => t.key)).to.not.include("general");
const adminRes = await agent.get("/admin/api/tours");
expect(adminRes.body.map((t: { key: string }) => t.key)).to.include("general");
});
});

View file

@ -46,6 +46,8 @@
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas", "TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas", "UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas",
"TOUR_NOT_FOUND": "Ce didacticiel n'existe pas", "TOUR_NOT_FOUND": "Ce didacticiel n'existe pas",
"TOUR_TARGET_NOT_FOUND": "Cette cible de surlignage n'existe pas",
"TOUR_KEY_ALREADY_IN_USE": "Un scénario utilise déjà cette clé",
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape", "INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard", "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard",
"RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours", "RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours",

View file

@ -75,8 +75,12 @@ export enum ErrorCode {
TECH_STEP_NOT_FOUND = 4051, TECH_STEP_NOT_FOUND = 4051,
/** A tech-step correction's manually-attached `utensils[].utensilId` doesn't match any reference `Utensil` row. */ /** A tech-step correction's manually-attached `utensils[].utensilId` doesn't match any reference `Utensil` row. */
UTENSIL_NOT_FOUND = 4052, UTENSIL_NOT_FOUND = 4052,
/** `POST /profile/tours/:tourKey/complete` (or an admin tour route) named a tour scenario key that doesn't match an enabled `TourScenario`. */ /** `POST /profile/tours/:tourKey/complete` (or an admin tour route) named a tour scenario key/id that doesn't match a `TourScenario` (and, for the public route, an *enabled* one). */
TOUR_NOT_FOUND = 4053, TOUR_NOT_FOUND = 4053,
/** An admin tour scenario step referenced a `targetKey` that doesn't match any seeded `TourTarget`. */
TOUR_TARGET_NOT_FOUND = 4054,
/** `POST /admin/api/tours` was given a `key` already used by another scenario. */
TOUR_KEY_ALREADY_IN_USE = 4025,
/** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */ /** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
INVALID_CORRECTION_SPAN = 4002, INVALID_CORRECTION_SPAN = 4002,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */ /** Unexpected/unhandled failure — the catch-all, always logged server-side. */

View file

@ -128,3 +128,44 @@ export const markPlaceholdersReviewedSchema = z.object({
}); });
/** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */ /** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */
export type MarkPlaceholdersReviewedInput = z.infer<typeof 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>;

View file

@ -29,3 +29,22 @@ export interface TourScenarioView {
export interface CompletedToursView { export interface CompletedToursView {
completedTourKeys: string[]; completedTourKeys: string[];
} }
/**
* One tour scenario as the admin CRUD (`GET /admin/api/tours`) returns it —
* unlike {@link TourScenarioView} this includes disabled scenarios and the
* numeric `id` / `enabled` flag the editor needs. Issue #44.
*/
export interface AdminTourScenarioView {
id: number;
key: string;
name: string;
enabled: boolean;
steps: TourScenarioStepView[];
}
/** A selectable highlight target for the admin scenario editor (`GET /admin/api/tours/targets`). */
export interface TourTargetView {
key: string;
description: string;
}