API: seed régimes/allergènes + GET /reference/diets, /reference/allergies (step 1/6)

- schema.prisma: Diet.name/Category.name deviennent @unique (pas dans le
  doc spec d'origine — ajouté pour que le seed soit idempotent par
  upsert). Migration écrite à la main + appliquée via `migrate deploy`
  (`migrate dev` refuse en environnement non-interactif ici) — SQL
  généré via `prisma migrate diff` pour matcher exactement les
  conventions Prisma.
- src/db/reference-seed-data.ts: seedReferenceData() — 5 régimes, 14
  allergènes (règlement UE 1169/2011 annexe II). Chaque allergène = une
  Category (upsert par nom) + une unique Allergy sous cette catégorie
  (Allergy elle-même ne porte pas de nom, voir schema.prisma).
  Réutilisée par prisma/seed.ts (CLI, `prisma db seed`) ET
  test-support/reset-db.ts (chaque test repart avec ces données de
  référence, pas des tables vides).
- modules/reference/: GET /reference/diets, GET /reference/allergies —
  publics (pas de requireAuth), lisibles avant qu'un compte existe
  (wizard d'inscription).
- packages/shared: DietView, AllergyView (name résolu côté serveur
  depuis Category, le split Allergy/Category reste invisible du client).
- Tests Mocha + Cucumber, doc README.

Premier commit de la feature profil/foyer/régime/allergènes (planifiée
en chat) — endpoints foyer/profil dans le commit suivant.
This commit is contained in:
Nicolas 2026-08-16 23:09:02 +02:00
parent a14bb4155d
commit 9b7c955019
15 changed files with 255 additions and 2 deletions

View file

@ -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 : a mis au jour une contrainte générique trop stricte, corrigée à la source :
[specs/backend-architecture.md](specs/backend-architecture.md). [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) ## Page de connexion / inscription (apps/web)
- `src/api/client.ts``ApiClient` (classe, instance unique exportée `apiClient`) : - `src/api/client.ts``ApiClient` (classe, instance unique exportée `apiClient`) :

View file

@ -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"

View file

@ -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}"`);
},
);

View file

@ -11,8 +11,12 @@
"test:bdd": "cross-env NODE_ENV=test NODE_OPTIONS=--import=tsx cucumber-js", "test:bdd": "cross-env NODE_ENV=test NODE_OPTIONS=--import=tsx cucumber-js",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:seed": "prisma db seed",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": { "dependencies": {
"@batch-cooking/error-tools": "workspace:*", "@batch-cooking/error-tools": "workspace:*",
"@batch-cooking/express-tools": "workspace:*", "@batch-cooking/express-tools": "workspace:*",

View file

@ -0,0 +1,5 @@
-- CreateIndex
CREATE UNIQUE INDEX "category_name_key" ON "category"("name");
-- CreateIndex
CREATE UNIQUE INDEX "diet_name_key" ON "diet"("name");

View file

@ -22,9 +22,13 @@ model House {
@@map("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 { model Diet {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String @unique
users UserProfile[] users UserProfile[]
@ -32,9 +36,10 @@ model Diet {
} }
/// Enumeration-style table, meant to grow over time (e.g. allergy nuances). /// 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 { model Category {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String @unique
allergies Allergy[] allergies Allergy[]

18
apps/api/prisma/seed.ts Normal file
View file

@ -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);
});

View file

@ -5,6 +5,7 @@ import type { Express, Request, Response } from "express";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { authRouter } from "./modules/auth/auth.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js";
import { planningRouter } from "./modules/planning/planning.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 * Builds the API's `ExpressServer`: standard middleware, routes, and the
@ -24,6 +25,7 @@ export function createServer(): ExpressServer {
server.mountRouter("/auth", authRouter); server.mountRouter("/auth", authRouter);
server.mountRouter("/planning", planningRouter); server.mountRouter("/planning", planningRouter);
server.mountRouter("/reference", referenceRouter);
// No route matched — same shape as every other error response, via the // No route matched — same shape as every other error response, via the
// shared ErrorCode contract, so clients never special-case 404s. // shared ErrorCode contract, so clients never special-case 404s.

View file

@ -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<void> {
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 } });
}
}
}

View file

@ -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());
}),
);

View file

@ -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<DietView[]> {
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<AllergyView[]> {
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 }));
}

View file

@ -1,7 +1,12 @@
import { prisma } from "../src/db/prisma.js"; 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 // Single TRUNCATE ... CASCADE covers FK ordering and resets identity
// sequences — used between tests/scenarios to start from a clean slate. // 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() { export async function resetDatabase() {
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
TRUNCATE TABLE TRUNCATE TABLE
@ -12,4 +17,5 @@ export async function resetDatabase() {
"user_profiles", "diet", "house" "user_profiles", "diet", "house"
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;
`); `);
await seedReferenceData(prisma);
} }

View file

@ -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"]);
});
});
});

View file

@ -7,4 +7,5 @@ export * from "./errors/error-codes.js";
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";
export * from "./tools/assert-is-never.js"; export * from "./tools/assert-is-never.js";
export * from "./types/planning.js"; export * from "./types/planning.js";
export * from "./types/reference.js";
export * from "./types/user-profile.js"; export * from "./types/user-profile.js";

View file

@ -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;
}