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:
parent
a14bb4155d
commit
9b7c955019
15 changed files with 255 additions and 2 deletions
25
README.md
25
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`) :
|
||||
|
|
|
|||
14
apps/api/features/reference.feature
Normal file
14
apps/api/features/reference.feature
Normal 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"
|
||||
11
apps/api/features/step-definitions/reference.steps.ts
Normal file
11
apps/api/features/step-definitions/reference.steps.ts
Normal 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}"`);
|
||||
},
|
||||
);
|
||||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "category_name_key" ON "category"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "diet_name_key" ON "diet"("name");
|
||||
|
|
@ -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[]
|
||||
|
||||
|
|
|
|||
18
apps/api/prisma/seed.ts
Normal file
18
apps/api/prisma/seed.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
55
apps/api/src/db/reference-seed-data.ts
Normal file
55
apps/api/src/db/reference-seed-data.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
26
apps/api/src/modules/reference/reference.routes.ts
Normal file
26
apps/api/src/modules/reference/reference.routes.ts
Normal 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());
|
||||
}),
|
||||
);
|
||||
21
apps/api/src/modules/reference/reference.service.ts
Normal file
21
apps/api/src/modules/reference/reference.service.ts
Normal 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 }));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
39
apps/api/test/reference.test.ts
Normal file
39
apps/api/test/reference.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
21
packages/shared/src/types/reference.ts
Normal file
21
packages/shared/src/types/reference.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue