Merge pull request 'feat(api): modèle de données + API des scénarios de didacticiel (#42/#44 — socle backend)' (#58) from feat/tour-scenarios-backend into main
All checks were successful
CI / lint (push) Successful in 2m52s
CI / build (push) Successful in 3m7s
CI / intent-service-test (push) Successful in 4m54s
CI / e2e (push) Successful in 8m31s
CI / test (push) Successful in 17m21s

This commit is contained in:
kyuno 2026-09-06 05:39:22 +02:00
commit d1948954a6
15 changed files with 618 additions and 1 deletions

View file

@ -0,0 +1,65 @@
-- Guided-tour scenarios, their steps, the catalogue of valid highlight
-- targets, and per-user "seen" progress. All four tables are new — plain
-- CREATEs, nothing to backfill. See issues #42 (the tour engine) and #44
-- (admin CRUD + the DB-backed model this migration implements, which
-- revises #42's originally-planned in-code registry).
-- CreateTable
CREATE TABLE "tour_target" (
"key" TEXT NOT NULL,
"description" TEXT NOT NULL,
CONSTRAINT "tour_target_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "tour_scenario" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tour_scenario_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tour_scenario_step" (
"id" SERIAL NOT NULL,
"scenario_id" INTEGER NOT NULL,
"order" INTEGER NOT NULL,
"route" TEXT,
"target_key" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT "tour_scenario_step_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tour_progress" (
"user_profile_id" INTEGER NOT NULL,
"scenario_id" INTEGER NOT NULL,
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tour_progress_pkey" PRIMARY KEY ("user_profile_id","scenario_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tour_scenario_key_key" ON "tour_scenario"("key");
-- CreateIndex
CREATE UNIQUE INDEX "tour_scenario_step_scenario_id_order_key" ON "tour_scenario_step"("scenario_id", "order");
-- AddForeignKey
ALTER TABLE "tour_scenario_step" ADD CONSTRAINT "tour_scenario_step_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "tour_scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tour_scenario_step" ADD CONSTRAINT "tour_scenario_step_target_key_fkey" FOREIGN KEY ("target_key") REFERENCES "tour_target"("key") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tour_progress" ADD CONSTRAINT "tour_progress_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tour_progress" ADD CONSTRAINT "tour_progress_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "tour_scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -125,6 +125,9 @@ model UserProfile {
/// list regardless of that real-world cardinality. /// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin") administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference? preferences UserPreference?
/// Guided-tour scenarios this profile has seen (completed or skipped) —
/// see {@link TourProgress} and issues #42 / #44.
tourProgress TourProgress[]
/// Tech-step corrections this profile has submitted (any profile that can /// Tech-step corrections this profile has submitted (any profile that can
/// view a recipe may correct its tech-step matches, not just its author — /// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`). /// see `StepTechStepCorrection.correctorId`).
@ -1107,3 +1110,82 @@ model BackfillState {
@@map("backfill_state") @@map("backfill_state")
} }
/// Catalogue des cibles de surlignage valides pour un didacticiel guidé —
/// même esprit que `TechStep`/`Unit`/`Diet` : une valeur de référence
/// maintenue par le code, seedée (`reference-seed-data.ts`). Chaque `key`
/// correspond à un attribut `data-tour-<key>` réellement posé quelque part
/// dans le frontend ; un admin choisit parmi ces cibles en construisant un
/// scénario, il ne peut pas en inventer une (poser un nouveau `data-tour-*`
/// reste un changement de code + une ligne seedée ici). Voir issues #42/#44.
model TourTarget {
key String @id
/// Libellé pour aider l'admin à choisir dans le formulaire, ex.
/// "Bouton + d'un créneau du planning".
description String
steps TourScenarioStep[]
@@map("tour_target")
}
/// Un scénario de didacticiel — une séquence d'étapes surlignées, rejouable
/// et éditable depuis l'admin (#44). `key` est une clé stable (comme
/// `Diet.key`/`Source.key`) : `"general"` / `"techStepMetadata"` pour les
/// deux scénarios seedés, libre pour un scénario créé par un admin.
model TourScenario {
id Int @id @default(autoincrement())
key String @unique
/// Libellé admin, ex. "Tour d'orientation".
name String
/// Désactiver sans supprimer — `GET /reference/tours` ne renvoie que les
/// scénarios activés.
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
steps TourScenarioStep[]
progress TourProgress[]
@@map("tour_scenario")
}
/// Une étape d'un scénario : (si `route` est défini) navigue vers cette
/// route, puis surligne l'élément portant `data-tour-<targetKey>` avec un
/// popover `title`/`content`. `title`/`content` sont du **texte libre**
/// saisi par l'admin, pas des clés i18n (#44 : le chrome de l'UI vient
/// d'i18next, le contenu administré dynamiquement est du texte, comme le
/// nom d'une recette).
model TourScenarioStep {
id Int @id @default(autoincrement())
scenarioId Int @map("scenario_id")
order Int
/// Route React Router à ouvrir avant cette étape ; `null` = rester sur la
/// route de l'étape précédente.
route String?
targetKey String @map("target_key")
title String
content String
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
target TourTarget @relation(fields: [targetKey], references: [key])
@@unique([scenarioId, order])
@@map("tour_scenario_step")
}
/// Présence d'une ligne = ce profil a *vu* ce scénario. Le terminer **ou**
/// le passer volontairement crée la ligne (pas de re-nag après un skip
/// délibéré). Révise la version de #42 (`tourKey: String` libre) en une
/// vraie FK vers `TourScenario` (#44).
model TourProgress {
userProfileId Int @map("user_profile_id")
scenarioId Int @map("scenario_id")
completedAt DateTime @default(now()) @map("completed_at")
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
@@id([userProfileId, scenarioId])
@@map("tour_progress")
}

View file

@ -191,6 +191,114 @@ export const UTENSILS: string[] = [
"mandoline", "mandoline",
]; ];
// Guided-tour highlight targets (issues #42/#44). Each `key` is the suffix
// of a `data-tour-<key>` attribute the frontend puts on the element the
// tour points at; `description` is the hint an admin sees when picking a
// target in the scenario editor. Reference data maintained here, like
// `TECH_STEPS`/`UTENSILS` — adding a real new target needs a frontend
// `data-tour-*` *and* a line here.
export const TOUR_TARGETS: Array<{ key: string; description: string }> = [
{ key: "nav-planning", description: "Lien « Planning » de la barre latérale" },
{ key: "nav-recipes", description: "Lien « Recettes » de la barre latérale" },
{ key: "nav-shopping-list", description: "Lien « Liste de courses » de la barre latérale" },
{ key: "planning-grid", description: "La grille du planning de la semaine" },
{ key: "planning-add-recipe", description: "Bouton « + » d'un créneau du planning" },
{ key: "planning-cook-button", description: "Bouton « Cuisiner » de l'en-tête du planning" },
{ key: "shopping-list-summary", description: "La liste de courses générée pour la semaine" },
{
key: "techstep-description",
description: "Le texte d'une étape de recette, avec les techniques surlignées",
},
{
key: "techstep-technique-picker",
description: "Le sélecteur de technique du popover de correction",
},
];
// The two tours filed in issue #42, as the DB-backed scenarios #44 defines.
// Only *created* if absent (an admin owns name/enabled/steps afterwards —
// see `seedTourScenarios`); `content`/`title` are plain French text, not
// i18n keys (#44). The second tour is deliberately short for now — issue
// #36 (correction popover closes mid-selection) is still open, so v1 only
// covers the gestures that already work (select text, pick a technique).
export const TOUR_SCENARIOS: Array<{
key: string;
name: string;
steps: Array<{ route: string | null; targetKey: string; title: string; content: string }>;
}> = [
{
key: "general",
name: "Tour d'orientation",
steps: [
{
route: "/",
targetKey: "nav-planning",
title: "Votre semaine, ici",
content: "Le planning est votre point de départ : ce que vous mangez, jour par jour.",
},
{
route: null,
targetKey: "nav-recipes",
title: "Vos recettes",
content:
"Retrouvez vos recettes, importez-en depuis d'autres sites, et filtrez selon vos préférences.",
},
{
route: null,
targetKey: "nav-shopping-list",
title: "La liste de courses",
content:
"Générée automatiquement à partir de tout ce que vous avez planifié pour la semaine.",
},
{
route: null,
targetKey: "planning-grid",
title: "Sept jours, cinq repas",
content: "Chaque case est un créneau. Ajoutez-y des recettes, ajustez les portions.",
},
{
route: null,
targetKey: "planning-add-recipe",
title: "Ajouter une recette",
content: "Ce bouton ouvre le sélecteur de recettes pour remplir un créneau.",
},
{
route: null,
targetKey: "planning-cook-button",
title: "Passer en cuisine",
content:
"Une fois la semaine planifiée, ce bouton réorganise tout en une séance de cuisine optimisée.",
},
{
route: "/liste-de-courses",
targetKey: "shopping-list-summary",
title: "Tout ce qu'il vous faut",
content: "Les ingrédients de toutes vos recettes de la semaine, regroupés et additionnés.",
},
],
},
{
key: "techStepMetadata",
name: "Corriger les techniques d'une recette",
steps: [
{
route: null,
targetKey: "techstep-description",
title: "Les techniques repérées",
content:
"Les mots surlignés sont des techniques de cuisson détectées automatiquement. Sélectionnez un passage pour le corriger.",
},
{
route: null,
targetKey: "techstep-technique-picker",
title: "Choisir la bonne technique",
content:
"Indiquez la technique correcte pour le passage sélectionné — la correction est immédiate.",
},
],
},
];
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
// businesses to declare — a standard, defensible reference list rather than // businesses to declare — a standard, defensible reference list rather than
// an invented one. Split into ALLERGY (classic IgE-mediated immune // an invented one. Split into ALLERGY (classic IgE-mediated immune
@ -1416,4 +1524,47 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
if (dietLinks.length > 0) { if (dietLinks.length > 0) {
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true }); await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
} }
await seedTourScenarios(prisma);
}
/**
* Seeds the guided-tour reference data (issues #42/#44):
*
* - `TourTarget` rows — upserted by key with `update: { description }`, same
* as `Unit` above: a reseed must correct an edited hint, not just skip.
* - `TourScenario` + its steps — **create-if-absent only**. Once a scenario
* exists, an admin owns its `name`/`enabled`/steps (that's the whole
* point of #44's CRUD), so a later deploy's reseed must never clobber
* those. In the test suite every `resetDatabase()` truncates first, so
* the defaults are always recreated fresh there.
*/
async function seedTourScenarios(prisma: PrismaClient): Promise<void> {
for (const { key, description } of TOUR_TARGETS) {
await prisma.tourTarget.upsert({
where: { key },
update: { description },
create: { key, description },
});
}
for (const { key, name, steps } of TOUR_SCENARIOS) {
const existing = await prisma.tourScenario.findUnique({ where: { key } });
if (existing) continue;
await prisma.tourScenario.create({
data: {
key,
name,
steps: {
create: steps.map((step, index) => ({
order: index,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
},
},
});
}
} }

View file

@ -1,5 +1,9 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { import {
type CompletedToursView,
completeTourSchema,
ErrorCode,
updateAllergiesSchema, updateAllergiesSchema,
updateDietSchema, updateDietSchema,
updateDislikedIngredientsSchema, updateDislikedIngredientsSchema,
@ -8,7 +12,9 @@ import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { import {
getAllergyIds, getAllergyIds,
getCompletedTourKeys,
getDislikedIngredientIds, getDislikedIngredientIds,
markTourSeen,
updateAllergies, updateAllergies,
updateDiet, updateDiet,
updateDislikedIngredients, updateDislikedIngredients,
@ -17,6 +23,14 @@ import {
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */ /** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
export const profileRouter = Router(); export const profileRouter = Router();
/** Express types a route param `string | undefined` even for a segment that always matches when the route does — satisfies TS, unreachable in practice. */
function requireParam(value: string | undefined): string {
if (value === undefined) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Missing route parameter");
}
return value;
}
/** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */ /** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */
profileRouter.patch( profileRouter.patch(
"/diet", "/diet",
@ -70,3 +84,29 @@ profileRouter.patch(
res.status(200).json(ids); res.status(200).json(ids);
}), }),
); );
/** Which guided-tour scenarios this profile has already seen — the web tour engine reads this once per session (issues #42/#44). */
profileRouter.get(
"/tours",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
const completedTourKeys = await getCompletedTourKeys(res.locals.userProfile.id);
res.status(200).json({ completedTourKeys } satisfies CompletedToursView);
}),
);
/**
* Marks a guided-tour scenario as seen — idempotent. An empty body means
* "completed"; `{ "skipped": true }` means the user dismissed it (different
* analytics event, same "don't re-nag" effect). 404s on an unknown/disabled
* tour key.
*/
profileRouter.post(
"/tours/:tourKey/complete",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const { skipped } = completeTourSchema.parse(req.body ?? {});
await markTourSeen(res.locals.userProfile.id, requireParam(req.params.tourKey), skipped);
res.status(204).end();
}),
);

