feat(api): modèle de données + API des scénarios de didacticiel (#42/#44 — socle backend) #58
15 changed files with 618 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
@ -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-<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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-<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
|
||||
// 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<void> {
|
|||
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<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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<IngredientView[]> {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -27,3 +27,17 @@ export const updateDislikedIngredientsSchema = z.object({
|
|||
});
|
||||
/** Inferred TS type for {@link updateDislikedIngredientsSchema}'s validated output. */
|
||||
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>;
|
||||
|
|
|
|||
31
packages/shared/src/types/tour.ts
Normal file
31
packages/shared/src/types/tour.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Reference in a new issue