batchCooking/apps/api/src/modules/reference/reference.service.ts
Nicolas cd8ce42df7
All checks were successful
CI / lint (push) Successful in 1m0s
CI / build (push) Successful in 1m16s
CI / intent-service-test (push) Successful in 3m34s
CI / e2e (push) Successful in 6m38s
CI / test (push) Successful in 15m49s
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>
2026-09-06 05:08:43 +02:00

205 lines
7 KiB
TypeScript

import type {
AllergyView,
DietView,
IngredientView,
SourceView,
TechStepView,
TourScenarioView,
UnitView,
UtensilView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/**
* All reference dietary regimes, ordered by `key` — small, static list (see
* prisma/seed.ts). `key` is a stable slug, not the display label (see
* {@link DietView}), so this is an alphabetical-by-slug order rather than a
* true French alphabetical one — close enough for a 5-item list, and the
* server has no other order to offer now that the label itself only exists
* client-side (`apps/web`'s `locales/fr/translation.json`).
*/
export async function getDiets(): Promise<DietView[]> {
try {
return await prisma.diet.findMany({ orderBy: { key: "asc" } });
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `async` body without a
// try/catch per the repo's convention.
throw err;
}
}
/**
* All reference allergens, ordered by key (see {@link getDiets} for why key,
* not label). `Allergy` carries no `key` of its own — it's the selectable
* instance of a keyed `Category` (see schema.prisma) — so this resolves
* each allergen's key from its category and flattens the split away for
* callers.
*/
export async function getAllergies(): Promise<AllergyView[]> {
try {
const allergies = await prisma.allergy.findMany({
include: { category: { select: { key: true, kind: true } } },
orderBy: { category: { key: "asc" } },
});
return allergies.map((allergy) => ({
id: allergy.id,
key: allergy.category.key,
kind: allergy.category.kind,
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* All reference recipe-ingredient units, ordered by key (see {@link getDiets}
* for why) — small, static list (see `reference-seed-data.ts`'s `UNITS`).
* `toBaseFactor` comes back as a Prisma `Decimal`, converted to a plain
* `number` here the same way `recipe.service.ts` does for
* `RecipeIngredient.quantity`.
*/
export async function getUnits(): Promise<UnitView[]> {
try {
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
return units.map((unit) => ({
id: unit.id,
key: unit.key,
type: unit.type,
toBaseFactor: Number(unit.toBaseFactor),
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* All reference cooking techniques, ordered by key (see {@link getDiets}
* for why) — small, static list (see `reference-seed-data.ts`'s
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
*/
export async function getTechSteps(): Promise<TechStepView[]> {
try {
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* All reference cooking utensils, ordered by key (see {@link getDiets} for
* why) — small, static list (see `reference-seed-data.ts`'s `UTENSILS`),
* same bare `id`/`key` shape as {@link getTechSteps}.
*/
export async function getUtensils(): Promise<UtensilView[]> {
try {
return await prisma.utensil.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* Every implemented recipe source, ordered by name (not `key` — unlike
* every other reference catalog, `name` here *is* the display string a
* household picks from, see {@link SourceView}, so alphabetical-by-name is
* what a real picker should show). Empty until a concrete adapter is
* registered (see `recipe-source-registry.ts`) and synced (see
* `recipe-source-sync.ts`'s `syncRecipeSources`).
*/
export async function getSources(): Promise<SourceView[]> {
try {
// Explicit `select` — `url` exists on the `Source` row but isn't part of
// `SourceView` yet, so it must not leak into the response the way a bare
// `findMany()` would let it.
return await prisma.source.findMany({
select: {
id: true,
key: true,
name: true,
official: true,
iconUrl: true,
},
orderBy: { name: "asc" },
});
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* All reference ingredients, ordered by key (see {@link getDiets} for why),
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
* and compatible diet regimes (see `IngredientDiet`) — same flattening
* approach as {@link getAllergies}. Ingredients with no linked
* allergen/diet come back with `allergens: []`/`diets: []`.
*
* Excludes placeholder rows (`Ingredient.isPlaceholder` — the free-text
* ingredients users type when the catalog falls short): this is the
* *browsable* catalog, and a placeholder is a per-recipe-line stand-in, not
* a real entry anyone should be able to pick again.
*/
export async function getIngredients(): Promise<IngredientView[]> {
try {
const ingredients = await prisma.ingredient.findMany({
where: { isPlaceholder: false },
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
orderBy: { key: "asc" },
});
return ingredients.map((ingredient) => ({
id: ingredient.id,
key: ingredient.key,
icon: ingredient.icon,
category: ingredient.category,
subcategory: ingredient.subcategory,
reproducible: ingredient.reproducible,
allergens: ingredient.allergies.map(({ allergy }) => ({
id: allergy.id,
key: allergy.category.key,
kind: allergy.category.kind,
})),
diets: ingredient.diets.map(({ diet }) => ({
id: diet.id,
key: diet.key,
})),
// Always a real catalog row here (placeholders are filtered out above).
isPlaceholder: false,
displayName: null,
}));
} catch (err) {
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
}
}