View file

@ -1,6 +1,7 @@
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared"; import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { toSafeProfile } from "../../lib/safe-profile.js"; import { toSafeProfile } from "../../lib/safe-profile.js";
/** /**
@ -149,3 +150,57 @@ export async function updateDislikedIngredients(
throw err; // see updateDiet()'s catch comment above throw err; // see updateDiet()'s catch comment above
} }
} }
/**
* The `key`s of every guided-tour scenario this profile has already seen
* (completed or skipped — both leave a `TourProgress` row). The web tour
* engine reads this once per session to decide which tours to auto-start
* (issues #42/#44).
*/
export async function getCompletedTourKeys(userProfileId: number): Promise<string[]> {
try {
const rows = await prisma.tourProgress.findMany({
where: { userProfileId },
select: { scenario: { select: { key: true } } },
});
return rows.map((row) => row.scenario.key);
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/**
* Marks a guided-tour scenario as seen by this profile — idempotent upsert,
* safe to call again (e.g. the engine retries after a flaky network). Emits
* `tour.completed` or `tour.skipped` for the admin dashboard (issue #44's
* analytics — the dashboard renders any new `AnalyticsEvent` type
* generically, no frontend change needed).
*
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `tourKey` doesn't match an
* **enabled** `TourScenario` (a disabled tour can't be completed).
*/
export async function markTourSeen(
userProfileId: number,
tourKey: string,
skipped: boolean,
): Promise<void> {
try {
const scenario = await prisma.tourScenario.findUnique({ where: { key: tourKey } });
if (!scenario?.enabled) {
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No enabled tour with key ${tourKey}`);
}
await prisma.tourProgress.upsert({
where: { userProfileId_scenarioId: { userProfileId, scenarioId: scenario.id } },
update: {},
create: { userProfileId, scenarioId: scenario.id },
});
analytics.recordEvent(skipped ? "tour.skipped" : "tour.completed", {
actorId: userProfileId,
context: { tourKey },
});
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}

View file

@ -6,6 +6,7 @@ import {
getIngredients, getIngredients,
getSources, getSources,
getTechSteps, getTechSteps,
getTours,
getUnits, getUnits,
getUtensils, getUtensils,
} from "./reference.service.js"; } from "./reference.service.js";
@ -69,3 +70,14 @@ referenceRouter.get(
res.status(200).json(await getSources()); res.status(200).json(await getSources());
}), }),
); );
// Enabled guided-tour scenarios + their steps — read by the web tour
// engine (issue #42). Public like every route here: the general tour can
// start right after signup, before onboarding finishes. Admin CRUD of
// these lives under `/admin/api/tours` (issue #44).
referenceRouter.get(
"/tours",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getTours());
}),
);

View file

@ -4,6 +4,7 @@ import type {
IngredientView, IngredientView,
SourceView, SourceView,
TechStepView, TechStepView,
TourScenarioView,
UnitView, UnitView,
UtensilView, UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
@ -173,3 +174,32 @@ export async function getIngredients(): Promise<IngredientView[]> {
throw err; // see getDiets()'s catch comment above throw err; // see getDiets()'s catch comment above
} }
} }
/**
* Every **enabled** guided-tour scenario with its ordered steps, for the
* web tour engine (issues #42/#44). A disabled scenario is hidden here
* without being deleted (an admin can toggle it back on). Steps come back
* ordered; `route`/`title`/`content` pass straight through.
*/
export async function getTours(): Promise<TourScenarioView[]> {
try {
const scenarios = await prisma.tourScenario.findMany({
where: { enabled: true },
orderBy: { key: "asc" },
include: { steps: { orderBy: { order: "asc" } } },
});
return scenarios.map((scenario) => ({
key: scenario.key,
name: scenario.name,
steps: scenario.steps.map((step) => ({
order: step.order,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}

View file

@ -48,6 +48,7 @@ export async function resetDatabase() {
"recipe_ingredient", "step_tech_step", "step", "tech_step", "recipe_ingredient", "step_tech_step", "step", "tech_step",
"recipe", "ingredients", "sources", "unit", "recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house", "user_profiles", "diet", "house",
"tour_progress", "tour_scenario_step", "tour_scenario", "tour_target",
"admin_users", "analytics_events", "worker_heartbeats", "backfill_state" "admin_users", "analytics_events", "worker_heartbeats", "backfill_state"
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;
`); `);

View file

@ -195,4 +195,95 @@ describe("Profile", () => {
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND); expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
}); });
}); });
describe("Guided tours (GET /profile/tours, POST /profile/tours/:key/complete)", () => {
it("rejects an unauthenticated request with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/profile/tours");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("starts with no completed tours for a fresh profile", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/profile/tours");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal({ completedTourKeys: [] });
});
it("marks a tour seen and reports it back", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const post = await agent.post("/profile/tours/general/complete").send({});
expect(post.status).to.equal(204);
const res = await agent.get("/profile/tours");
expect(res.body.completedTourKeys).to.deep.equal(["general"]);
});
it("is idempotent — completing the same tour twice stays a single row", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await agent.post("/profile/tours/general/complete").send({});
const second = await agent.post("/profile/tours/general/complete").send({});
expect(second.status).to.equal(204);
const res = await agent.get("/profile/tours");
expect(res.body.completedTourKeys).to.deep.equal(["general"]);
});
it("accepts { skipped: true } and still marks the tour seen", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const post = await agent
.post("/profile/tours/techStepMetadata/complete")
.send({ skipped: true });
expect(post.status).to.equal(204);
const res = await agent.get("/profile/tours");
expect(res.body.completedTourKeys).to.deep.equal(["techStepMetadata"]);
});
it("rejects an unknown tour key with 404 TOUR_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.post("/profile/tours/does-not-exist/complete").send({});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
});
it("rejects completing a disabled tour with 404 TOUR_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await prisma.tourScenario.update({
where: { key: "techStepMetadata" },
data: { enabled: false },
});
const res = await agent.post("/profile/tours/techStepMetadata/complete").send({});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
});
it("keeps tours per-profile — one user's completion doesn't leak to another", async () => {
const alice = request.agent(app);
await alice.post("/auth/signup").send(buildSignupPayload());
await alice.post("/profile/tours/general/complete").send({});
const bob = request.agent(app);
await bob.post("/auth/signup").send(buildSignupPayload());
const res = await bob.get("/profile/tours");
expect(res.body.completedTourKeys).to.deep.equal([]);
});
});
}); });

View file

@ -3,7 +3,12 @@ import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { seedReferenceData, TECH_STEPS, UTENSILS } from "../src/db/reference-seed-data.js"; import {
seedReferenceData,
TECH_STEPS,
TOUR_SCENARIOS,
UTENSILS,
} from "../src/db/reference-seed-data.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js"; import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
import { import {
clearRecipeSources, clearRecipeSources,
@ -234,4 +239,40 @@ describe("Reference data", () => {
expect(res.body.map((s: { name: string }) => s.name)).to.deep.equal(["Alpha", "Bravo"]); expect(res.body.map((s: { name: string }) => s.name)).to.deep.equal(["Alpha", "Bravo"]);
}); });
}); });
describe("GET /reference/tours", () => {
it("returns every seeded scenario with its steps in order", async () => {
const res = await request(app).get("/reference/tours");
expect(res.status).to.equal(200);
expect(res.body.map((t: { key: string }) => t.key)).to.have.members(
TOUR_SCENARIOS.map((s) => s.key),
);
const general = res.body.find((t: { key: string }) => t.key === "general");
expect(general.name).to.be.a("string").and.not.empty;
expect(general.steps).to.have.length(TOUR_SCENARIOS[0].steps.length);
expect(general.steps[0]).to.have.keys(["order", "route", "targetKey", "title", "content"]);
expect(general.steps.map((s: { order: number }) => s.order)).to.deep.equal(
general.steps.map((_: unknown, i: number) => i),
);
// First step of the general tour navigates to "/" and points at the
// planning nav link (see TOUR_SCENARIOS).
expect(general.steps[0].route).to.equal("/");
expect(general.steps[0].targetKey).to.equal("nav-planning");
});
it("excludes a disabled scenario", async () => {
await prisma.tourScenario.update({
where: { key: "techStepMetadata" },
data: { enabled: false },
});
const res = await request(app).get("/reference/tours");
const keys = res.body.map((t: { key: string }) => t.key);
expect(keys).to.include("general");
expect(keys).to.not.include("techStepMetadata");
});
});
}); });

View file

@ -45,6 +45,7 @@
"STEP_NOT_FOUND": "Cette étape n'existe pas", "STEP_NOT_FOUND": "Cette étape n'existe pas",
"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",
"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,6 +75,8 @@ 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`. */
TOUR_NOT_FOUND = 4053,
/** 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

@ -30,4 +30,5 @@ export * from "./types/reference.js";
export * from "./types/shopping-list.js"; export * from "./types/shopping-list.js";
export * from "./types/sources.js"; export * from "./types/sources.js";
export * from "./types/tech-step-worker.js"; export * from "./types/tech-step-worker.js";
export * from "./types/tour.js";
export * from "./types/user-profile.js"; export * from "./types/user-profile.js";

View file

@ -27,3 +27,17 @@ export const updateDislikedIngredientsSchema = z.object({
}); });
/** Inferred TS type for {@link updateDislikedIngredientsSchema}'s validated output. */ /** Inferred TS type for {@link updateDislikedIngredientsSchema}'s validated output. */
export type UpdateDislikedIngredientsInput = z.infer<typeof updateDislikedIngredientsSchema>; export type UpdateDislikedIngredientsInput = z.infer<typeof updateDislikedIngredientsSchema>;
/**
* Payload accepted by `POST /profile/tours/:tourKey/complete` — an empty
* body is fine (`skipped` defaults to `false`). `skipped: true` means the
* user dismissed the tour rather than stepping through it; both mark it
* "seen" (no re-nag), but they emit different analytics events
* (`tour.completed` vs `tour.skipped`) so an over-long tour is visible in
* the dashboard. See issues #42/#44.
*/
export const completeTourSchema = z.object({
skipped: z.boolean().optional().default(false),
});
/** Inferred TS type for {@link completeTourSchema}'s validated output. */
export type CompleteTourInput = z.infer<typeof completeTourSchema>;

View file

@ -0,0 +1,31 @@
/**
* Guided-tour scenarios (issues #42/#44), as served by
* `GET /reference/tours` and consumed by the web tour engine. The engine
* drives `driver.js` from this shape instead of an in-code registry —
* scenarios and their steps are DB-backed and admin-editable (#44), so
* `title`/`content` are free text here, not i18n keys.
*/
export interface TourScenarioStepView {
/** 0-based position within the scenario. */
order: number;
/** Route to navigate to before this step, or `null` to stay on the current one. */
route: string | null;
/** Suffix of the `data-tour-<key>` attribute the highlighted element carries. */
targetKey: string;
title: string;
content: string;
}
/** One tour scenario with its ordered steps. Only `enabled` scenarios are returned. */
export interface TourScenarioView {
/** Stable slug — `"general"` / `"techStepMetadata"` for the seeded ones. */
key: string;
/** Admin-facing label, e.g. "Tour d'orientation". */
name: string;
steps: TourScenarioStepView[];
}
/** `GET /profile/tours` — which tour scenarios the current profile has already seen (completed or skipped). */
export interface CompletedToursView {
completedTourKeys: string[];
}