feat(api): CRUD admin des scénarios de didacticiel (#44 — API) #59
8 changed files with 539 additions and 1 deletions
80
apps/api/src/modules/admin/admin-tours.routes.ts
Normal file
80
apps/api/src/modules/admin/admin-tours.routes.ts
Normal 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();
|
||||
}),
|
||||
);
|
||||
175
apps/api/src/modules/admin/admin-tours.service.ts
Normal file
175
apps/api/src/modules/admin/admin-tours.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { adminAuthRouter } from "./admin-auth.routes.js";
|
|||
import { adminCatalogRouter } from "./admin-catalog.routes.js";
|
||||
import { adminMetricsRouter } from "./admin-metrics.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
|
||||
|
|
@ -24,3 +25,4 @@ adminRouter.use("/auth", adminAuthRouter);
|
|||
adminRouter.use("/metrics", adminMetricsRouter);
|
||||
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
||||
adminRouter.use("/catalog", adminCatalogRouter);
|
||||
adminRouter.use("/tours", adminToursRouter);
|
||||
|
|
|
|||
215
apps/api/test/admin-tours.test.ts
Normal file
215
apps/api/test/admin-tours.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -46,6 +46,8 @@
|
|||
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
|
||||
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés 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",
|
||||
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard",
|
||||
"RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours",
|
||||
|
|
|
|||
|
|
@ -75,8 +75,12 @@ export enum ErrorCode {
|
|||
TECH_STEP_NOT_FOUND = 4051,
|
||||
/** A tech-step correction's manually-attached `utensils[].utensilId` doesn't match any reference `Utensil` row. */
|
||||
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,
|
||||
/** 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`. */
|
||||
INVALID_CORRECTION_SPAN = 4002,
|
||||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||
|
|
|
|||
|
|
@ -128,3 +128,44 @@ export const markPlaceholdersReviewedSchema = z.object({
|
|||
});
|
||||
/** 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>;
|
||||
|
|
|
|||
|
|
@ -29,3 +29,22 @@ export interface TourScenarioView {
|
|||
export interface CompletedToursView {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue