diff --git a/README.md b/README.md index 9e40008..afdac88 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,31 @@ premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, c a mis au jour une contrainte générique trop stricte, corrigée à la source : [specs/backend-architecture.md](specs/backend-architecture.md). +## Données de référence — régimes & allergènes (apps/api) + +- `GET /reference/diets` — liste des régimes alimentaires (`Diet`, 5 valeurs seedées). +- `GET /reference/allergies` — liste des allergènes sélectionnables, `{ id, name }` + (le nom vient de `Category.name` — la table `allergy` elle-même ne porte pas de + nom, voir `schema.prisma` — chaque allergène = une `Category` + une unique + `Allergy` sous cette catégorie). + +Les deux sont **publics** (pas de `requireAuth`) : ce sont des données de référence, +pas des données de foyer, et le wizard d'inscription doit pouvoir les lire avant +qu'un compte (donc une session) n'existe. + +Données seedées via `apps/api/prisma/seed.ts` (`pnpm --filter api prisma:seed`, ou +automatiquement après `prisma migrate reset` — config `prisma.seed` dans +`package.json`). La logique réelle (listes + upsert idempotent) vit dans +`src/db/reference-seed-data.ts`, partagée avec `test-support/reset-db.ts` : chaque +test repart d'une base **avec** ces données de référence, pas de tables vides — +nécessaire pour tester `dietId`/`allergyIds` sur de vraies lignes. + +`Diet.name` et `Category.name` sont `@unique` — ajouté à ce schéma (pas dans le doc +spec d'origine) précisément pour permettre cet upsert idempotent par nom. + +Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste +standard, pas inventée. + ## Page de connexion / inscription (apps/web) - `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) : diff --git a/apps/api/features/reference.feature b/apps/api/features/reference.feature new file mode 100644 index 0000000..10b7718 --- /dev/null +++ b/apps/api/features/reference.feature @@ -0,0 +1,14 @@ +Feature: Reference data (diets, allergens) + As a visitor filling in the signup wizard, or a signed-in user editing their profile + I want to read the list of dietary regimes and allergens + So that I can pick from them — before an account necessarily exists + + Scenario: A visitor without a session can read the list of dietary regimes + When I send a GET request to "/reference/diets" + Then the response status should be 200 + And the reference list response should include "Végétarien" + + Scenario: A visitor without a session can read the list of allergens + When I send a GET request to "/reference/allergies" + Then the response status should be 200 + And the reference list response should include "Arachides" diff --git a/apps/api/features/step-definitions/reference.steps.ts b/apps/api/features/step-definitions/reference.steps.ts new file mode 100644 index 0000000..e4e7c1b --- /dev/null +++ b/apps/api/features/step-definitions/reference.steps.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import { Then } from "@cucumber/cucumber"; +import type { CustomWorld } from "../support/world.js"; + +Then( + "the reference list response should include {string}", + function (this: CustomWorld, name: string) { + const names = (this.response.body as Array<{ name: string }>).map((item) => item.name); + assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`); + }, +); diff --git a/apps/api/package.json b/apps/api/package.json index c7860cc..ddb49cb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,8 +11,12 @@ "test:bdd": "cross-env NODE_ENV=test NODE_OPTIONS=--import=tsx cucumber-js", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", + "prisma:seed": "prisma db seed", "postinstall": "prisma generate" }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, "dependencies": { "@batch-cooking/error-tools": "workspace:*", "@batch-cooking/express-tools": "workspace:*", diff --git a/apps/api/prisma/migrations/20260816230050_unique_diet_category_name/migration.sql b/apps/api/prisma/migrations/20260816230050_unique_diet_category_name/migration.sql new file mode 100644 index 0000000..09919e5 --- /dev/null +++ b/apps/api/prisma/migrations/20260816230050_unique_diet_category_name/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE UNIQUE INDEX "category_name_key" ON "category"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "diet_name_key" ON "diet"("name"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c94073e..2d92313 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -22,9 +22,13 @@ model House { @@map("house") } +/// `name` is `@unique` — not in the original spec doc, added so the seed +/// script (prisma/seed.ts) can `upsert` by name and stay idempotent/safe to +/// re-run, and so two reference rows can never silently duplicate the same +/// regime. model Diet { id Int @id @default(autoincrement()) - name String + name String @unique users UserProfile[] @@ -32,9 +36,10 @@ model Diet { } /// Enumeration-style table, meant to grow over time (e.g. allergy nuances). +/// `name` is `@unique` for the same reason as `Diet.name` above. model Category { id Int @id @default(autoincrement()) - name String + name String @unique allergies Allergy[] diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..87534e6 --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -0,0 +1,18 @@ +import { PrismaClient } from "@prisma/client"; +import { seedReferenceData } from "../src/db/reference-seed-data.js"; + +// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in +// the rest of the app's config/env plumbing this doesn't need), run via +// `prisma db seed` (see the `prisma.seed` entry in package.json) — either +// directly (`pnpm --filter api prisma:seed`) or automatically after +// `prisma migrate reset`. The actual data/logic lives in +// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`. +const prisma = new PrismaClient(); + +seedReferenceData(prisma) + .then(() => prisma.$disconnect()) + .catch(async (err) => { + console.error(err); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 63baf0f..6b6e2f7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,6 +5,7 @@ import type { Express, Request, Response } from "express"; import { env } from "./config/env.js"; import { authRouter } from "./modules/auth/auth.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js"; +import { referenceRouter } from "./modules/reference/reference.routes.js"; /** * Builds the API's `ExpressServer`: standard middleware, routes, and the @@ -24,6 +25,7 @@ export function createServer(): ExpressServer { server.mountRouter("/auth", authRouter); server.mountRouter("/planning", planningRouter); + server.mountRouter("/reference", referenceRouter); // No route matched — same shape as every other error response, via the // shared ErrorCode contract, so clients never special-case 404s. diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts new file mode 100644 index 0000000..ef0d449 --- /dev/null +++ b/apps/api/src/db/reference-seed-data.ts @@ -0,0 +1,55 @@ +import type { PrismaClient } from "@prisma/client"; + +// Short, optional-to-pick regime list — `UserProfile.dietId` stays +// nullable, this is not meant to be exhaustive. +const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans gluten"]; + +// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food +// businesses to declare — a standard, defensible reference list rather than +// an invented one. +const ALLERGENS = [ + "Gluten", + "Crustacés", + "Œufs", + "Poissons", + "Arachides", + "Soja", + "Lait", + "Fruits à coque", + "Céleri", + "Moutarde", + "Graines de sésame", + "Sulfites", + "Lupin", + "Mollusques", +]; + +/** + * Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent + * (safe to call against a database that already has this data — upserts by + * `name`, both `@unique`) — used both by `prisma/seed.ts` (the CLI entry + * point, `prisma db seed`) and by `test-support/reset-db.ts` (so every + * test starts from the same realistic reference data the real app seeds, + * not an empty table). + */ +export async function seedReferenceData(prisma: PrismaClient): Promise { + for (const name of DIETS) { + await prisma.diet.upsert({ where: { name }, update: {}, create: { name } }); + } + + // `Allergy` itself carries no `name` — it's the selectable instance of a + // named `Category` (see schema.prisma) — so seeding an allergen means one + // Category (upserted by name) plus exactly one Allergy row under it, + // created only the first time. + for (const name of ALLERGENS) { + const category = await prisma.category.upsert({ + where: { name }, + update: {}, + create: { name }, + }); + const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } }); + if (!existing) { + await prisma.allergy.create({ data: { categoryId: category.id } }); + } + } +} diff --git a/apps/api/src/modules/reference/reference.routes.ts b/apps/api/src/modules/reference/reference.routes.ts new file mode 100644 index 0000000..cd3e02a --- /dev/null +++ b/apps/api/src/modules/reference/reference.routes.ts @@ -0,0 +1,26 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { Router } from "express"; +import { getAllergies, getDiets } from "./reference.service.js"; + +/** + * Router mounted at `/reference` in app.ts. Both routes are deliberately + * public (no `requireAuth`) — this is static reference data, not + * per-household state, and the signup wizard (household/regime/allergen + * steps) needs to read it before an account — and therefore a session — + * exists. + */ +export const referenceRouter = Router(); + +referenceRouter.get( + "/diets", + wrapAsyncHandler(async (_req, res) => { + res.status(200).json(await getDiets()); + }), +); + +referenceRouter.get( + "/allergies", + wrapAsyncHandler(async (_req, res) => { + res.status(200).json(await getAllergies()); + }), +); diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts new file mode 100644 index 0000000..699d304 --- /dev/null +++ b/apps/api/src/modules/reference/reference.service.ts @@ -0,0 +1,21 @@ +import type { AllergyView, DietView } from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; + +/** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */ +export async function getDiets(): Promise { + return prisma.diet.findMany({ orderBy: { name: "asc" } }); +} + +/** + * All reference allergens, alphabetically. `Allergy` carries no `name` of + * its own — it's the selectable instance of a named `Category` (see + * schema.prisma) — so this resolves each allergen's display name from its + * category and flattens the split away for callers. + */ +export async function getAllergies(): Promise { + const allergies = await prisma.allergy.findMany({ + include: { category: { select: { name: true } } }, + orderBy: { category: { name: "asc" } }, + }); + return allergies.map((allergy) => ({ id: allergy.id, name: allergy.category.name })); +} diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index cdbf1f2..9b5b54e 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -1,7 +1,12 @@ import { prisma } from "../src/db/prisma.js"; +import { seedReferenceData } from "../src/db/reference-seed-data.js"; // Single TRUNCATE ... CASCADE covers FK ordering and resets identity // sequences — used between tests/scenarios to start from a clean slate. +// Re-seeds the Diet/Category/Allergy reference data right after truncating +// it, so every test starts from the same realistic reference data the real +// app seeds (`prisma/seed.ts`) rather than empty tables — tests exercising +// dietId/allergyIds need real rows to reference. export async function resetDatabase() { await prisma.$executeRawUnsafe(` TRUNCATE TABLE @@ -12,4 +17,5 @@ export async function resetDatabase() { "user_profiles", "diet", "house" RESTART IDENTITY CASCADE; `); + await seedReferenceData(prisma); } diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts new file mode 100644 index 0000000..d8963a3 --- /dev/null +++ b/apps/api/test/reference.test.ts @@ -0,0 +1,39 @@ +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { prisma } from "../src/db/prisma.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +describe("Reference data", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("GET /reference/diets", () => { + it("returns the seeded regimes, no session required", async () => { + const res = await request(app).get("/reference/diets"); + + expect(res.status).to.equal(200); + expect(res.body).to.have.length(5); + expect(res.body.map((d: { name: string }) => d.name)).to.include("Végétarien"); + expect(res.body[0]).to.have.keys(["id", "name"]); + }); + }); + + describe("GET /reference/allergies", () => { + it("returns the seeded allergens with their name resolved, no session required", async () => { + const res = await request(app).get("/reference/allergies"); + + expect(res.status).to.equal(200); + expect(res.body).to.have.length(14); + expect(res.body.map((a: { name: string }) => a.name)).to.include("Arachides"); + expect(res.body[0]).to.have.keys(["id", "name"]); + }); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f0dc809..c600342 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,4 +7,5 @@ export * from "./errors/error-codes.js"; export * from "./schemas/auth.js"; export * from "./tools/assert-is-never.js"; export * from "./types/planning.js"; +export * from "./types/reference.js"; export * from "./types/user-profile.js"; diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts new file mode 100644 index 0000000..f02cfe2 --- /dev/null +++ b/packages/shared/src/types/reference.ts @@ -0,0 +1,21 @@ +/** + * A dietary regime, as returned by `GET /reference/diets` — reference data + * (`Diet`, seeded via `apps/api/prisma/seed.ts`), not user-specific. + */ +export interface DietView { + id: number; + name: string; +} + +/** + * A selectable allergen, as returned by `GET /reference/allergies`. + * + * `name` is resolved server-side from the parent `Category` — the `Allergy` + * table itself carries no name of its own (see `schema.prisma`), so this + * flattens that split away: callers just get `{id, name}` and never need to + * know a `Category` exists underneath. + */ +export interface AllergyView { + id: number; + name: string; +}