From 9b7c955019d5a15b76fe2192d082256f8c21fe74 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 16 Aug 2026 23:09:02 +0200 Subject: [PATCH 1/9] =?UTF-8?q?API:=20seed=20r=C3=A9gimes/allerg=C3=A8nes?= =?UTF-8?q?=20+=20GET=20/reference/diets,=20/reference/allergies=20(step?= =?UTF-8?q?=201/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- README.md | 25 +++++++++ apps/api/features/reference.feature | 14 +++++ .../step-definitions/reference.steps.ts | 11 ++++ apps/api/package.json | 4 ++ .../migration.sql | 5 ++ apps/api/prisma/schema.prisma | 9 ++- apps/api/prisma/seed.ts | 18 ++++++ apps/api/src/app.ts | 2 + apps/api/src/db/reference-seed-data.ts | 55 +++++++++++++++++++ .../src/modules/reference/reference.routes.ts | 26 +++++++++ .../modules/reference/reference.service.ts | 21 +++++++ apps/api/test-support/reset-db.ts | 6 ++ apps/api/test/reference.test.ts | 39 +++++++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/types/reference.ts | 21 +++++++ 15 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 apps/api/features/reference.feature create mode 100644 apps/api/features/step-definitions/reference.steps.ts create mode 100644 apps/api/prisma/migrations/20260816230050_unique_diet_category_name/migration.sql create mode 100644 apps/api/prisma/seed.ts create mode 100644 apps/api/src/db/reference-seed-data.ts create mode 100644 apps/api/src/modules/reference/reference.routes.ts create mode 100644 apps/api/src/modules/reference/reference.service.ts create mode 100644 apps/api/test/reference.test.ts create mode 100644 packages/shared/src/types/reference.ts diff --git a/README.md b/README.md index 9e40008..afdac88 100644 --- a/README.md +++ b/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`) : diff --git a/apps/api/features/reference.feature b/apps/api/features/reference.feature new file mode 100644 index 0000000..10b7718 --- /dev/null +++ b/apps/api/features/reference.feature @@ -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" diff --git a/apps/api/features/step-definitions/reference.steps.ts b/apps/api/features/step-definitions/reference.steps.ts new file mode 100644 index 0000000..e4e7c1b --- /dev/null +++ b/apps/api/features/step-definitions/reference.steps.ts @@ -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}"`); + }, +); diff --git a/apps/api/package.json b/apps/api/package.json index c7860cc..ddb49cb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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:*", diff --git a/apps/api/prisma/migrations/20260816230050_unique_diet_category_name/migration.sql b/apps/api/prisma/migrations/20260816230050_unique_diet_category_name/migration.sql new file mode 100644 index 0000000..09919e5 --- /dev/null +++ b/apps/api/prisma/migrations/20260816230050_unique_diet_category_name/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE UNIQUE INDEX "category_name_key" ON "category"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "diet_name_key" ON "diet"("name"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c94073e..2d92313 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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[] diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..87534e6 --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -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); + }); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 63baf0f..6b6e2f7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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. diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts new file mode 100644 index 0000000..ef0d449 --- /dev/null +++ b/apps/api/src/db/reference-seed-data.ts @@ -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 { + 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 } }); + } + } +} diff --git a/apps/api/src/modules/reference/reference.routes.ts b/apps/api/src/modules/reference/reference.routes.ts new file mode 100644 index 0000000..cd3e02a --- /dev/null +++ b/apps/api/src/modules/reference/reference.routes.ts @@ -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()); + }), +); diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts new file mode 100644 index 0000000..699d304 --- /dev/null +++ b/apps/api/src/modules/reference/reference.service.ts @@ -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 { + 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 { + 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 })); +} diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index cdbf1f2..9b5b54e 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -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); } diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts new file mode 100644 index 0000000..d8963a3 --- /dev/null +++ b/apps/api/test/reference.test.ts @@ -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"]); + }); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f0dc809..c600342 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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"; diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts new file mode 100644 index 0000000..f02cfe2 --- /dev/null +++ b/packages/shared/src/types/reference.ts @@ -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; +} From 1d03effc77a57d5a1507f90833244bf03daab71c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 16 Aug 2026 23:18:46 +0200 Subject: [PATCH 2/9] =?UTF-8?q?API:=20endpoints=20foyer/profil=20(nom,=20r?= =?UTF-8?q?=C3=A9gime,=20allerg=C3=A8nes)=20(step=202/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET/PATCH /house/current — renomme le foyer de l'utilisateur connecté. PATCH avec houseId null -> 404 HOUSE_NOT_FOUND. - PATCH /profile/diet { dietId: number | null } — régime du profil ; null l'efface (étape skippable du parcours). dietId invalide -> 404 DIET_NOT_FOUND. - GET/PATCH /profile/allergies — allergènes/intolérances, liste d'IDs ; PATCH remplace l'ensemble complet (pas une fusion, cohérent avec un multi-select). ID invalide -> 404 ALLERGY_NOT_FOUND. - 3 nouveaux ErrorCode (4041-4043) + libellés fr. - Extraction de toSafeProfile() dans src/lib/safe-profile.ts — auparavant dupliqué dans auth.service.ts et require-auth.ts, profile.service.ts le réutilise aussi. - Tests Mocha (28 passing) + Cucumber (15 scenarios) — même convention que le reste, doc README. Deuxième commit de la feature profil/foyer/régime/allergènes — composants front partagés dans le commit suivant. --- README.md | 22 +++ apps/api/features/household.feature | 16 +++ apps/api/features/profile.feature | 28 ++++ .../features/step-definitions/health.steps.ts | 7 + .../step-definitions/household.steps.ts | 12 ++ .../step-definitions/profile.steps.ts | 46 +++++++ apps/api/src/app.ts | 4 + apps/api/src/lib/safe-profile.ts | 14 ++ apps/api/src/middlewares/require-auth.ts | 4 +- apps/api/src/modules/auth/auth.service.ts | 27 ++-- apps/api/src/modules/house/house.routes.ts | 28 ++++ apps/api/src/modules/house/house.service.ts | 40 ++++++ .../api/src/modules/profile/profile.routes.ts | 39 ++++++ .../src/modules/profile/profile.service.ts | 76 +++++++++++ apps/api/test/house.test.ts | 81 +++++++++++ apps/api/test/profile.test.ts | 128 ++++++++++++++++++ apps/web/src/locales/fr/translation.json | 3 + packages/shared/src/errors/error-codes.ts | 6 + packages/shared/src/index.ts | 3 + packages/shared/src/schemas/household.ts | 12 ++ packages/shared/src/schemas/profile.ts | 17 +++ packages/shared/src/types/household.ts | 9 ++ 22 files changed, 605 insertions(+), 17 deletions(-) create mode 100644 apps/api/features/household.feature create mode 100644 apps/api/features/profile.feature create mode 100644 apps/api/features/step-definitions/household.steps.ts create mode 100644 apps/api/features/step-definitions/profile.steps.ts create mode 100644 apps/api/src/lib/safe-profile.ts create mode 100644 apps/api/src/modules/house/house.routes.ts create mode 100644 apps/api/src/modules/house/house.service.ts create mode 100644 apps/api/src/modules/profile/profile.routes.ts create mode 100644 apps/api/src/modules/profile/profile.service.ts create mode 100644 apps/api/test/house.test.ts create mode 100644 apps/api/test/profile.test.ts create mode 100644 packages/shared/src/schemas/household.ts create mode 100644 packages/shared/src/schemas/profile.ts create mode 100644 packages/shared/src/types/household.ts diff --git a/README.md b/README.md index afdac88..bb2f2a8 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,28 @@ 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. +## Foyer & profil — nom, régime, allergènes (apps/api) + +Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de +référence ci-dessus, ce sont des données propres à l'utilisateur/au foyer. + +- `GET`/`PATCH /house/current` — foyer de l'utilisateur connecté. `GET` renvoie + `null` si le profil n'a pas encore de foyer (cas théorique : le signup en crée + toujours un) ; `PATCH { name }` le renomme (`404 HOUSE_NOT_FOUND` si le profil + n'a pas de foyer). +- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ; + `null` efface le régime (étape "skippable" du parcours). `404 DIET_NOT_FOUND` si + `dietId` ne correspond à aucun régime de référence. +- `GET`/`PATCH /profile/allergies` — allergènes/intolérances du profil connecté, + sous forme de liste d'IDs (`number[]`). `PATCH { allergyIds }` **remplace** + l'ensemble (pas une fusion — le client renvoie toujours la sélection complète, + cohérent avec un composant de multi-sélection). `404 ALLERGY_NOT_FOUND` si un ID + ne correspond à aucun allergène de référence. + +`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash` +(`toSafeProfile`), auparavant dupliqué dans `auth.service.ts` et +`require-auth.ts` — `profile.service.ts` le réutilise aussi. + ## Page de connexion / inscription (apps/web) - `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) : diff --git a/apps/api/features/household.feature b/apps/api/features/household.feature new file mode 100644 index 0000000..94a56e2 --- /dev/null +++ b/apps/api/features/household.feature @@ -0,0 +1,16 @@ +Feature: Household name + As a signed-in user + I want to name my household + So that it's recognizable as ours, not the auto-generated default + + Scenario: A visitor without a session cannot read the household + When I send a GET request to "/house/current" + Then the response status should be 401 + And the response error code should be "NOT_AUTHENTICATED" + + Scenario: A signed-in user renames their household + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I rename my household to "Chez les Martin" + Then the response status should be 200 + And my household should be named "Chez les Martin" diff --git a/apps/api/features/profile.feature b/apps/api/features/profile.feature new file mode 100644 index 0000000..deca688 --- /dev/null +++ b/apps/api/features/profile.feature @@ -0,0 +1,28 @@ +Feature: Profile regime and allergens + As a signed-in user + I want to set my dietary regime and allergens/intolerances + So that the household's meal planning can account for them later + + Scenario: A visitor without a session cannot set a regime + When I send a PATCH request to "/profile/diet" with body: + """ + { "dietId": 1 } + """ + Then the response status should be 401 + And the response error code should be "NOT_AUTHENTICATED" + + Scenario: A signed-in user sets their regime to a valid, seeded diet + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I set my regime to "Végétarien" + Then the response status should be 200 + And my profile's regime should be "Végétarien" + + Scenario: A signed-in user selects allergens, then replaces the selection + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I set my allergens to "Arachides, Gluten" + Then the response status should be 200 + And my selected allergens should be "Arachides, Gluten" + When I set my allergens to "Lait" + Then my selected allergens should be "Lait" diff --git a/apps/api/features/step-definitions/health.steps.ts b/apps/api/features/step-definitions/health.steps.ts index c69fc92..052fe87 100644 --- a/apps/api/features/step-definitions/health.steps.ts +++ b/apps/api/features/step-definitions/health.steps.ts @@ -8,6 +8,13 @@ When("I send a GET request to {string}", async function (this: CustomWorld, path this.response = await request(this.app).get(path); }); +When( + "I send a PATCH request to {string} with body:", + async function (this: CustomWorld, path: string, body: string) { + this.response = await request(this.app).patch(path).send(JSON.parse(body)); + }, +); + Then("the response status should be {int}", function (this: CustomWorld, status: number) { assert.equal(this.response.status, status); }); diff --git a/apps/api/features/step-definitions/household.steps.ts b/apps/api/features/step-definitions/household.steps.ts new file mode 100644 index 0000000..4a8dabb --- /dev/null +++ b/apps/api/features/step-definitions/household.steps.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { Then, When } from "@cucumber/cucumber"; +import type { CustomWorld } from "../support/world.js"; + +When("I rename my household to {string}", async function (this: CustomWorld, name: string) { + this.response = await this.agent.patch("/house/current").send({ name }); +}); + +Then("my household should be named {string}", async function (this: CustomWorld, name: string) { + const res = await this.agent.get("/house/current"); + assert.equal(res.body.name, name); +}); diff --git a/apps/api/features/step-definitions/profile.steps.ts b/apps/api/features/step-definitions/profile.steps.ts new file mode 100644 index 0000000..f9e432f --- /dev/null +++ b/apps/api/features/step-definitions/profile.steps.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { Then, When } from "@cucumber/cucumber"; +import { prisma } from "../../src/db/prisma.js"; +import type { CustomWorld } from "../support/world.js"; + +/** Splits a comma-separated list of names from a `.feature` string into trimmed, non-empty parts. */ +function splitNames(names: string): string[] { + return names + .split(",") + .map((name) => name.trim()) + .filter(Boolean); +} + +/** Resolves allergen names (Category.name) to their Allergy id — see reference.service.ts for why the name lives on Category, not Allergy. */ +async function allergyIdsFor(names: string[]): Promise { + const allergies = await prisma.allergy.findMany({ include: { category: true } }); + return names.map((name) => { + const match = allergies.find((allergy) => allergy.category.name === name); + if (!match) throw new Error(`No seeded allergen named "${name}"`); + return match.id; + }); +} + +When("I set my regime to {string}", async function (this: CustomWorld, dietName: string) { + const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } }); + this.response = await this.agent.patch("/profile/diet").send({ dietId: diet.id }); +}); + +Then( + "my profile's regime should be {string}", + async function (this: CustomWorld, dietName: string) { + const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } }); + assert.equal(this.response.body.dietId, diet.id); + }, +); + +When("I set my allergens to {string}", async function (this: CustomWorld, names: string) { + const allergyIds = await allergyIdsFor(splitNames(names)); + this.response = await this.agent.patch("/profile/allergies").send({ allergyIds }); +}); + +Then("my selected allergens should be {string}", async function (this: CustomWorld, names: string) { + const expected = (await allergyIdsFor(splitNames(names))).sort(); + const actual = [...this.response.body].sort(); + assert.deepEqual(actual, expected); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6b6e2f7..c4f8727 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,7 +4,9 @@ import { ErrorCode } from "@batch-cooking/shared"; import type { Express, Request, Response } from "express"; import { env } from "./config/env.js"; import { authRouter } from "./modules/auth/auth.routes.js"; +import { houseRouter } from "./modules/house/house.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js"; +import { profileRouter } from "./modules/profile/profile.routes.js"; import { referenceRouter } from "./modules/reference/reference.routes.js"; /** @@ -24,7 +26,9 @@ export function createServer(): ExpressServer { }); server.mountRouter("/auth", authRouter); + server.mountRouter("/house", houseRouter); server.mountRouter("/planning", planningRouter); + server.mountRouter("/profile", profileRouter); server.mountRouter("/reference", referenceRouter); // No route matched — same shape as every other error response, via the diff --git a/apps/api/src/lib/safe-profile.ts b/apps/api/src/lib/safe-profile.ts new file mode 100644 index 0000000..ecf40e7 --- /dev/null +++ b/apps/api/src/lib/safe-profile.ts @@ -0,0 +1,14 @@ +import type { SafeUserProfile } from "@batch-cooking/shared"; +import type { UserProfile } from "@prisma/client"; + +/** + * Strips `passwordHash` off a Prisma `UserProfile` before it's ever sent to + * a client. Shared by every module that hands a profile back to the + * caller (`auth.service.ts`, `require-auth.ts`, `profile.service.ts`) — + * previously duplicated inline in each, consolidated here so there's one + * place this security-relevant stripping happens. + */ +export function toSafeProfile(profile: UserProfile): SafeUserProfile { + const { passwordHash: _passwordHash, ...safeProfile } = profile; + return safeProfile; +} diff --git a/apps/api/src/middlewares/require-auth.ts b/apps/api/src/middlewares/require-auth.ts index ba32431..099793c 100644 --- a/apps/api/src/middlewares/require-auth.ts +++ b/apps/api/src/middlewares/require-auth.ts @@ -4,6 +4,7 @@ import type { NextFunction, Request, Response } from "express"; import { env } from "../config/env.js"; import { prisma } from "../db/prisma.js"; import { verifyAuthToken } from "../lib/jwt.js"; +import { toSafeProfile } from "../lib/safe-profile.js"; /** * Shape of `res.locals` once {@link requireAuth} has run successfully. Type @@ -54,8 +55,7 @@ export async function requireAuth( throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); } - const { passwordHash: _passwordHash, ...safeProfile } = profile; - res.locals.userProfile = safeProfile; + res.locals.userProfile = toSafeProfile(profile); next(); } catch (err) { if (err instanceof HttpError) { diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 5fb5a3b..24a6508 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -1,18 +1,20 @@ import { HttpError } from "@batch-cooking/error-tools"; -import { ErrorCode, type LoginInput, type SignupInput } from "@batch-cooking/shared"; -import type { UserProfile } from "@prisma/client"; +import { + ErrorCode, + type LoginInput, + type SafeUserProfile, + type SignupInput, +} from "@batch-cooking/shared"; import argon2 from "argon2"; import { env } from "../../config/env.js"; import { prisma } from "../../db/prisma.js"; import { signAuthToken } from "../../lib/jwt.js"; - -/** A UserProfile as it's safe to hand back to a client — never the password hash. */ -type SafeProfile = Omit; +import { toSafeProfile } from "../../lib/safe-profile.js"; /** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */ interface AuthResult { /** The authenticated profile, safe to hand back to the client. */ - profile: SafeProfile; + profile: SafeUserProfile; /** Signed session JWT — the caller sets this as the session cookie's value. */ token: string; } @@ -25,12 +27,6 @@ interface AuthResult { const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 }; const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined; -/** Strips `passwordHash` off a Prisma UserProfile before it's ever sent to a client. */ -function toSafeProfile(profile: UserProfile): SafeProfile { - const { passwordHash: _passwordHash, ...safeProfile } = profile; - return safeProfile; -} - /** * Creates a new household (`house`) and profile (`user_profiles`) together * in one transaction, hashes the password, and issues a session token. @@ -45,9 +41,10 @@ export async function signup(input: SignupInput): Promise { const passwordHash = await argon2.hash(input.password, hashOptions); - // A profile always belongs to a house; signup creates one (named after - // the new user for now — renaming/joining an existing house is a - // separate, not-yet-built feature). + // A profile always belongs to a house; signup creates one, named after + // the new user for now — renamed via `PATCH /house/current` (the + // household step of the profile journey). Joining an existing house is a + // separate, not-yet-built feature. const profile = await prisma.$transaction(async (tx) => { const house = await tx.house.create({ data: { name: `Foyer de ${input.firstName}` }, diff --git a/apps/api/src/modules/house/house.routes.ts b/apps/api/src/modules/house/house.routes.ts new file mode 100644 index 0000000..ec9c67d --- /dev/null +++ b/apps/api/src/modules/house/house.routes.ts @@ -0,0 +1,28 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { renameHouseSchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { getCurrentHouse, renameHouse } from "./house.service.js"; + +/** Router mounted at `/house` in app.ts. Both routes require a session — a household is per-user (via their profile), never public. */ +export const houseRouter = Router(); + +houseRouter.get( + "/current", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + const house = await getCurrentHouse(res.locals.userProfile.houseId); + res.status(200).json(house); + }), +); + +/** The household step of the profile journey (signup wizard and the `/foyer` settings page both call this). */ +houseRouter.patch( + "/current", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = renameHouseSchema.parse(req.body); + const house = await renameHouse(res.locals.userProfile.houseId, input.name); + res.status(200).json(house); + }), +); diff --git a/apps/api/src/modules/house/house.service.ts b/apps/api/src/modules/house/house.service.ts new file mode 100644 index 0000000..6a34438 --- /dev/null +++ b/apps/api/src/modules/house/house.service.ts @@ -0,0 +1,40 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { ErrorCode, type HouseView } from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; + +/** Returns the profile's household, or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */ +export async function getCurrentHouse(houseId: number | null): Promise { + if (houseId === null) { + return null; + } + return findHouseOrThrow(houseId); +} + +/** + * Renames the profile's household. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. + */ +export async function renameHouse(houseId: number | null, name: string): Promise { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + await findHouseOrThrow(houseId); + return prisma.house.update({ where: { id: houseId }, data: { name } }); +} + +/** + * A profile's `houseId` is only ever set to a real house (foreign key, + * never assigned by hand) — a lookup miss here means the referenced row + * was deleted out from under a still-linked profile, an internal + * inconsistency rather than a normal "not found" a client could hit + * through the API, hence a plain `Error` (500) rather than a + * `HOUSE_NOT_FOUND` HttpError. + */ +async function findHouseOrThrow(houseId: number): Promise { + const house = await prisma.house.findUnique({ where: { id: houseId } }); + if (!house) { + throw new Error(`House ${houseId} referenced by a profile but not found`); + } + return house; +} diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts new file mode 100644 index 0000000..7732fba --- /dev/null +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -0,0 +1,39 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { updateAllergiesSchema, updateDietSchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { getAllergyIds, updateAllergies, updateDiet } from "./profile.service.js"; + +/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */ +export const profileRouter = Router(); + +/** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */ +profileRouter.patch( + "/diet", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = updateDietSchema.parse(req.body); + const profile = await updateDiet(res.locals.userProfile.id, input.dietId); + res.status(200).json(profile); + }), +); + +profileRouter.get( + "/allergies", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + const allergyIds = await getAllergyIds(res.locals.userProfile.id); + res.status(200).json(allergyIds); + }), +); + +/** The allergen/intolerance step of the profile journey — same callers as PATCH /diet above. */ +profileRouter.patch( + "/allergies", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = updateAllergiesSchema.parse(req.body); + const allergyIds = await updateAllergies(res.locals.userProfile.id, input.allergyIds); + res.status(200).json(allergyIds); + }), +); diff --git a/apps/api/src/modules/profile/profile.service.ts b/apps/api/src/modules/profile/profile.service.ts new file mode 100644 index 0000000..487a19c --- /dev/null +++ b/apps/api/src/modules/profile/profile.service.ts @@ -0,0 +1,76 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; +import { toSafeProfile } from "../../lib/safe-profile.js"; + +/** + * Sets (or clears, if `dietId` is `null`) a profile's dietary regime — the + * regime step of the profile journey is skippable, so `null` is a normal, + * valid value, not an omission to reject. + * + * @throws {HttpError} `404 DIET_NOT_FOUND` if `dietId` doesn't match a reference `Diet` row. + */ +export async function updateDiet( + userProfileId: number, + dietId: number | null, +): Promise { + if (dietId !== null) { + const diet = await prisma.diet.findUnique({ where: { id: dietId } }); + if (!diet) { + throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`); + } + } + + const profile = await prisma.userProfile.update({ + where: { id: userProfileId }, + data: { dietId }, + }); + return toSafeProfile(profile); +} + +/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */ +export async function getAllergyIds(userProfileId: number): Promise { + const rows = await prisma.userProfileAllergy.findMany({ + where: { userProfileId }, + select: { allergyId: true }, + }); + return rows.map((row) => row.allergyId); +} + +/** + * Replaces a profile's full allergen set (not a merge — the caller sends + * the complete list every time, same shape the multi-select UI already + * holds). Validates every id up front so a partially-invalid request never + * leaves the set half-updated. + * + * @throws {HttpError} `404 ALLERGY_NOT_FOUND` if any `allergyId` doesn't match a reference `Allergy` row. + */ +export async function updateAllergies( + userProfileId: number, + allergyIds: number[], +): Promise { + if (allergyIds.length > 0) { + const found = await prisma.allergy.findMany({ + where: { id: { in: allergyIds } }, + select: { id: true }, + }); + const foundIds = new Set(found.map((allergy) => allergy.id)); + const missing = allergyIds.filter((id) => !foundIds.has(id)); + if (missing.length > 0) { + throw new HttpError( + 404, + ErrorCode.ALLERGY_NOT_FOUND, + `Unknown allergy id(s): ${missing.join(", ")}`, + ); + } + } + + await prisma.$transaction([ + prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }), + prisma.userProfileAllergy.createMany({ + data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })), + }), + ]); + + return allergyIds; +} diff --git a/apps/api/test/house.test.ts b/apps/api/test/house.test.ts new file mode 100644 index 0000000..87cd0cf --- /dev/null +++ b/apps/api/test/house.test.ts @@ -0,0 +1,81 @@ +import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +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"; + +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +describe("Household", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("GET /house/current", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/house/current"); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("returns the household created at signup", async () => { + const agent = request.agent(app); + const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/house/current"); + + expect(res.status).to.equal(200); + expect(res.body).to.deep.equal({ id: signupRes.body.houseId, name: res.body.name }); + }); + }); + + describe("PATCH /house/current", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).patch("/house/current").send({ name: "Chez nous" }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("renames the household", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.patch("/house/current").send({ name: "Chez les Dupont" }); + + expect(res.status).to.equal(200); + expect(res.body.name).to.equal("Chez les Dupont"); + + const refetch = await agent.get("/house/current"); + expect(refetch.body.name).to.equal("Chez les Dupont"); + }); + + it("rejects an empty name with 400 VALIDATION_ERROR", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.patch("/house/current").send({ name: "" }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + }); +}); diff --git a/apps/api/test/profile.test.ts b/apps/api/test/profile.test.ts new file mode 100644 index 0000000..f515935 --- /dev/null +++ b/apps/api/test/profile.test.ts @@ -0,0 +1,128 @@ +import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +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"; + +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +describe("Profile", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("PATCH /profile/diet", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).patch("/profile/diet").send({ dietId: 1 }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("sets the profile's regime to a valid, seeded diet", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } }); + + const res = await agent.patch("/profile/diet").send({ dietId: diet.id }); + + expect(res.status).to.equal(200); + expect(res.body.dietId).to.equal(diet.id); + }); + + it("clears the regime when dietId is null", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végan" } }); + await agent.patch("/profile/diet").send({ dietId: diet.id }); + + const res = await agent.patch("/profile/diet").send({ dietId: null }); + + expect(res.status).to.equal(200); + expect(res.body.dietId).to.equal(null); + }); + + it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.patch("/profile/diet").send({ dietId: 999_999 }); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND); + }); + }); + + describe("GET /profile/allergies + PATCH /profile/allergies", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const getRes = await request(app).get("/profile/allergies"); + const patchRes = await request(app).patch("/profile/allergies").send({ allergyIds: [] }); + + expect(getRes.status).to.equal(401); + expect(patchRes.status).to.equal(401); + }); + + it("starts empty, then reflects a saved selection", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const allergies = await prisma.allergy.findMany({ include: { category: true } }); + const peanuts = allergies.find((a) => a.category.name === "Arachides"); + const gluten = allergies.find((a) => a.category.name === "Gluten"); + if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); + + const initial = await agent.get("/profile/allergies"); + expect(initial.body).to.deep.equal([]); + + const patchRes = await agent + .patch("/profile/allergies") + .send({ allergyIds: [peanuts.id, gluten.id] }); + expect(patchRes.status).to.equal(200); + expect(patchRes.body.sort()).to.deep.equal([peanuts.id, gluten.id].sort()); + + const refetch = await agent.get("/profile/allergies"); + expect(refetch.body.sort()).to.deep.equal([peanuts.id, gluten.id].sort()); + }); + + it("replaces (not merges) the previous selection", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const allergies = await prisma.allergy.findMany({ include: { category: true } }); + const peanuts = allergies.find((a) => a.category.name === "Arachides"); + const gluten = allergies.find((a) => a.category.name === "Gluten"); + if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); + + await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] }); + await agent.patch("/profile/allergies").send({ allergyIds: [gluten.id] }); + + const res = await agent.get("/profile/allergies"); + expect(res.body).to.deep.equal([gluten.id]); + }); + + it("rejects an unknown allergyId with 404 ALLERGY_NOT_FOUND", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.patch("/profile/allergies").send({ allergyIds: [999_999] }); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.ALLERGY_NOT_FOUND); + }); + }); +}); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 5917c23..397c238 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -5,6 +5,9 @@ "INVALID_CREDENTIALS": "Email ou mot de passe incorrect", "NOT_AUTHENTICATED": "Vous devez être connecté", "NOT_FOUND": "Ressource introuvable", + "HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer", + "DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas", + "ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés n'existe pas", "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" }, "auth": { diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts index 52a99a3..b245726 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -34,6 +34,12 @@ export enum ErrorCode { NOT_AUTHENTICATED = 4011, /** No route/resource matches the request. */ NOT_FOUND = 4040, + /** The profile making the request has no household yet (`houseId` is `null`). */ + HOUSE_NOT_FOUND = 4041, + /** A `dietId` was given that doesn't match any reference `Diet` row. */ + DIET_NOT_FOUND = 4042, + /** One or more `allergyIds` don't match any reference `Allergy` row. */ + ALLERGY_NOT_FOUND = 4043, /** Unexpected/unhandled failure — the catch-all, always logged server-side. */ INTERNAL_ERROR = 5000, } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c600342..8f20709 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,7 +5,10 @@ export * from "./errors/error-codes.js"; export * from "./schemas/auth.js"; +export * from "./schemas/household.js"; +export * from "./schemas/profile.js"; export * from "./tools/assert-is-never.js"; +export * from "./types/household.js"; export * from "./types/planning.js"; export * from "./types/reference.js"; export * from "./types/user-profile.js"; diff --git a/packages/shared/src/schemas/household.ts b/packages/shared/src/schemas/household.ts new file mode 100644 index 0000000..bd138aa --- /dev/null +++ b/packages/shared/src/schemas/household.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +// Shared between apps/api (server-side validation) and apps/web (client-side +// validation for instant feedback) — see schemas/auth.ts for the full +// rationale, same pattern here. + +/** Payload accepted by `PATCH /house/current`. */ +export const renameHouseSchema = z.object({ + name: z.string().trim().min(1, "Le nom du foyer est requis").max(100), +}); +/** Inferred TS type for {@link renameHouseSchema}'s validated output. */ +export type RenameHouseInput = z.infer; diff --git a/packages/shared/src/schemas/profile.ts b/packages/shared/src/schemas/profile.ts new file mode 100644 index 0000000..92e0a81 --- /dev/null +++ b/packages/shared/src/schemas/profile.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +// See schemas/auth.ts for the shared client/server validation rationale. + +/** Payload accepted by `PATCH /profile/diet`. `null` clears the profile's regime — this step of the profile journey is skippable. */ +export const updateDietSchema = z.object({ + dietId: z.number().int().positive().nullable(), +}); +/** Inferred TS type for {@link updateDietSchema}'s validated output. */ +export type UpdateDietInput = z.infer; + +/** Payload accepted by `PATCH /profile/allergies`. Replaces the profile's full allergy set — an empty array clears it (also skippable). */ +export const updateAllergiesSchema = z.object({ + allergyIds: z.array(z.number().int().positive()), +}); +/** Inferred TS type for {@link updateAllergiesSchema}'s validated output. */ +export type UpdateAllergiesInput = z.infer; diff --git a/packages/shared/src/types/household.ts b/packages/shared/src/types/household.ts new file mode 100644 index 0000000..32b7c93 --- /dev/null +++ b/packages/shared/src/types/household.ts @@ -0,0 +1,9 @@ +/** + * A household, as returned by `GET /house/current` / `PATCH /house/current`. + * Unlike `DietView`/`AllergyView` this isn't reference data — it's the + * current user's own household. + */ +export interface HouseView { + id: number; + name: string; +} From e512c33ffcb212ded91a832b704cb26f87ae9779 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 16 Aug 2026 23:22:19 +0200 Subject: [PATCH 3/9] =?UTF-8?q?Web:=20composants=20partag=C3=A9s=20foyer/r?= =?UTF-8?q?=C3=A9gime/allerg=C3=A8nes=20(step=203/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApiClient: getDiets/getAllergies (référence), getCurrentHouse/ renameHouse, updateDiet, getAllergyIds/updateAllergyIds. - features/profile/: HouseNameField, DietSelect (toujours une option "aucun régime" -> null, étape skippable), AllergySelect (checkboxes en grille + fieldset/legend, pas un ` — far more + * discoverable/tappable, especially on the mobile viewport this app is + * eventually embedded into via Capacitor) for allergens/intolerances. Used + * both by the signup wizard's allergens step and the `/foyer` settings + * page. An empty `value` is a normal, valid state (no declared allergies, + * or this skippable step was skipped), not an incomplete one. + * + * A `
`/`` (not a bare `