diff --git a/README.md b/README.md index 3bff53d..e90929c 100644 --- a/README.md +++ b/README.md @@ -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 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) Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de diff --git a/apps/api/prisma/migrations/20260816235436_add_category_kind/migration.sql b/apps/api/prisma/migrations/20260816235436_add_category_kind/migration.sql new file mode 100644 index 0000000..f9ed394 --- /dev/null +++ b/apps/api/prisma/migrations/20260816235436_add_category_kind/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "AllergenKind" AS ENUM ('ALLERGY', 'INTOLERANCE'); + +-- AlterTable +ALTER TABLE "category" ADD COLUMN "kind" "AllergenKind" NOT NULL DEFAULT 'ALLERGY'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2d92313..ebe8716 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -35,11 +35,22 @@ model 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). -/// `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 { - id Int @id @default(autoincrement()) - name String @unique + id Int @id @default(autoincrement()) + name String @unique + kind AllergenKind @default(ALLERGY) allergies Allergy[] diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index ef0d449..4d4eeaf 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -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 // 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 // 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", +// an invented one. Split into ALLERGY (classic IgE-mediated immune +// reaction) vs INTOLERANCE (non-immune — gluten sensitivity, sulfite +// sensitivity) per the product decision discussed in chat: only Gluten and +// Sulfites are commonly-recognized intolerances among the 14; the rest are +// true allergens. +const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [ + { name: "Gluten", kind: "INTOLERANCE" }, + { name: "Crustacés", kind: "ALLERGY" }, + { name: "Œufs", kind: "ALLERGY" }, + { name: "Poissons", kind: "ALLERGY" }, + { name: "Arachides", kind: "ALLERGY" }, + { name: "Soja", kind: "ALLERGY" }, + { name: "Lait", kind: "ALLERGY" }, + { name: "Fruits à coque", kind: "ALLERGY" }, + { name: "Céleri", kind: "ALLERGY" }, + { 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 { // `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) { + // created only the first time. `update: { kind }` (not `{}`) — a reseed + // 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({ where: { name }, - update: {}, - create: { name }, + update: { kind }, + create: { name, kind }, }); const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } }); if (!existing) { diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 699d304..2c43575 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -14,8 +14,12 @@ export async function getDiets(): Promise { */ export async function getAllergies(): Promise { const allergies = await prisma.allergy.findMany({ - include: { category: { select: { name: true } } }, + include: { category: { select: { name: true, kind: true } } }, 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, + })); } diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index d8963a3..85e048b 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -33,7 +33,17 @@ describe("Reference data", () => { 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"]); + 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); }); }); }); diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index f02cfe2..55f0a07 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -7,15 +7,27 @@ export interface DietView { 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`. * * `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. + * 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 { id: number; name: string; + kind: AllergenKind; }