From cd8ce42df7db98b489acfac2f7aa04540533ca26 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 6 Sep 2026 05:03:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20mod=C3=A8le=20de=20donn=C3=A9es=20?= =?UTF-8?q?+=20API=20des=20sc=C3=A9narios=20de=20didacticiel=20(#42/#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Socle backend, entièrement testable en CI, pour le tutoriel guidé. Le moteur front (driver.js), l'instrumentation `data-tour-*` et le CRUD admin viendront ensuite. Schéma (`schema.prisma` + migration `20260906120000_tour_scenarios`) — les 4 modèles de #44, qui révisent le registre statique prévu par #42 : - `TourTarget` (`key` PK + `description`) : catalogue des cibles de surlignage valides, seedé, comme `TechStep`/`Unit`. - `TourScenario` (`key` stable, `name`, `enabled`) : un scénario rejouable. - `TourScenarioStep` (`order`, `route?`, `targetKey`, `title`, `content`) : `title`/`content` sont du **texte libre**, pas des clés i18n (#44 : contenu administrable → texte, comme le nom d'une recette). - `TourProgress` (`userProfileId` + `scenarioId`, PK composite) : présence d'une ligne = scénario vu (terminé *ou* passé). Seed (`reference-seed-data.ts`) : les 9 `TourTarget` correspondant aux `data-tour-*` à poser côté front, + les 2 scénarios de #42 (`general`, `techStepMetadata`) **créés seulement s'ils sont absents** (un admin en devient propriétaire ensuite). `techStepMetadata` reste court pour l'instant — #36 (popover qui se ferme en pleine sélection) est ouvert, v1 ne couvre que les gestes qui marchent déjà. API : - `GET /reference/tours` (public) → scénarios **activés** + étapes ordonnées, forme consommée directement par le futur `useTour()`. - `GET /profile/tours` → `{ completedTourKeys: string[] }`. - `POST /profile/tours/:tourKey/complete` → upsert idempotent ; corps vide = terminé, `{ skipped: true }` = passé. Émet `analytics.recordEvent` `tour.completed` / `tour.skipped` (le dashboard admin agrège déjà tout type d'`AnalyticsEvent` génériquement — analytics de #44 quasi gratuites). 404 `TOUR_NOT_FOUND` (nouveau code 4053) sur une clé inconnue/désactivée. `reset-db.ts` truncate les 4 nouvelles tables (reseedées juste après). Tests Mocha : `GET /reference/tours` (scénarios + étapes ordonnées, exclusion d'un scénario désactivé) ; `/profile/tours` GET+POST (upsert idempotent, `skipped`, 404 clé inconnue, 404 tour désactivé, isolation par profil). Pas de base Postgres locale → exécutés en CI. Refs #42, #44 Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 65 ++++++++ apps/api/prisma/schema.prisma | 82 ++++++++++ apps/api/src/db/reference-seed-data.ts | 151 ++++++++++++++++++ .../api/src/modules/profile/profile.routes.ts | 40 +++++ .../src/modules/profile/profile.service.ts | 55 +++++++ .../src/modules/reference/reference.routes.ts | 12 ++ .../modules/reference/reference.service.ts | 30 ++++ apps/api/test-support/reset-db.ts | 1 + apps/api/test/profile.test.ts | 91 +++++++++++ apps/api/test/reference.test.ts | 43 ++++- apps/web/src/locales/fr/translation.json | 1 + packages/shared/src/errors/error-codes.ts | 2 + packages/shared/src/index.ts | 1 + packages/shared/src/schemas/profile.ts | 14 ++ packages/shared/src/types/tour.ts | 31 ++++ 15 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20260906120000_tour_scenarios/migration.sql create mode 100644 packages/shared/src/types/tour.ts diff --git a/apps/api/prisma/migrations/20260906120000_tour_scenarios/migration.sql b/apps/api/prisma/migrations/20260906120000_tour_scenarios/migration.sql new file mode 100644 index 0000000..ec659b4 --- /dev/null +++ b/apps/api/prisma/migrations/20260906120000_tour_scenarios/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0102fbf..61c4e92 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -125,6 +125,9 @@ model UserProfile { /// list regardless of that real-world cardinality. administeredHouses House[] @relation("HouseAdmin") 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 /// view a recipe may correct its tech-step matches, not just its author — /// see `StepTechStepCorrection.correctorId`). @@ -1107,3 +1110,82 @@ model BackfillState { @@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-` 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-` 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") +} diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index 27c1e25..0458d66 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -191,6 +191,114 @@ export const UTENSILS: string[] = [ "mandoline", ]; +// Guided-tour highlight targets (issues #42/#44). Each `key` is the suffix +// of a `data-tour-` 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 // businesses to declare — a standard, defensible reference list rather than // an invented one. Split into ALLERGY (classic IgE-mediated immune @@ -1416,4 +1524,47 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { if (dietLinks.length > 0) { 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 { + 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, + })), + }, + }, + }); + } } diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts index 209f1a7..7db11a4 100644 --- a/apps/api/src/modules/profile/profile.routes.ts +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -1,5 +1,9 @@ +import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { + type CompletedToursView, + completeTourSchema, + ErrorCode, updateAllergiesSchema, updateDietSchema, updateDislikedIngredientsSchema, @@ -8,7 +12,9 @@ import { Router } from "express"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { getAllergyIds, + getCompletedTourKeys, getDislikedIngredientIds, + markTourSeen, updateAllergies, updateDiet, 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. */ 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). */ profileRouter.patch( "/diet", @@ -70,3 +84,29 @@ profileRouter.patch( 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(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(async (req, res) => { + const { skipped } = completeTourSchema.parse(req.body ?? {}); + await markTourSeen(res.locals.userProfile.id, requireParam(req.params.tourKey), skipped); + res.status(204).end(); + }), +); diff --git a/apps/api/src/modules/profile/profile.service.ts b/apps/api/src/modules/profile/profile.service.ts index 1b0197d..32d0085 100644 --- a/apps/api/src/modules/profile/profile.service.ts +++ b/apps/api/src/modules/profile/profile.service.ts @@ -1,6 +1,7 @@ import { HttpError } from "@batch-cooking/error-tools"; import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; +import { analytics } from "../../lib/analytics.service.js"; import { toSafeProfile } from "../../lib/safe-profile.js"; /** @@ -149,3 +150,57 @@ export async function updateDislikedIngredients( 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 { + 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 { + 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 + } +} diff --git a/apps/api/src/modules/reference/reference.routes.ts b/apps/api/src/modules/reference/reference.routes.ts index 074bdb5..5952752 100644 --- a/apps/api/src/modules/reference/reference.routes.ts +++ b/apps/api/src/modules/reference/reference.routes.ts @@ -6,6 +6,7 @@ import { getIngredients, getSources, getTechSteps, + getTours, getUnits, getUtensils, } from "./reference.service.js"; @@ -69,3 +70,14 @@ referenceRouter.get( 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()); + }), +); diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index b00e7f3..1dcea17 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -4,6 +4,7 @@ import type { IngredientView, SourceView, TechStepView, + TourScenarioView, UnitView, UtensilView, } from "@batch-cooking/shared"; @@ -173,3 +174,32 @@ export async function getIngredients(): Promise { 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 { + 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 + } +} diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index f283a32..67af1e0 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -48,6 +48,7 @@ export async function resetDatabase() { "recipe_ingredient", "step_tech_step", "step", "tech_step", "recipe", "ingredients", "sources", "unit", "user_profiles", "diet", "house", + "tour_progress", "tour_scenario_step", "tour_scenario", "tour_target", "admin_users", "analytics_events", "worker_heartbeats", "backfill_state" RESTART IDENTITY CASCADE; `); diff --git a/apps/api/test/profile.test.ts b/apps/api/test/profile.test.ts index 87a221c..553f200 100644 --- a/apps/api/test/profile.test.ts +++ b/apps/api/test/profile.test.ts @@ -195,4 +195,95 @@ describe("Profile", () => { 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([]); + }); + }); }); diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 2acc9e3..3d48a2f 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -3,7 +3,12 @@ import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.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 { clearRecipeSources, @@ -234,4 +239,40 @@ describe("Reference data", () => { 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"); + }); + }); }); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index c7818e9..44c6b73 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -45,6 +45,7 @@ "STEP_NOT_FOUND": "Cette étape n'existe pas", "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", "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 91c8a56..538cb7a 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -75,6 +75,8 @@ 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`. */ + TOUR_NOT_FOUND = 4053, /** 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/index.ts b/packages/shared/src/index.ts index c35526f..5dfea77 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -30,4 +30,5 @@ export * from "./types/reference.js"; export * from "./types/shopping-list.js"; export * from "./types/sources.js"; export * from "./types/tech-step-worker.js"; +export * from "./types/tour.js"; export * from "./types/user-profile.js"; diff --git a/packages/shared/src/schemas/profile.ts b/packages/shared/src/schemas/profile.ts index d6d2279..7086cc3 100644 --- a/packages/shared/src/schemas/profile.ts +++ b/packages/shared/src/schemas/profile.ts @@ -27,3 +27,17 @@ export const updateDislikedIngredientsSchema = z.object({ }); /** Inferred TS type for {@link updateDislikedIngredientsSchema}'s validated output. */ export type UpdateDislikedIngredientsInput = z.infer; + +/** + * 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; diff --git a/packages/shared/src/types/tour.ts b/packages/shared/src/types/tour.ts new file mode 100644 index 0000000..99ec61a --- /dev/null +++ b/packages/shared/src/types/tour.ts @@ -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-` 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[]; +} -- 2.45.2