feat(api): modèle de données + API des scénarios de didacticiel (#42/#44)
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 <noreply@anthropic.com>
This commit is contained in:
parent
9174f80282
commit
cd8ce42df7
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.
|
/// 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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
`);
|
`);
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
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