From 2beff139a390bca9f7a13d02553550ca6a40eaf4 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 6 Sep 2026 05:08:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20CRUD=20admin=20des=20sc=C3=A9nario?= =?UTF-8?q?s=20de=20didacticiel=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/modules/admin/admin-tours.routes.ts | 80 +++++++ .../src/modules/admin/admin-tours.service.ts | 175 ++++++++++++++ apps/api/src/modules/admin/admin.routes.ts | 2 + apps/api/test/admin-tours.test.ts | 215 ++++++++++++++++++ apps/web/src/locales/fr/translation.json | 2 + packages/shared/src/errors/error-codes.ts | 6 +- packages/shared/src/schemas/admin.ts | 41 ++++ packages/shared/src/types/tour.ts | 19 ++ 8 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/modules/admin/admin-tours.routes.ts create mode 100644 apps/api/src/modules/admin/admin-tours.service.ts create mode 100644 apps/api/test/admin-tours.test.ts diff --git a/apps/api/src/modules/admin/admin-tours.routes.ts b/apps/api/src/modules/admin/admin-tours.routes.ts new file mode 100644 index 0000000..cb14092 --- /dev/null +++ b/apps/api/src/modules/admin/admin-tours.routes.ts @@ -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(); + }), +); diff --git a/apps/api/src/modules/admin/admin-tours.service.ts b/apps/api/src/modules/admin/admin-tours.service.ts new file mode 100644 index 0000000..5ea4a26 --- /dev/null +++ b/apps/api/src/modules/admin/admin-tours.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index 11073d6..e85d096 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -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); diff --git a/apps/api/test/admin-tours.test.ts b/apps/api/test/admin-tours.test.ts new file mode 100644 index 0000000..ea3e3ae --- /dev/null +++ b/apps/api/test/admin-tours.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 44c6b73..2719560 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -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", diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts index 538cb7a..b78ef66 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -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. */ diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index 6c00561..ac23d65 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -128,3 +128,44 @@ export const markPlaceholdersReviewedSchema = z.object({ }); /** 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; diff --git a/packages/shared/src/types/tour.ts b/packages/shared/src/types/tour.ts index 99ec61a..58821cd 100644 --- a/packages/shared/src/types/tour.ts +++ b/packages/shared/src/types/tour.ts @@ -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; +} -- 2.45.2