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 :
|
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`) :
|
||||||
|
|
|
||||||
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",
|
"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:*",
|
||||||
|
|
|
||||||
|
|
@ -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")
|
@@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
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 { 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.
|
||||||
|
|
|
||||||
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 { 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 "./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";
|
||||||
|
|
|
||||||
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