Compare commits
No commits in common. "d1948954a6fbf0e3fdfe253126acba9a36228bbc" and "9174f802829ca12ae79c120c6b801423736abfe8" have entirely different histories.
d1948954a6
...
9174f80282
15 changed files with 1 additions and 618 deletions
|
|
@ -1,65 +0,0 @@
|
||||||
-- 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,9 +125,6 @@ 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`).
|
||||||
|
|
@ -1110,82 +1107,3 @@ 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,114 +191,6 @@ 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
|
||||||
|
|
@ -1524,47 +1416,4 @@ 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,9 +1,5 @@
|
||||||
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,
|
||||||
|
|
@ -12,9 +8,7 @@ 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,
|
||||||
|
|
@ -23,14 +17,6 @@ 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",
|
||||||
|
|
@ -84,29 +70,3 @@ 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,7 +1,6 @@
|
||||||
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -150,57 +149,3 @@ 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,7 +6,6 @@ import {
|
||||||
getIngredients,
|
getIngredients,
|
||||||
getSources,
|
getSources,
|
||||||
getTechSteps,
|
getTechSteps,
|
||||||
getTours,
|
|
||||||
getUnits,
|
getUnits,
|
||||||
getUtensils,
|
getUtensils,
|
||||||
} from "./reference.service.js";
|
} from "./reference.service.js";
|
||||||
|
|
@ -70,14 +69,3 @@ 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,7 +4,6 @@ import type {
|
||||||
IngredientView,
|
IngredientView,
|
||||||
SourceView,
|
SourceView,
|
||||||
TechStepView,
|
TechStepView,
|
||||||
TourScenarioView,
|
|
||||||
UnitView,
|
UnitView,
|
||||||
UtensilView,
|
UtensilView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
@ -174,32 +173,3 @@ 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,7 +48,6 @@ 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,95 +195,4 @@ 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,12 +3,7 @@ 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 {
|
import { seedReferenceData, TECH_STEPS, UTENSILS } from "../src/db/reference-seed-data.js";
|
||||||
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,
|
||||||
|
|
@ -239,40 +234,4 @@ 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,7 +45,6 @@
|
||||||
"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,8 +75,6 @@ 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,5 +30,4 @@ 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,17 +27,3 @@ 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>;
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
/**
|
|
||||||
* 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