diff --git a/README.md b/README.md
index 9e40008..6a2261d 100644
--- a/README.md
+++ b/README.md
@@ -186,6 +186,63 @@ 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.
+
+**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
+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`) :
@@ -211,17 +268,67 @@ Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — s
` ` pour la route active — montée une seule fois comme route parente de tout
l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx`
(routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`,
-voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes`, `Liste de
-courses` et `Foyer & profil` n'ont pas encore de backend dédié et rendent pour
-l'instant le même composant `ComingSoonPage`. Détail complet (pourquoi une seule
-route parente, pourquoi un composant stub partagé) :
+voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes` et `Liste de
+courses` n'ont pas encore de backend dédié et rendent pour l'instant le même
+composant `ComingSoonPage` — `Foyer & profil` (`src/pages/HouseholdPage.tsx`), lui,
+est une vraie page (voir section suivante). Détail complet (pourquoi une seule route
+parente, pourquoi un composant stub partagé) :
[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
+## Parcours profil — foyer, régime, allergènes (apps/web)
+
+- `src/features/profile/` — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
+ contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien
+ eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une
+ grille de cases à cocher dans un `
`/`` plutôt qu'un
+ `` — bien plus repérable/tapable, notamment sur mobile. Prend un
+ `legend` en prop (pas un libellé fixe interne) : le même composant est rendu
+ **deux fois** par chaque page consommatrice — une fois pour les allergies
+ (`AllergyView.kind === "ALLERGY"`), une fois pour les intolérances
+ (`"INTOLERANCE"`) — les deux listes filtrées côté client à partir d'un seul
+ `GET /reference/allergies`, mais la sélection (`allergyIds`) reste une seule
+ liste d'IDs partagée entre les deux groupes (une seule `PATCH /profile/allergies`).
+- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
+ l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage` →
+ `OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`).
+ Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante (y
+ compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip
+ implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
+ wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`.
+- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes réglages, modifiables
+ à tout moment. **Hot saving** (retour fonctionnel) : pas de bouton "Enregistrer",
+ chaque section sauvegarde automatiquement peu après la dernière modification —
+ nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms,
+ pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé
+ immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le
+ handler `onChange` de chaque champ, jamais depuis un `useEffect` générique qui
+ observerait la valeur — un tel effect se déclencherait aussi au chargement
+ initial (quand le `GET` peuple le même state), sans moyen propre de distinguer
+ "vient d'être chargé" de "vient d'être modifié par l'utilisateur".
+
+**Piège trouvé en testant dans le navigateur** : `RedirectIfAuthenticated` (garde de
+`/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la
+vérification initiale — un `navigate()` explicite dans le gestionnaire de soumission
+d'un formulaire qu'elle protège (ex. `SignupPage` après `signup()`, qui met `user` à
+jour) entre alors en course avec le propre `` de la garde. Invisible tant
+que les deux ciblaient "/", devenu un vrai bug dès que `SignupPage` a dû rediriger
+ailleurs (`/onboarding/foyer`). Fix : la décision de redirection est verrouillée une
+seule fois, au moment où `isLoading` passe à `false`, plus jamais réévaluée après.
+
+**Autre piège, même méthode** : `HouseholdPage` initialisait le régime affiché depuis
+`useAuth().user.dietId` (un instantané jamais rafraîchi après une modification faite
+directement via `apiClient`, qui ne touche pas `AuthContext`) — revenait à l'ancienne
+valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la
+page fetch son propre profil frais (`apiClient.me()`) au montage, et
+`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
+régime pour que le reste de l'app reste cohérent aussi.
+
Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` +
-`home-planning.cy.ts` mockent l'API via `cy.intercept` plutôt que de dépendre d'un
-vrai backend — le job e2e de la CI ne provisionne pas de Postgres/API, seulement le
-serveur de dev Vite. Le comportement réel de l'API est couvert par les suites
-Mocha/Cucumber d'`apps/api` (contre une vraie base).
+`home-planning.cy.ts` + `onboarding.cy.ts` + `household.cy.ts` mockent l'API via
+`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
+provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
+réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une
+vraie base).
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
> Chromium/Electron headless plante au lancement du process GPU
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/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/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/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/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 c94073e..ebe8716 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -22,19 +22,35 @@ 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[]
@@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. `kind` is
+/// also not in the original spec doc — see {@link AllergenKind}.
model Category {
- id Int @id @default(autoincrement())
- name String
+ id Int @id @default(autoincrement())
+ name String @unique
+ kind AllergenKind @default(ALLERGY)
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..c4f8727 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -4,7 +4,10 @@ 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";
/**
* Builds the API's `ExpressServer`: standard middleware, routes, and the
@@ -23,7 +26,10 @@ 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
// 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..4d4eeaf
--- /dev/null
+++ b/apps/api/src/db/reference-seed-data.ts
@@ -0,0 +1,61 @@
+import type { AllergenKind, 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. 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" },
+];
+
+/**
+ * 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. `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: { kind },
+ create: { name, kind },
+ });
+ 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/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/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..2c43575
--- /dev/null
+++ b/apps/api/src/modules/reference/reference.service.ts
@@ -0,0 +1,25 @@
+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, kind: true } } },
+ orderBy: { category: { name: "asc" } },
+ });
+ return allergies.map((allergy) => ({
+ id: allergy.id,
+ name: allergy.category.name,
+ kind: allergy.category.kind,
+ }));
+}
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/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/api/test/reference.test.ts b/apps/api/test/reference.test.ts
new file mode 100644
index 0000000..85e048b
--- /dev/null
+++ b/apps/api/test/reference.test.ts
@@ -0,0 +1,49 @@
+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", "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/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts
index 0f5db35..40250ac 100644
--- a/apps/web/cypress/e2e/auth.cy.ts
+++ b/apps/web/cypress/e2e/auth.cy.ts
@@ -6,9 +6,14 @@ import { ErrorCode } from "@batch-cooking/shared";
// suites against a real database.
describe("Signup", () => {
- it("creates a profile and lands on the home page", () => {
+ it("creates a profile and starts the onboarding wizard (household/regime/allergens)", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
- cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
+ // The onboarding wizard's first step (see onboarding.cy.ts for the full
+ // walkthrough) reads the household right away to prefill its name field.
+ cy.intercept("GET", "**/house/current", {
+ statusCode: 200,
+ body: { id: 1, name: "Foyer de Alice" },
+ });
cy.intercept("POST", "**/auth/signup", {
statusCode: 201,
body: {
@@ -30,8 +35,11 @@ describe("Signup", () => {
cy.contains("button", "Créer mon profil").click();
cy.wait("@signup");
- cy.url().should("not.include", "/signup");
- cy.contains("Bonjour Alice").should("be.visible");
+ // Not the home page directly — signup hands off to the onboarding
+ // wizard first (RedirectIfAuthenticated no longer applies here, it's a
+ // RequireAuth-gated route of its own, see App.tsx).
+ cy.url().should("include", "/onboarding/foyer");
+ cy.get("#houseName").should("have.value", "Foyer de Alice");
});
it("shows a client-side validation error without calling the API", () => {
diff --git a/apps/web/cypress/e2e/household.cy.ts b/apps/web/cypress/e2e/household.cy.ts
new file mode 100644
index 0000000..9255797
--- /dev/null
+++ b/apps/web/cypress/e2e/household.cy.ts
@@ -0,0 +1,101 @@
+// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
+
+const authenticatedProfile = {
+ id: 1,
+ firstName: "Alice",
+ lastName: "Martin",
+ email: "alice@example.com",
+ tokenVersion: 0,
+ houseId: 1,
+ dietId: 2,
+};
+
+describe("Household & profile settings (/foyer) — hot saving", () => {
+ beforeEach(() => {
+ cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
+ cy.intercept("GET", "**/house/current", {
+ statusCode: 200,
+ body: { id: 1, name: "Chez Alice" },
+ });
+ cy.intercept("GET", "**/reference/diets", {
+ statusCode: 200,
+ body: [
+ { id: 1, name: "Omnivore" },
+ { id: 2, name: "Végétarien" },
+ ],
+ });
+ cy.intercept("GET", "**/reference/allergies", {
+ statusCode: 200,
+ body: [
+ { id: 1, name: "Arachides", kind: "ALLERGY" },
+ { id: 2, name: "Gluten", kind: "INTOLERANCE" },
+ ],
+ });
+ cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
+ });
+
+ it("loads the current household name, regime, and shows allergies/intolerances as two groups", () => {
+ cy.visit("/foyer");
+
+ cy.get("#houseName").should("have.value", "Chez Alice");
+ cy.get("#diet").should("have.value", "2");
+ cy.contains("legend", "Allergies").should("be.visible");
+ cy.contains("legend", "Intolérances").should("be.visible");
+ cy.contains("label", "Gluten").find("input[type=checkbox]").should("be.checked");
+ cy.contains("label", "Arachides").find("input[type=checkbox]").should("not.be.checked");
+ });
+
+ it("has no explicit save button anywhere on the page", () => {
+ cy.visit("/foyer");
+ cy.contains("button", "Enregistrer").should("not.exist");
+ });
+
+ it("autosaves the household name a short pause after typing, no button click", () => {
+ cy.intercept("PATCH", "**/house/current", {
+ statusCode: 200,
+ body: { id: 1, name: "Chez les Martin" },
+ }).as("renameHouse");
+
+ cy.visit("/foyer");
+ cy.get("#houseName").clear();
+ cy.get("#houseName").type("Chez les Martin");
+
+ cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" });
+ cy.contains("Enregistré ✓").should("be.visible");
+ });
+
+ it("does not autosave an empty household name — shows a validation message instead", () => {
+ cy.intercept("PATCH", "**/house/current").as("renameHouse");
+
+ cy.visit("/foyer");
+ cy.get("#houseName").clear();
+
+ cy.contains("Le nom du foyer est requis").should("be.visible");
+ cy.get("@renameHouse.all").should("have.length", 0);
+ });
+
+ it("autosaves the regime as soon as it's selected", () => {
+ cy.intercept("PATCH", "**/profile/diet", {
+ statusCode: 200,
+ body: { ...authenticatedProfile, dietId: 1 },
+ }).as("updateDiet");
+
+ cy.visit("/foyer");
+ cy.get("#diet").select("Omnivore");
+
+ cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 });
+ });
+
+ it("autosaves allergies and intolerances together after checking boxes", () => {
+ cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [2, 1] }).as(
+ "updateAllergies",
+ );
+
+ cy.visit("/foyer");
+ cy.contains("label", "Arachides").find("input[type=checkbox]").check();
+
+ cy.wait("@updateAllergies")
+ .its("request.body")
+ .should("deep.equal", { allergyIds: [2, 1] });
+ });
+});
diff --git a/apps/web/cypress/e2e/onboarding.cy.ts b/apps/web/cypress/e2e/onboarding.cy.ts
new file mode 100644
index 0000000..39dbd0f
--- /dev/null
+++ b/apps/web/cypress/e2e/onboarding.cy.ts
@@ -0,0 +1,131 @@
+// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
+// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
+// real API behavior against a real database).
+
+const signupResponse = {
+ id: 1,
+ firstName: "Alice",
+ lastName: "Martin",
+ email: "alice@example.com",
+ tokenVersion: 0,
+ houseId: 1,
+ dietId: null,
+};
+
+describe("Onboarding wizard (household → regime → allergens)", () => {
+ it("walks through all three steps after signup and lands on the home", () => {
+ cy.intercept("GET", "**/auth/me", { statusCode: 401 });
+ cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup");
+ cy.intercept("GET", "**/house/current", {
+ statusCode: 200,
+ body: { id: 1, name: "Foyer de Alice" },
+ });
+ cy.intercept("PATCH", "**/house/current", {
+ statusCode: 200,
+ body: { id: 1, name: "Chez Alice" },
+ }).as("renameHouse");
+ cy.intercept("GET", "**/reference/diets", {
+ statusCode: 200,
+ body: [
+ { id: 1, name: "Omnivore" },
+ { id: 2, name: "Végétarien" },
+ ],
+ });
+ cy.intercept("PATCH", "**/profile/diet", {
+ statusCode: 200,
+ body: { ...signupResponse, dietId: 2 },
+ }).as("updateDiet");
+ cy.intercept("GET", "**/reference/allergies", {
+ statusCode: 200,
+ body: [
+ { id: 1, name: "Arachides", kind: "ALLERGY" },
+ { id: 2, name: "Gluten", kind: "INTOLERANCE" },
+ ],
+ });
+ cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as(
+ "updateAllergies",
+ );
+ cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
+
+ cy.visit("/signup");
+ cy.get("#firstName").type("Alice");
+ cy.get("#lastName").type("Martin");
+ cy.get("#email").type("alice@example.com");
+ cy.get("#password").type("correct-horse-battery-staple");
+ cy.contains("button", "Créer mon profil").click();
+ cy.wait("@signup");
+
+ // Step 1/3 — household name, prefilled with the auto-generated default.
+ cy.url().should("include", "/onboarding/foyer");
+ cy.contains("Étape 1 sur 3").should("be.visible");
+ cy.get("#houseName").should("have.value", "Foyer de Alice");
+ cy.get("#houseName").clear();
+ cy.get("#houseName").type("Chez Alice");
+ cy.contains("button", "Continuer").click();
+ cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez Alice" });
+
+ // Step 2/3 — dietary regime.
+ cy.url().should("include", "/onboarding/regime");
+ cy.contains("Étape 2 sur 3").should("be.visible");
+ cy.get("#diet").select("Végétarien");
+ cy.contains("button", "Continuer").click();
+ cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 });
+
+ // Step 3/3 — allergens (grouped into two lists) and intolerances, then finish.
+ cy.url().should("include", "/onboarding/allergenes");
+ cy.contains("Étape 3 sur 3").should("be.visible");
+ cy.contains("legend", "Allergies").should("be.visible");
+ cy.contains("legend", "Intolérances").should("be.visible");
+ cy.contains("label", "Arachides").find("input[type=checkbox]").check();
+ cy.contains("button", "Terminer").click();
+ cy.wait("@updateAllergies")
+ .its("request.body")
+ .should("deep.equal", { allergyIds: [1] });
+
+ cy.url().should("eq", `${Cypress.config().baseUrl}/`);
+ cy.contains("h1", "Planning de la semaine").should("be.visible");
+ });
+
+ it("lets every step be skipped without changing anything", () => {
+ cy.intercept("GET", "**/auth/me", { statusCode: 401 });
+ cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse });
+ cy.intercept("GET", "**/house/current", {
+ statusCode: 200,
+ body: { id: 1, name: "Foyer de Alice" },
+ });
+ cy.intercept("PATCH", "**/house/current", {
+ statusCode: 200,
+ body: { id: 1, name: "Foyer de Alice" },
+ }).as("renameHouse");
+ cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
+ cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }).as(
+ "updateDiet",
+ );
+ cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
+ cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }).as(
+ "updateAllergies",
+ );
+ cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
+
+ cy.visit("/signup");
+ cy.get("#firstName").type("Alice");
+ cy.get("#lastName").type("Martin");
+ cy.get("#email").type("alice@example.com");
+ cy.get("#password").type("correct-horse-battery-staple");
+ cy.contains("button", "Créer mon profil").click();
+
+ cy.url().should("include", "/onboarding/foyer");
+ cy.contains("button", "Continuer").click();
+ cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Foyer de Alice" });
+
+ cy.url().should("include", "/onboarding/regime");
+ cy.contains("button", "Continuer").click();
+ cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: null });
+
+ cy.url().should("include", "/onboarding/allergenes");
+ cy.contains("button", "Terminer").click();
+ cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] });
+
+ cy.url().should("eq", `${Cypress.config().baseUrl}/`);
+ });
+});
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 3709ca9..94cc69c 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -8,6 +8,9 @@ import { LoginPage } from "./pages/LoginPage";
import { RecipesPage } from "./pages/RecipesPage";
import { ShoppingListPage } from "./pages/ShoppingListPage";
import { SignupPage } from "./pages/SignupPage";
+import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
+import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
+import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
/**
* Top-level route table. Every authenticated section is nested under one
@@ -16,6 +19,12 @@ import { SignupPage } from "./pages/SignupPage";
* `/signup` redirect an already-logged-in visitor to `/` instead (see
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
* itself redirects to `/login` if needed.
+ *
+ * `/onboarding/*` (household/regime/allergens) is also `RequireAuth`-gated
+ * — reached right after signup, once a session already exists — but
+ * deliberately its own top-level route group, *not* nested under
+ * `AppLayout`: a focused, distraction-free wizard with no sidebar, same
+ * full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
*/
export function App() {
return (
@@ -32,6 +41,30 @@ export function App() {
} />
} />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
{
return this.request("/planning/current");
}
+
+ /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
+ public getDiets(): Promise {
+ return this.request("/reference/diets");
+ }
+
+ /** Reference list of selectable allergens (signup wizard, `/foyer`). Public — no session required. */
+ public getAllergies(): Promise {
+ return this.request("/reference/allergies");
+ }
+
+ /** Fetches the current user's household. */
+ public getCurrentHouse(): Promise {
+ return this.request("/house/current");
+ }
+
+ /** Renames the current user's household. */
+ public renameHouse(name: string): Promise {
+ return this.request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) });
+ }
+
+ /** Sets (or clears, with `null`) the current user's dietary regime. */
+ public updateDiet(dietId: number | null): Promise {
+ return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
+ }
+
+ /** Fetches the current user's selected allergen ids. */
+ public getAllergyIds(): Promise {
+ return this.request("/profile/allergies");
+ }
+
+ /** Replaces the current user's full allergen selection (not a merge — send the complete list). */
+ public updateAllergyIds(allergyIds: number[]): Promise {
+ return this.request("/profile/allergies", {
+ method: "PATCH",
+ body: JSON.stringify({ allergyIds }),
+ });
+ }
}
/** Single shared instance — this client is stateless, no need for one per caller. */
diff --git a/apps/web/src/features/auth/AuthContext.tsx b/apps/web/src/features/auth/AuthContext.tsx
index a63bf00..cca04ac 100644
--- a/apps/web/src/features/auth/AuthContext.tsx
+++ b/apps/web/src/features/auth/AuthContext.tsx
@@ -14,6 +14,14 @@ interface AuthContextValue {
login: (input: LoginInput) => Promise;
/** Ends the session and clears `user`. */
logout: () => Promise;
+ /**
+ * Re-fetches the current profile and updates `user`. Needed after
+ * anything that changes profile fields `user` carries (e.g. `dietId`)
+ * outside of `signup`/`login` — `PATCH /profile/diet` (see
+ * `HouseholdPage.tsx`) updates the database directly via `apiClient`,
+ * which doesn't touch this context on its own.
+ */
+ refreshUser: () => Promise;
}
/** React context carrying {@link AuthContextValue} — always accessed through {@link useAuth}, never directly. */
@@ -51,8 +59,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null);
}, []);
+ const refreshUser = useCallback(async () => {
+ setUser(await apiClient.me());
+ }, []);
+
return (
-
+
{children}
);
diff --git a/apps/web/src/features/auth/RedirectIfAuthenticated.tsx b/apps/web/src/features/auth/RedirectIfAuthenticated.tsx
index d4540b5..e20d8b4 100644
--- a/apps/web/src/features/auth/RedirectIfAuthenticated.tsx
+++ b/apps/web/src/features/auth/RedirectIfAuthenticated.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import { type ReactNode, useRef } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "./AuthContext";
@@ -6,14 +6,32 @@ import { useAuth } from "./AuthContext";
* Route guard for pages that make no sense to an already-authenticated
* visitor (`/login`, `/signup`). Mirrors {@link RequireAuth}'s waiting
* behavior while the initial session check is pending.
+ *
+ * The redirect decision is latched exactly once, the first moment the
+ * initial `isLoading` check resolves — deliberately *not* reactive to
+ * `user` changing afterward. Without this, a guarded page's own action
+ * that authenticates the visitor (e.g. `SignupPage`'s submit handler
+ * calling `signup()`, which sets `user`, then explicitly navigating
+ * elsewhere) races this guard: `user` becoming truthy while still mounted
+ * fires this component's own ``, competing with — and
+ * sometimes winning against — the page's explicit `navigate(...)` call to
+ * a different destination. Previously invisible only because the old
+ * `SignupPage` happened to navigate to the same place ("/") this guard
+ * does; became visible once it needed to route to `/onboarding/foyer`
+ * instead.
*/
export function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth();
+ const shouldRedirect = useRef(null);
- if (isLoading) {
+ if (!isLoading && shouldRedirect.current === null) {
+ shouldRedirect.current = user !== null;
+ }
+
+ if (isLoading || shouldRedirect.current === null) {
return null;
}
- if (user) {
+ if (shouldRedirect.current) {
return ;
}
return <>{children}>;
diff --git a/apps/web/src/features/profile/AllergySelect.tsx b/apps/web/src/features/profile/AllergySelect.tsx
new file mode 100644
index 0000000..d271ac3
--- /dev/null
+++ b/apps/web/src/features/profile/AllergySelect.tsx
@@ -0,0 +1,50 @@
+import type { AllergyView } from "@batch-cooking/shared";
+import "./profile-forms.scss";
+
+interface AllergySelectProps {
+ legend: string;
+ allergies: AllergyView[];
+ value: number[];
+ onChange: (allergyIds: number[]) => void;
+}
+
+/**
+ * Multi-select (checkbox grid, not a native `` — far more
+ * discoverable/tappable, especially on the mobile viewport this app is
+ * eventually embedded into via Capacitor) for a group of allergens. Used
+ * both by the signup wizard's allergens step and the `/foyer` settings
+ * page, and rendered *twice* by each — once for allergies, once for
+ * intolerances (`AllergyView.kind` groups them; callers filter and pass
+ * two separate lists rather than this component knowing about the split).
+ * An empty `value` is a normal, valid state (no declared allergies, or
+ * this skippable step was skipped), not an incomplete one.
+ *
+ * `legend` (not a fixed internal label) — the same component serves both
+ * groups, only the heading differs. A ``/`` (not a bare
+ * ``, which only associates with a single control) is the correct
+ * semantic label for a group of checkboxes.
+ *
+ * Receives `allergies` as a prop rather than fetching them itself — same
+ * rationale as `DietSelect`.
+ */
+export function AllergySelect({ legend, allergies, value, onChange }: AllergySelectProps) {
+ function toggle(id: number) {
+ onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]);
+ }
+
+ return (
+
+ {legend}
+ {allergies.map((allergy) => (
+
+ toggle(allergy.id)}
+ />
+ {allergy.name}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/features/profile/DietSelect.tsx b/apps/web/src/features/profile/DietSelect.tsx
new file mode 100644
index 0000000..c936aaa
--- /dev/null
+++ b/apps/web/src/features/profile/DietSelect.tsx
@@ -0,0 +1,42 @@
+import type { DietView } from "@batch-cooking/shared";
+import { useTranslation } from "react-i18next";
+import "./profile-forms.scss";
+
+interface DietSelectProps {
+ diets: DietView[];
+ value: number | null;
+ onChange: (dietId: number | null) => void;
+}
+
+/**
+ * Dropdown picker for a dietary regime — used both by the signup wizard's
+ * regime step and the `/foyer` settings page. Always includes a "none"
+ * option (mapped to `null`, not just an empty label) since this step of the
+ * profile journey is skippable — a profile with no regime is a normal,
+ * valid state, not an incomplete one.
+ *
+ * Receives `diets` as a prop rather than fetching them itself: the caller
+ * (a page) owns loading state/errors for the reference list, this stays a
+ * plain, easy-to-test presentational component.
+ */
+export function DietSelect({ diets, value, onChange }: DietSelectProps) {
+ const { t } = useTranslation();
+
+ return (
+ <>
+ {t("household.form.dietLabel")}
+ onChange(e.target.value === "" ? null : Number(e.target.value))}
+ >
+ {t("household.form.dietNone")}
+ {diets.map((diet) => (
+
+ {diet.name}
+
+ ))}
+
+ >
+ );
+}
diff --git a/apps/web/src/features/profile/HouseNameField.tsx b/apps/web/src/features/profile/HouseNameField.tsx
new file mode 100644
index 0000000..80e4a53
--- /dev/null
+++ b/apps/web/src/features/profile/HouseNameField.tsx
@@ -0,0 +1,32 @@
+import { useTranslation } from "react-i18next";
+import "./profile-forms.scss";
+
+interface HouseNameFieldProps {
+ value: string;
+ onChange: (name: string) => void;
+ error?: string;
+}
+
+/**
+ * Labeled text input for the household's name — used both by the signup
+ * wizard's household step and the `/foyer` settings page (see
+ * `profile-forms.scss` for the shared styling both consume). Controlled
+ * component: the caller owns the value and persists it (`ApiClient.
+ * renameHouse`) on submit, not this component.
+ */
+export function HouseNameField({ value, onChange, error }: HouseNameFieldProps) {
+ const { t } = useTranslation();
+
+ return (
+ <>
+ {t("household.form.nameLabel")}
+ onChange(e.target.value)}
+ autoComplete="off"
+ />
+ {error && {error}
}
+ >
+ );
+}
diff --git a/apps/web/src/features/profile/profile-forms.scss b/apps/web/src/features/profile/profile-forms.scss
new file mode 100644
index 0000000..7ac4a1e
--- /dev/null
+++ b/apps/web/src/features/profile/profile-forms.scss
@@ -0,0 +1,74 @@
+// =============================================================================
+// Styles shared by the profile-journey field components (HouseNameField,
+// DietSelect, AllergySelect) — used both by the signup wizard's steps
+// (pages/onboarding/) and the `/foyer` settings page (HouseholdPage). Field
+// styling only (label/input/select/checkbox) — the surrounding page/card
+// layout belongs to each consuming page's own .scss, same split as
+// features/auth/auth-form.scss vs. LoginPage/SignupPage.
+// =============================================================================
+
+// No `@use` of the theme partial needed — every token below is a runtime
+// CSS custom property, see global.scss.
+
+label {
+ display: block;
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ margin-top: var(--space-sm);
+}
+
+input,
+select {
+ width: 100%;
+ padding: var(--space-sm);
+ font-family: var(--font-body);
+ font-size: var(--font-size-base);
+ color: var(--color-text);
+ background: var(--color-background);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-base);
+}
+
+.field-error {
+ color: var(--color-error);
+ font-size: var(--font-size-xs);
+ margin: 0;
+}
+
+// Checkbox grid for allergens/intolerances — a grid of tappable rows reads
+// better than a native multi-select listbox, especially on the narrow
+// viewport this app is eventually embedded into via Capacitor (see
+// AllergySelect.tsx).
+.allergy-select {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+ gap: var(--space-xs) var(--space-md);
+ // Reset the browser's default fieldset chrome (border/padding) — the
+ // grid above is styling enough, this shouldn't look like a boxed panel.
+ margin: var(--space-sm) 0 0;
+ padding: 0;
+ border: none;
+
+ legend {
+ grid-column: 1 / -1;
+ padding: 0;
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ }
+
+ &__option {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
+ // Overrides the block/margin-top label rule above — this label wraps
+ // an inline checkbox + text pair, not a field caption above an input.
+ margin-top: 0;
+ font-weight: 400;
+ font-size: var(--font-size-base);
+ cursor: pointer;
+
+ input[type="checkbox"] {
+ width: auto;
+ }
+ }
+}
diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json
index 5917c23..9f9ef04 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": {
@@ -29,6 +32,21 @@
"loginLink": "Se connecter"
}
},
+ "onboarding": {
+ "step": "Étape {{current}} sur {{total}}",
+ "continue": "Continuer",
+ "finish": "Terminer",
+ "loading": "Chargement…",
+ "household": {
+ "title": "Comment s'appelle votre foyer ?"
+ },
+ "diet": {
+ "title": "Un régime alimentaire particulier ?"
+ },
+ "allergens": {
+ "title": "Des allergies ou intolérances ?"
+ }
+ },
"layout": {
"nav": {
"planning": "Planning",
@@ -60,6 +78,14 @@
},
"household": {
"title": "Foyer & profil",
- "comingSoon": "Cette section arrive bientôt."
+ "form": {
+ "nameLabel": "Nom du foyer",
+ "dietLabel": "Régime alimentaire",
+ "dietNone": "Aucun régime particulier",
+ "allergiesLabel": "Allergies",
+ "intolerancesLabel": "Intolérances",
+ "saving": "Enregistrement…",
+ "saved": "Enregistré ✓"
+ }
}
}
diff --git a/apps/web/src/pages/HouseholdPage.scss b/apps/web/src/pages/HouseholdPage.scss
new file mode 100644
index 0000000..b5297c0
--- /dev/null
+++ b/apps/web/src/pages/HouseholdPage.scss
@@ -0,0 +1,46 @@
+// =============================================================================
+// Styles specific to HouseholdPage — colocated next to HouseholdPage.tsx
+// since nothing else uses these classes. Field/label/input styling itself
+// comes from features/profile/profile-forms.scss (shared with the
+// onboarding wizard); this file only covers this page's own layout.
+// =============================================================================
+
+.household-page {
+ &__status {
+ color: var(--color-text-muted);
+ font-size: var(--font-size-md);
+ }
+
+ &__status--error {
+ color: var(--color-error);
+ }
+}
+
+// Each of the three settings (household name, regime, allergies +
+// intolerances) is its own independently-autosaved section — a card per
+// section, same surface treatment used elsewhere (see .planning-table in
+// HomePage.scss), so each reads as a distinct, self-contained unit rather
+// than one long form. No buttons here (hot saving — see HouseholdPage.tsx).
+.household-page__section {
+ max-width: 32rem;
+ margin-top: var(--space-lg);
+ padding: var(--space-lg);
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
+}
+
+.household-page__saving,
+.household-page__saved {
+ margin: var(--space-sm) 0 0;
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+}
+
+.household-page__saving {
+ color: var(--color-text-muted);
+}
+
+.household-page__saved {
+ color: var(--color-success);
+}
diff --git a/apps/web/src/pages/HouseholdPage.tsx b/apps/web/src/pages/HouseholdPage.tsx
index 8502f37..eebae37 100644
--- a/apps/web/src/pages/HouseholdPage.tsx
+++ b/apps/web/src/pages/HouseholdPage.tsx
@@ -1,8 +1,239 @@
+import {
+ type AllergyView,
+ type DietView,
+ ErrorCode,
+ renameHouseSchema,
+} from "@batch-cooking/shared";
+import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { ComingSoonPage } from "./ComingSoonPage";
+import { ApiError, apiClient } from "../api/client";
+import { useAuth } from "../features/auth/AuthContext";
+import { AllergySelect } from "../features/profile/AllergySelect";
+import { DietSelect } from "../features/profile/DietSelect";
+import { HouseNameField } from "../features/profile/HouseNameField";
+import { fieldErrorsFrom } from "../lib/zod-errors";
+import { errorMessageService } from "../services/error-message.service";
+import "./HouseholdPage.scss";
-/** Household & profile section — routed at `/foyer`. No backend yet beyond auth, stub for now. */
+/** Status of one section's own autosave — sections save independently, each with its own feedback. */
+type SaveState = "idle" | "saving" | "saved" | "error";
+
+/** Debounce for the household name field (typing) — long enough that saves don't fire on every keystroke. */
+const HOUSE_NAME_DEBOUNCE_MS = 600;
+/** Debounce for the allergen checkboxes — coalesces a quick burst of several toggles into one request. */
+const ALLERGIES_DEBOUNCE_MS = 500;
+
+/**
+ * Household & profile settings — routed at `/foyer`. The always-available
+ * counterpart to the signup wizard (`pages/onboarding/`): same concerns
+ * (household name, dietary regime, allergies, intolerances), same shared
+ * field components, but editable at any time rather than run once.
+ *
+ * Hot saving (retour fonctionnel) — no "Enregistrer" buttons; each section
+ * autosaves shortly after the user stops changing it. Saves are triggered
+ * from the field's own `onChange` handler, *not* a generic `useEffect`
+ * watching the value: an effect keyed on the value would also fire the
+ * moment the initial `GET` calls populate that same state, with no clean
+ * way to tell "just loaded" apart from "user edited" — routing every save
+ * through an explicit handler sidesteps that entirely, since the initial
+ * load never goes through these handlers.
+ */
export function HouseholdPage() {
const { t } = useTranslation();
- return ;
+ const { refreshUser } = useAuth();
+
+ const [isLoading, setIsLoading] = useState(true);
+ const [loadError, setLoadError] = useState(false);
+
+ const [houseName, setHouseName] = useState("");
+ const [houseNameErrors, setHouseNameErrors] = useState>({});
+ const [houseSaveState, setHouseSaveState] = useState("idle");
+ const [houseSaveError, setHouseSaveError] = useState(null);
+ const houseNameTimeout = useRef(undefined);
+
+ const [diets, setDiets] = useState([]);
+ const [dietId, setDietId] = useState(null);
+ const [dietSaveState, setDietSaveState] = useState("idle");
+ const [dietSaveError, setDietSaveError] = useState(null);
+
+ const [allergies, setAllergies] = useState([]);
+ const [allergyIds, setAllergyIds] = useState([]);
+ const [allergySaveState, setAllergySaveState] = useState("idle");
+ const [allergySaveError, setAllergySaveError] = useState(null);
+ const allergiesTimeout = useRef(undefined);
+
+ useEffect(() => {
+ let cancelled = false;
+ // `apiClient.me()` here (not `useAuth().user.dietId`) — this page can
+ // be revisited many times over a session without a full reload, and
+ // AuthContext's `user` only refreshes on app load or after an
+ // explicit `refreshUser()` call; relying on it directly would show a
+ // stale `dietId` after navigating away and back post-save.
+ Promise.all([
+ apiClient.getCurrentHouse(),
+ apiClient.getDiets(),
+ apiClient.getAllergies(),
+ apiClient.getAllergyIds(),
+ apiClient.me(),
+ ])
+ .then(([house, dietsResult, allergiesResult, allergyIdsResult, profile]) => {
+ if (cancelled) return;
+ setHouseName(house?.name ?? "");
+ setDiets(dietsResult);
+ setAllergies(allergiesResult);
+ setAllergyIds(allergyIdsResult);
+ setDietId(profile.dietId);
+ })
+ .catch(() => {
+ if (!cancelled) setLoadError(true);
+ })
+ .finally(() => {
+ if (!cancelled) setIsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Pending debounced saves must not fire after unmount (e.g. the user
+ // navigates away mid-debounce).
+ useEffect(() => {
+ return () => {
+ window.clearTimeout(houseNameTimeout.current);
+ window.clearTimeout(allergiesTimeout.current);
+ };
+ }, []);
+
+ function handleHouseNameChange(name: string) {
+ setHouseName(name);
+ window.clearTimeout(houseNameTimeout.current);
+
+ const result = renameHouseSchema.safeParse({ name });
+ if (!result.success) {
+ setHouseNameErrors(fieldErrorsFrom(result.error));
+ setHouseSaveState("idle");
+ return;
+ }
+ setHouseNameErrors({});
+ setHouseSaveState("saving");
+ houseNameTimeout.current = window.setTimeout(async () => {
+ try {
+ await apiClient.renameHouse(result.data.name);
+ setHouseSaveState("saved");
+ } catch (err) {
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setHouseSaveError(errorMessageService.getLabel(code));
+ setHouseSaveState("error");
+ }
+ }, HOUSE_NAME_DEBOUNCE_MS);
+ }
+
+ async function handleDietChange(newDietId: number | null) {
+ setDietId(newDietId);
+ setDietSaveState("saving");
+ try {
+ await apiClient.updateDiet(newDietId);
+ // Keeps AuthContext's `user.dietId` in sync — nothing else reads it
+ // today, but the sidebar/anywhere else that might in the future
+ // shouldn't have to know this page exists to stay correct.
+ await refreshUser();
+ setDietSaveState("saved");
+ } catch (err) {
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setDietSaveError(errorMessageService.getLabel(code));
+ setDietSaveState("error");
+ }
+ }
+
+ function handleAllergyIdsChange(newAllergyIds: number[]) {
+ setAllergyIds(newAllergyIds);
+ window.clearTimeout(allergiesTimeout.current);
+ setAllergySaveState("saving");
+ allergiesTimeout.current = window.setTimeout(async () => {
+ try {
+ await apiClient.updateAllergyIds(newAllergyIds);
+ setAllergySaveState("saved");
+ } catch (err) {
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setAllergySaveError(errorMessageService.getLabel(code));
+ setAllergySaveState("error");
+ }
+ }, ALLERGIES_DEBOUNCE_MS);
+ }
+
+ if (isLoading) {
+ return (
+
+
{t("household.title")}
+
{t("onboarding.loading")}
+
+ );
+ }
+
+ if (loadError) {
+ return (
+
+
{t("household.title")}
+
{t("home.error")}
+
+ );
+ }
+
+ return (
+
+
{t("household.title")}
+
+
+
+
+
+
+
+
+
+
+
+
+
allergy.kind === "ALLERGY")}
+ value={allergyIds}
+ onChange={handleAllergyIdsChange}
+ />
+ allergy.kind === "INTOLERANCE")}
+ value={allergyIds}
+ onChange={handleAllergyIdsChange}
+ />
+
+
+
+ );
+}
+
+/** Inline "saving…"/"saved ✓"/error feedback shared by every autosaved section — `idle` renders nothing. */
+function SaveStatus({
+ state,
+ error,
+ t,
+}: {
+ state: SaveState;
+ error: string | null;
+ t: (key: string) => string;
+}) {
+ if (state === "saving") {
+ return {t("household.form.saving")}
;
+ }
+ if (state === "saved") {
+ return {t("household.form.saved")}
;
+ }
+ if (state === "error") {
+ return {error}
;
+ }
+ return null;
}
diff --git a/apps/web/src/pages/SignupPage.tsx b/apps/web/src/pages/SignupPage.tsx
index 662d839..56260ba 100644
--- a/apps/web/src/pages/SignupPage.tsx
+++ b/apps/web/src/pages/SignupPage.tsx
@@ -37,7 +37,7 @@ export function SignupPage() {
const [formError, setFormError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
- /** Validates, then submits the form; navigates home on success. */
+ /** Validates, then submits the form; on success, starts the household/regime/allergens onboarding wizard rather than going straight to the home. */
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
@@ -52,7 +52,7 @@ export function SignupPage() {
setIsSubmitting(true);
try {
await signup(result.data);
- navigate("/");
+ navigate("/onboarding/foyer");
} catch (err) {
// ApiError.code is looked up through ErrorMessageService so the
// label is centralized and localized — never display err.message
diff --git a/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx b/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
new file mode 100644
index 0000000..bb6a1d4
--- /dev/null
+++ b/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
@@ -0,0 +1,93 @@
+import type { AllergyView } from "@batch-cooking/shared";
+import { ErrorCode } from "@batch-cooking/shared";
+import { type FormEvent, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { ApiError, apiClient } from "../../api/client";
+import { AllergySelect } from "../../features/profile/AllergySelect";
+import { errorMessageService } from "../../services/error-message.service";
+import "./onboarding.scss";
+
+/**
+ * Last step of the post-signup onboarding wizard, routed at
+ * `/onboarding/allergenes`. Starts from an empty selection — a freshly
+ * signed-up profile has none yet, so there's no need for the extra
+ * `GET /profile/allergies` round trip a "resume where I left off" flow
+ * would require (the `/foyer` settings page, task 10, is the always-fetch
+ * source of truth for editing an existing selection later).
+ */
+export function OnboardingAllergensPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const [allergies, setAllergies] = useState([]);
+ const [allergyIds, setAllergyIds] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [formError, setFormError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ apiClient
+ .getAllergies()
+ .then((result) => {
+ if (!cancelled) setAllergies(result);
+ })
+ .finally(() => {
+ if (!cancelled) setIsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ /** Finishes the wizard — the profile journey is complete, back to the app itself. */
+ async function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ setFormError(null);
+ setIsSubmitting(true);
+ try {
+ await apiClient.updateAllergyIds(allergyIds);
+ navigate("/");
+ } catch (err) {
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setFormError(errorMessageService.getLabel(code));
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/onboarding/OnboardingDietPage.tsx b/apps/web/src/pages/onboarding/OnboardingDietPage.tsx
new file mode 100644
index 0000000..a29fef9
--- /dev/null
+++ b/apps/web/src/pages/onboarding/OnboardingDietPage.tsx
@@ -0,0 +1,80 @@
+import type { DietView } from "@batch-cooking/shared";
+import { ErrorCode } from "@batch-cooking/shared";
+import { type FormEvent, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { ApiError, apiClient } from "../../api/client";
+import { useAuth } from "../../features/auth/AuthContext";
+import { DietSelect } from "../../features/profile/DietSelect";
+import { errorMessageService } from "../../services/error-message.service";
+import "./onboarding.scss";
+
+/**
+ * Second step of the post-signup onboarding wizard, routed at
+ * `/onboarding/regime`. Initial selection comes from `useAuth()`'s
+ * already-loaded profile (`user.dietId`) — freshly signed-up, this is
+ * `null` — no extra fetch needed just to know the starting value, unlike
+ * the household step which has no such shortcut for the current name.
+ */
+export function OnboardingDietPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { user } = useAuth();
+
+ const [diets, setDiets] = useState([]);
+ const [dietId, setDietId] = useState(user?.dietId ?? null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [formError, setFormError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ apiClient
+ .getDiets()
+ .then((result) => {
+ if (!cancelled) setDiets(result);
+ })
+ .finally(() => {
+ if (!cancelled) setIsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ async function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ setFormError(null);
+ setIsSubmitting(true);
+ try {
+ await apiClient.updateDiet(dietId);
+ navigate("/onboarding/allergenes");
+ } catch (err) {
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setFormError(errorMessageService.getLabel(code));
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx b/apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx
new file mode 100644
index 0000000..eef0d85
--- /dev/null
+++ b/apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx
@@ -0,0 +1,88 @@
+import { ErrorCode, renameHouseSchema } from "@batch-cooking/shared";
+import { type FormEvent, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { ApiError, apiClient } from "../../api/client";
+import { HouseNameField } from "../../features/profile/HouseNameField";
+import { fieldErrorsFrom } from "../../lib/zod-errors";
+import { errorMessageService } from "../../services/error-message.service";
+import "./onboarding.scss";
+
+/**
+ * First step of the post-signup onboarding wizard, routed at
+ * `/onboarding/foyer` — behind {@link RequireAuth} (see `App.tsx`), reached
+ * right after `POST /auth/signup` creates the account and its household
+ * (auto-named, see `auth.service.ts`). Prefills the current (default) name
+ * so continuing without editing it is a valid, implicit "skip" — there's
+ * no separate skip button anywhere in this wizard, see `DietSelect`/
+ * `AllergySelect` for the same choice on the following steps.
+ */
+export function OnboardingHouseholdPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const [name, setName] = useState("");
+ const [isLoading, setIsLoading] = useState(true);
+ const [fieldErrors, setFieldErrors] = useState>({});
+ const [formError, setFormError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ apiClient
+ .getCurrentHouse()
+ .then((house) => {
+ if (!cancelled && house) setName(house.name);
+ })
+ .finally(() => {
+ if (!cancelled) setIsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ async function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ setFormError(null);
+
+ const result = renameHouseSchema.safeParse({ name });
+ if (!result.success) {
+ setFieldErrors(fieldErrorsFrom(result.error));
+ return;
+ }
+ setFieldErrors({});
+
+ setIsSubmitting(true);
+ try {
+ await apiClient.renameHouse(result.data.name);
+ navigate("/onboarding/regime");
+ } catch (err) {
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setFormError(errorMessageService.getLabel(code));
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/onboarding/onboarding.scss b/apps/web/src/pages/onboarding/onboarding.scss
new file mode 100644
index 0000000..379c8eb
--- /dev/null
+++ b/apps/web/src/pages/onboarding/onboarding.scss
@@ -0,0 +1,74 @@
+// =============================================================================
+// Styles shared by the three onboarding wizard pages (OnboardingHousehold,
+// OnboardingDiet, OnboardingAllergens) — the household/regime/allergens
+// steps of the profile journey, run once right after signup.
+//
+// Deliberately its own file rather than importing features/auth/
+// auth-form.scss: same visual language (centered card) but a different
+// feature area — matches the project's existing convention of colocating
+// styles per feature rather than sharing a "global card" partial (see
+// HomePage.scss, which makes the same call for its own button styling).
+// =============================================================================
+
+.onboarding-page {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-md);
+ background: var(--color-background);
+}
+
+.onboarding-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ width: 100%;
+ max-width: var(--max-width-form);
+ padding: var(--space-xl);
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+
+ h1 {
+ font-size: var(--font-size-lg);
+ text-align: center;
+ margin-bottom: var(--space-sm);
+ }
+
+ button {
+ margin-top: var(--space-md);
+ padding: 0.6rem;
+ font-family: var(--font-body);
+ font-size: var(--font-size-base);
+ font-weight: 600;
+ cursor: pointer;
+ border-radius: var(--radius-base);
+ border: none;
+ background: var(--color-primary);
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: var(--color-primary-hover);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+ }
+}
+
+.onboarding-step {
+ color: var(--color-text-muted);
+ font-size: var(--font-size-xs);
+ text-align: center;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin: 0 0 var(--space-sm);
+}
+
+.form-error {
+ color: var(--color-error);
+ font-size: var(--font-size-sm);
+}
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 f0dc809..8f20709 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -5,6 +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;
+}
diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts
new file mode 100644
index 0000000..55f0a07
--- /dev/null
+++ b/packages/shared/src/types/reference.ts
@@ -0,0 +1,33 @@
+/**
+ * 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;
+}
+
+/**
+ * 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. `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;
+}
diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md
index 3933206..74436e2 100644
--- a/specs/frontend-architecture.md
+++ b/specs/frontend-architecture.md
@@ -14,15 +14,18 @@ apps/web/src/
├── i18n/
│ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord
├── locales/
-│ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, household.*)
+│ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, onboarding.*, household.*)
├── services/
│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
├── features/
-│ └── auth/ # tout ce qui concerne l'authentification
-│ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout)
-│ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
-│ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
-│ └── auth-form.scss # styles partagés par LoginPage et SignupPage
+│ ├── auth/ # tout ce qui concerne l'authentification
+│ │ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout, refreshUser)
+│ │ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
+│ │ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
+│ │ └── auth-form.scss # styles partagés par LoginPage et SignupPage
+│ └── profile/ # champs du parcours foyer/régime/allergènes, voir plus bas
+│ ├── HouseNameField.tsx / DietSelect.tsx / AllergySelect.tsx
+│ └── profile-forms.scss # styles partagés par les trois
├── layouts/
│ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas
├── pages/
@@ -30,7 +33,11 @@ apps/web/src/
│ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé)
│ ├── HomePage.tsx + HomePage.scss # planning de la semaine (routée sur "/")
│ ├── ComingSoonPage.tsx + .scss # placeholder partagé par les sections sans backend encore
-│ ├── RecipesPage.tsx / ShoppingListPage.tsx / HouseholdPage.tsx # fines enveloppes autour de ComingSoonPage
+│ ├── RecipesPage.tsx / ShoppingListPage.tsx # fines enveloppes autour de ComingSoonPage
+│ ├── HouseholdPage.tsx + .scss # foyer/régime/allergènes, éditable à tout moment (routée sur "/foyer")
+│ └── onboarding/ # wizard d'inscription (foyer → régime → allergènes), voir plus bas
+│ ├── OnboardingHouseholdPage.tsx / OnboardingDietPage.tsx / OnboardingAllergensPage.tsx
+│ └── onboarding.scss # styles partagés par les trois
├── styles/
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx)
@@ -82,6 +89,11 @@ flowchart TB
(redirige un utilisateur déjà connecté vers `/`). Les deux affichent `null` tant
que la vérification initiale est en cours, pour éviter un flash de contenu suivi
d'une redirection.
+- **`RedirectIfAuthenticated` verrouille sa décision une seule fois** (au moment où
+ `isLoading` passe à `false`), au lieu de réagir à chaque changement de `user` —
+ bug trouvé en construisant le wizard d'inscription (voir plus bas) : un
+ `navigate()` explicite dans le formulaire qu'elle protège entrait en course avec
+ son propre ``, invisible tant que les deux ciblaient "/".
---
@@ -110,12 +122,71 @@ README racine).
### Sections sans backend — `ComingSoonPage`
-`Recettes`, `Liste de courses` et `Foyer & profil` n'ont pas encore de backend
-dédié (seul `/planning/current` existe, voir le README). Chacune a néanmoins sa
-propre route/page (`RecipesPage.tsx`, etc. — choix délibéré pour que construire la
-vraie fonctionnalité plus tard soit réécrire un fichier dédié, pas éclater une
-route générique), mais toutes rendent le même composant `ComingSoonPage`
-(`title`/`description`) pour éviter de tripler un même bloc de markup.
+`Recettes` et `Liste de courses` n'ont pas encore de backend dédié (seuls
+`/planning/current` et le parcours foyer/profil ci-dessous existent, voir le
+README). Chacune a néanmoins sa propre route/page (`RecipesPage.tsx`, etc. — choix
+délibéré pour que construire la vraie fonctionnalité plus tard soit réécrire un
+fichier dédié, pas éclater une route générique), mais toutes rendent le même
+composant `ComingSoonPage` (`title`/`description`) pour éviter de tripler un même
+bloc de markup.
+
+---
+
+## Parcours profil — foyer, régime, allergènes
+
+Deux surfaces, mêmes composants de champ (`features/profile/`) :
+
+```mermaid
+flowchart LR
+ SIGNUP["SignupPage (POST /auth/signup)"] --> OB1["/onboarding/foyer"]
+ OB1 --> OB2["/onboarding/regime"]
+ OB2 --> OB3["/onboarding/allergenes"]
+ OB3 --> HOME["/ (home)"]
+
+ SIDEBAR["Sidebar : Foyer & profil"] --> SETTINGS["/foyer (HouseholdPage)"]
+```
+
+- **`pages/onboarding/`** — wizard de 3 écrans, lancé une seule fois juste après
+ l'inscription. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
+ wizard plein écran sans sidebar (`onboarding.scss`, même langage visuel que
+ `/login`/`/signup`, délibérément un fichier à part plutôt qu'un import de
+ `auth-form.scss` — même choix que `HomePage.scss` avant elle, voir plus haut).
+ Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante — pas de
+ bouton "Passer" séparé, une valeur "aucune"/vide *est* le skip.
+- **`pages/HouseholdPage.tsx`** (routée `/foyer`, dans `AppLayout`) — les mêmes
+ réglages, éditables à tout moment, en **hot saving** (retour fonctionnel : pas de
+ bouton "Enregistrer"). Chaque section sauvegarde peu après la dernière
+ modification (nom du foyer et allergènes/intolérances debouncés — 600ms/500ms —
+ régime immédiat) — 3 ressources API indépendantes (`PATCH /house/current`,
+ `/profile/diet`, `/profile/allergies`), 3 cycles de sauvegarde indépendants.
+ Déclenché depuis le handler `onChange` de chaque champ, jamais un `useEffect`
+ générique sur la valeur — un tel effect se déclencherait aussi au chargement
+ initial (le `GET` peuple le même state), sans distinction propre entre "vient
+ d'être chargé" et "vient d'être modifié".
+- **`features/profile/`** — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
+ contrôlés, "dumb" (reçoivent `diets`/`allergies` en props plutôt que de les
+ fetcher). `AllergySelect` est un ``/`` + grille de cases à
+ cocher, pas un `` — bien plus repérable/tapable, notamment sur
+ mobile (voir la note Capacitor plus haut). Prend un `legend` en prop : chaque
+ page consommatrice le rend **deux fois** (allergies / intolérances, filtrées
+ côté client via `AllergyView.kind`), mais la sélection reste une seule liste
+ d'IDs partagée entre les deux groupes.
+
+### Deux bugs de state trouvés en testant dans le navigateur
+
+1. **Course entre `navigate()` et `RedirectIfAuthenticated`** — voir la note sur
+ `RedirectIfAuthenticated` plus haut. `SignupPage` doit maintenant rediriger vers
+ `/onboarding/foyer`, pas `/`, ce qui a rendu visible une course de state
+ auparavant invisible.
+2. **`user.dietId` périmé sur `/foyer`** — `HouseholdPage` initialisait le régime
+ affiché depuis `useAuth().user.dietId`, un instantané d'`AuthContext` jamais
+ rafraîchi après une modification faite directement via `apiClient` (qui ne
+ touche pas le contexte). Une navigation SPA aller-retour sans rechargement
+ complet ré-affichait donc l'ancienne valeur après une sauvegarde. Fix :
+ `HouseholdPage` fetch son propre profil frais (`apiClient.me()`) au montage
+ plutôt que de dépendre du contexte, et `AuthContext.refreshUser()` (nouvelle
+ méthode, re-fetch `GET /auth/me`) est appelée après une sauvegarde réussie du
+ régime — pour que le reste de l'app (pas seulement cette page) reste cohérent.
---
@@ -146,7 +217,9 @@ JSON, jamais codé en dur dans un composant.
namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
`auth.login.*` / `auth.signup.*`, `layout.*` (nav de la sidebar, salutation,
déconnexion — `AppLayout`), `home.*` (planning), `recipes.*` / `shoppingList.*`
- / `household.*` (copie des pages stub, voir `ComingSoonPage` plus haut).
+ (copie des pages stub, voir `ComingSoonPage` plus haut), `onboarding.*` (wizard
+ d'inscription) et `household.*` (titre + `form.*`, champs partagés par le wizard
+ et `/foyer`).
- Dans un composant : `const { t } = useTranslation(); t("auth.login.title")`.
- Ajouter une langue : créer `locales//translation.json` avec les mêmes clés,
ajouter `resources.` dans `i18n/i18n.ts` — aucun composant à toucher.