API: séparer allergies et intolérances (kind sur Category) (step 7/8)

Retour fonctionnel : les allergies et intolérances doivent être
distinguées, pas listées ensemble.

- schema.prisma: enum AllergenKind (ALLERGY|INTOLERANCE) + Category.kind
  (@default(ALLERGY), migration écrite à la main comme précédemment —
  `migrate dev` refuse en environnement non-interactif ici — SQL généré
  via `prisma migrate diff`).
- reference-seed-data.ts: classification par substance (Gluten et
  Sulfites = INTOLERANCE, les 12 autres = ALLERGY — réaction
  non-immunitaire documentée vs réaction immunitaire classique).
  Corrige au passage l'upsert : `update: { kind }` au lieu de `update:
  {}` — un reseed doit pouvoir corriger `kind` sur une Category déjà
  existante, pas juste no-op.
- reference.service.ts / packages/shared: AllergyView gagne `kind`.
  PATCH /profile/allergies ne change pas (une seule liste d'IDs, kind
  ne sert qu'au groupement d'affichage côté client).
- Tests Mocha (29 passing) + Cucumber (15 scenarios, inchangés).

Classifié par substance (pas par utilisateur) — documenté comme
limitation connue dans le README. Web (split UI + hot saving sur
/foyer) dans le commit suivant.
This commit is contained in:
Nicolas 2026-08-17 00:01:19 +02:00
parent 9722f4a27b
commit 6cfa71730c
7 changed files with 86 additions and 28 deletions

View file

@ -211,6 +211,16 @@ 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 Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
standard, pas inventée. standard, pas inventée.
**Allergies vs intolérances** (retour fonctionnel, pas dans le doc spec d'origine) :
`Category.kind` (`AllergenKind` — `ALLERGY` | `INTOLERANCE`) classe chaque allergène.
Seuls `Gluten` et `Sulfites` sont en `INTOLERANCE` (réaction non-immunitaire
documentée) ; les 12 autres en `ALLERGY` (réaction immunitaire classique). Classifié
par substance, pas par utilisateur — un même foyer ne peut pas déclarer "allergie au
lait" pour un membre et "intolérance au lait" pour un autre ; a suffi pour le besoin
exprimé, à revoir si ça devient un problème réel. `GET /reference/allergies` renvoie
`kind` dans chaque `AllergyView` ; `PATCH /profile/allergies` ne change pas (une
seule liste d'IDs, `kind` ne sert qu'à grouper l'affichage côté client).
## Foyer & profil — nom, régime, allergènes (apps/api) ## Foyer & profil — nom, régime, allergènes (apps/api)
Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de

View file

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "AllergenKind" AS ENUM ('ALLERGY', 'INTOLERANCE');
-- AlterTable
ALTER TABLE "category" ADD COLUMN "kind" "AllergenKind" NOT NULL DEFAULT 'ALLERGY';

View file

@ -35,11 +35,22 @@ model Diet {
@@map("diet") @@map("diet")
} }
/// Not in the original spec doc — a category is either a true (IgE-mediated)
/// allergy or a non-immune intolerance; the UI groups selectable allergens
/// into two separate lists (`AllergySelect`, apps/web) instead of one flat
/// "allergies & intolérances" list.
enum AllergenKind {
ALLERGY
INTOLERANCE
}
/// 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. /// `name` is `@unique` for the same reason as `Diet.name` above. `kind` is
/// also not in the original spec doc — see {@link AllergenKind}.
model Category { model Category {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
kind AllergenKind @default(ALLERGY)
allergies Allergy[] allergies Allergy[]

View file

@ -1,4 +1,4 @@
import type { PrismaClient } from "@prisma/client"; import type { AllergenKind, PrismaClient } from "@prisma/client";
// Short, optional-to-pick regime list — `UserProfile.dietId` stays // Short, optional-to-pick regime list — `UserProfile.dietId` stays
// nullable, this is not meant to be exhaustive. // nullable, this is not meant to be exhaustive.
@ -6,22 +6,26 @@ const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans glute
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
// businesses to declare — a standard, defensible reference list rather than // businesses to declare — a standard, defensible reference list rather than
// an invented one. // an invented one. Split into ALLERGY (classic IgE-mediated immune
const ALLERGENS = [ // reaction) vs INTOLERANCE (non-immune — gluten sensitivity, sulfite
"Gluten", // sensitivity) per the product decision discussed in chat: only Gluten and
"Crustacés", // Sulfites are commonly-recognized intolerances among the 14; the rest are
"Œufs", // true allergens.
"Poissons", const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [
"Arachides", { name: "Gluten", kind: "INTOLERANCE" },
"Soja", { name: "Crustacés", kind: "ALLERGY" },
"Lait", { name: "Œufs", kind: "ALLERGY" },
"Fruits à coque", { name: "Poissons", kind: "ALLERGY" },
"Céleri", { name: "Arachides", kind: "ALLERGY" },
"Moutarde", { name: "Soja", kind: "ALLERGY" },
"Graines de sésame", { name: "Lait", kind: "ALLERGY" },
"Sulfites", { name: "Fruits à coque", kind: "ALLERGY" },
"Lupin", { name: "Céleri", kind: "ALLERGY" },
"Mollusques", { name: "Moutarde", kind: "ALLERGY" },
{ name: "Graines de sésame", kind: "ALLERGY" },
{ name: "Sulfites", kind: "INTOLERANCE" },
{ name: "Lupin", kind: "ALLERGY" },
{ name: "Mollusques", kind: "ALLERGY" },
]; ];
/** /**
@ -40,12 +44,14 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
// `Allergy` itself carries no `name` — it's the selectable instance of a // `Allergy` itself carries no `name` — it's the selectable instance of a
// named `Category` (see schema.prisma) — so seeding an allergen means one // named `Category` (see schema.prisma) — so seeding an allergen means one
// Category (upserted by name) plus exactly one Allergy row under it, // Category (upserted by name) plus exactly one Allergy row under it,
// created only the first time. // created only the first time. `update: { kind }` (not `{}`) — a reseed
for (const name of ALLERGENS) { // must correct `kind` on an already-existing category if the
// classification above ever changes, not just skip it.
for (const { name, kind } of ALLERGENS) {
const category = await prisma.category.upsert({ const category = await prisma.category.upsert({
where: { name }, where: { name },
update: {}, update: { kind },
create: { name }, create: { name, kind },
}); });
const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } }); const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } });
if (!existing) { if (!existing) {

View file

@ -14,8 +14,12 @@ export async function getDiets(): Promise<DietView[]> {
*/ */
export async function getAllergies(): Promise<AllergyView[]> { export async function getAllergies(): Promise<AllergyView[]> {
const allergies = await prisma.allergy.findMany({ const allergies = await prisma.allergy.findMany({
include: { category: { select: { name: true } } }, include: { category: { select: { name: true, kind: true } } },
orderBy: { category: { name: "asc" } }, orderBy: { category: { name: "asc" } },
}); });
return allergies.map((allergy) => ({ id: allergy.id, name: allergy.category.name })); return allergies.map((allergy) => ({
id: allergy.id,
name: allergy.category.name,
kind: allergy.category.kind,
}));
} }

View file

@ -33,7 +33,17 @@ describe("Reference data", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.have.length(14); expect(res.body).to.have.length(14);
expect(res.body.map((a: { name: string }) => a.name)).to.include("Arachides"); expect(res.body.map((a: { name: string }) => a.name)).to.include("Arachides");
expect(res.body[0]).to.have.keys(["id", "name"]); expect(res.body[0]).to.have.keys(["id", "name", "kind"]);
});
it("classifies Gluten and Sulfites as intolerances, the rest as allergies", async () => {
const res = await request(app).get("/reference/allergies");
const byName = (name: string) => res.body.find((a: { name: string }) => a.name === name);
expect(byName("Gluten").kind).to.equal("INTOLERANCE");
expect(byName("Sulfites").kind).to.equal("INTOLERANCE");
expect(byName("Arachides").kind).to.equal("ALLERGY");
expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2);
}); });
}); });
}); });

View file

@ -7,15 +7,27 @@ export interface DietView {
name: string; name: string;
} }
/**
* Whether an allergen is a true (IgE-mediated) allergy or a non-immune
* intolerance mirrors `AllergenKind` in `schema.prisma`. Declared by hand
* here rather than derived from the Prisma enum, same reasoning as
* `SafeUserProfile`: `apps/web` must not depend on `@prisma/client`.
*/
export type AllergenKind = "ALLERGY" | "INTOLERANCE";
/** /**
* A selectable allergen, as returned by `GET /reference/allergies`. * A selectable allergen, as returned by `GET /reference/allergies`.
* *
* `name` is resolved server-side from the parent `Category` the `Allergy` * `name` is resolved server-side from the parent `Category` the `Allergy`
* table itself carries no name of its own (see `schema.prisma`), so this * 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 * flattens that split away: callers just get `{id, name}` and never need to
* know a `Category` exists underneath. * know a `Category` exists underneath. `kind` groups allergens into two
* separate lists client-side (`AllergySelect`, `apps/web`) rather than one
* flat "allergies & intolérances" list a single `PATCH /profile/allergies`
* call still covers both, this is a display grouping only.
*/ */
export interface AllergyView { export interface AllergyView {
id: number; id: number;
name: string; name: string;
kind: AllergenKind;
} }