Merge pull request #10 from kyuno053/feat/profile-household-diet-allergens

Profil : création de foyer, régime alimentaire, allergies & intolérances
This commit is contained in:
kyuno053 2026-08-17 07:55:40 +02:00 committed by GitHub
commit 97ca5f88dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 2255 additions and 56 deletions

123
README.md
View file

@ -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
`<Outlet />` 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 `<fieldset>`/`<legend>` plutôt qu'un
`<select multiple>` — 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 `<Navigate>` 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

View file

@ -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"

View file

@ -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"

View file

@ -0,0 +1,14 @@
Feature: Reference data (diets, allergens)
As a visitor filling in the signup wizard, or a signed-in user editing their profile
I want to read the list of dietary regimes and allergens
So that I can pick from them before an account necessarily exists
Scenario: A visitor without a session can read the list of dietary regimes
When I send a GET request to "/reference/diets"
Then the response status should be 200
And the reference list response should include "Végétarien"
Scenario: A visitor without a session can read the list of allergens
When I send a GET request to "/reference/allergies"
Then the response status should be 200
And the reference list response should include "Arachides"

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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<number[]> {
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);
});

View file

@ -0,0 +1,11 @@
import assert from "node:assert/strict";
import { Then } from "@cucumber/cucumber";
import type { CustomWorld } from "../support/world.js";
Then(
"the reference list response should include {string}",
function (this: CustomWorld, name: string) {
const names = (this.response.body as Array<{ name: string }>).map((item) => item.name);
assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`);
},
);

View file

@ -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:*",

View file

@ -0,0 +1,5 @@
-- CreateIndex
CREATE UNIQUE INDEX "category_name_key" ON "category"("name");
-- CreateIndex
CREATE UNIQUE INDEX "diet_name_key" ON "diet"("name");

View file

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

View file

@ -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[]

18
apps/api/prisma/seed.ts Normal file
View file

@ -0,0 +1,18 @@
import { PrismaClient } from "@prisma/client";
import { seedReferenceData } from "../src/db/reference-seed-data.js";
// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in
// the rest of the app's config/env plumbing this doesn't need), run via
// `prisma db seed` (see the `prisma.seed` entry in package.json) — either
// directly (`pnpm --filter api prisma:seed`) or automatically after
// `prisma migrate reset`. The actual data/logic lives in
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
const prisma = new PrismaClient();
seedReferenceData(prisma)
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -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.

View file

@ -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<void> {
for (const name of DIETS) {
await prisma.diet.upsert({ where: { name }, update: {}, create: { name } });
}
// `Allergy` itself carries no `name` — it's the selectable instance of a
// named `Category` (see schema.prisma) — so seeding an allergen means one
// Category (upserted by name) plus exactly one Allergy row under it,
// created only the first time. `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 } });
}
}
}

View file

@ -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;
}

View file

@ -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) {

View file

@ -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<UserProfile, "passwordHash">;
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<AuthResult> {
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}` },

View file

@ -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<unknown, AuthLocals>(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<unknown, AuthLocals>(async (req, res) => {
const input = renameHouseSchema.parse(req.body);
const house = await renameHouse(res.locals.userProfile.houseId, input.name);
res.status(200).json(house);
}),
);

View file

@ -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<HouseView | null> {
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<HouseView> {
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<HouseView> {
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;
}

View file

@ -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<unknown, AuthLocals>(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<unknown, AuthLocals>(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<unknown, AuthLocals>(async (req, res) => {
const input = updateAllergiesSchema.parse(req.body);
const allergyIds = await updateAllergies(res.locals.userProfile.id, input.allergyIds);
res.status(200).json(allergyIds);
}),
);

View file

@ -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<SafeUserProfile> {
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<number[]> {
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<number[]> {
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;
}

View file

@ -0,0 +1,26 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { Router } from "express";
import { getAllergies, getDiets } from "./reference.service.js";
/**
* Router mounted at `/reference` in app.ts. Both routes are deliberately
* public (no `requireAuth`) this is static reference data, not
* per-household state, and the signup wizard (household/regime/allergen
* steps) needs to read it before an account and therefore a session
* exists.
*/
export const referenceRouter = Router();
referenceRouter.get(
"/diets",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getDiets());
}),
);
referenceRouter.get(
"/allergies",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getAllergies());
}),
);

View file

@ -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<DietView[]> {
return prisma.diet.findMany({ orderBy: { name: "asc" } });
}
/**
* All reference allergens, alphabetically. `Allergy` carries no `name` of
* its own it's the selectable instance of a named `Category` (see
* schema.prisma) so this resolves each allergen's display name from its
* category and flattens the split away for callers.
*/
export async function getAllergies(): Promise<AllergyView[]> {
const allergies = await prisma.allergy.findMany({
include: { category: { select: { name: true, kind: true } } },
orderBy: { category: { name: "asc" } },
});
return allergies.map((allergy) => ({
id: allergy.id,
name: allergy.category.name,
kind: allergy.category.kind,
}));
}

View file

@ -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);
}

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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", () => {

View file

@ -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] });
});
});

View file

@ -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}/`);
});
});

View file

@ -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() {
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/foyer" element={<HouseholdPage />} />
</Route>
<Route
path="/onboarding/foyer"
element={
<RequireAuth>
<OnboardingHouseholdPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/regime"
element={
<RequireAuth>
<OnboardingDietPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route
path="/login"
element={

View file

@ -1,6 +1,9 @@
import {
type AllergyView,
type ApiErrorResponse,
type DietView,
ErrorCode,
type HouseView,
type LoginInput,
type PlanningView,
type SafeUserProfile,
@ -99,6 +102,44 @@ export class ApiClient {
public getCurrentPlanning(): Promise<PlanningView | null> {
return this.request("/planning/current");
}
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
public getDiets(): Promise<DietView[]> {
return this.request("/reference/diets");
}
/** Reference list of selectable allergens (signup wizard, `/foyer`). Public — no session required. */
public getAllergies(): Promise<AllergyView[]> {
return this.request("/reference/allergies");
}
/** Fetches the current user's household. */
public getCurrentHouse(): Promise<HouseView | null> {
return this.request("/house/current");
}
/** Renames the current user's household. */
public renameHouse(name: string): Promise<HouseView> {
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<SafeUserProfile> {
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
}
/** Fetches the current user's selected allergen ids. */
public getAllergyIds(): Promise<number[]> {
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<number[]> {
return this.request("/profile/allergies", {
method: "PATCH",
body: JSON.stringify({ allergyIds }),
});
}
}
/** Single shared instance — this client is stateless, no need for one per caller. */

View file

@ -14,6 +14,14 @@ interface AuthContextValue {
login: (input: LoginInput) => Promise<void>;
/** Ends the session and clears `user`. */
logout: () => Promise<void>;
/**
* 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<void>;
}
/** 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 (
<AuthContext.Provider value={{ user, isLoading, signup, login, logout }}>
<AuthContext.Provider value={{ user, isLoading, signup, login, logout, refreshUser }}>
{children}
</AuthContext.Provider>
);

View file

@ -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 `<Navigate to="/">`, 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<boolean | null>(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 <Navigate to="/" replace />;
}
return <>{children}</>;

View file

@ -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 `<select multiple>` 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 `<fieldset>`/`<legend>` (not a bare
* `<label>`, 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 (
<fieldset className="allergy-select">
<legend>{legend}</legend>
{allergies.map((allergy) => (
<label key={allergy.id} className="allergy-select__option">
<input
type="checkbox"
checked={value.includes(allergy.id)}
onChange={() => toggle(allergy.id)}
/>
{allergy.name}
</label>
))}
</fieldset>
);
}

View file

@ -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 (
<>
<label htmlFor="diet">{t("household.form.dietLabel")}</label>
<select
id="diet"
value={value ?? ""}
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
>
<option value="">{t("household.form.dietNone")}</option>
{diets.map((diet) => (
<option key={diet.id} value={diet.id}>
{diet.name}
</option>
))}
</select>
</>
);
}

View file

@ -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 (
<>
<label htmlFor="houseName">{t("household.form.nameLabel")}</label>
<input
id="houseName"
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
/>
{error && <p className="field-error">{error}</p>}
</>
);
}

View file

@ -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;
}
}
}

View file

@ -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é ✓"
}
}
}

View file

@ -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);
}

View file

@ -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 <ComingSoonPage title={t("household.title")} description={t("household.comingSoon")} />;
const { refreshUser } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [houseName, setHouseName] = useState("");
const [houseNameErrors, setHouseNameErrors] = useState<Record<string, string>>({});
const [houseSaveState, setHouseSaveState] = useState<SaveState>("idle");
const [houseSaveError, setHouseSaveError] = useState<string | null>(null);
const houseNameTimeout = useRef<number | undefined>(undefined);
const [diets, setDiets] = useState<DietView[]>([]);
const [dietId, setDietId] = useState<number | null>(null);
const [dietSaveState, setDietSaveState] = useState<SaveState>("idle");
const [dietSaveError, setDietSaveError] = useState<string | null>(null);
const [allergies, setAllergies] = useState<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [allergySaveState, setAllergySaveState] = useState<SaveState>("idle");
const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
const allergiesTimeout = useRef<number | undefined>(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 (
<div className="household-page">
<h1>{t("household.title")}</h1>
<p className="household-page__status">{t("onboarding.loading")}</p>
</div>
);
}
if (loadError) {
return (
<div className="household-page">
<h1>{t("household.title")}</h1>
<p className="household-page__status household-page__status--error">{t("home.error")}</p>
</div>
);
}
return (
<div className="household-page">
<h1>{t("household.title")}</h1>
<div className="household-page__section">
<HouseNameField
value={houseName}
onChange={handleHouseNameChange}
error={houseNameErrors.name}
/>
<SaveStatus state={houseSaveState} error={houseSaveError} t={t} />
</div>
<div className="household-page__section">
<DietSelect diets={diets} value={dietId} onChange={handleDietChange} />
<SaveStatus state={dietSaveState} error={dietSaveError} t={t} />
</div>
<div className="household-page__section">
<AllergySelect
legend={t("household.form.allergiesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
value={allergyIds}
onChange={handleAllergyIdsChange}
/>
<AllergySelect
legend={t("household.form.intolerancesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
value={allergyIds}
onChange={handleAllergyIdsChange}
/>
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
</div>
</div>
);
}
/** 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 <p className="household-page__saving">{t("household.form.saving")}</p>;
}
if (state === "saved") {
return <p className="household-page__saved">{t("household.form.saved")}</p>;
}
if (state === "error") {
return <p className="field-error">{error}</p>;
}
return null;
}

View file

@ -37,7 +37,7 @@ export function SignupPage() {
const [formError, setFormError] = useState<string | null>(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

View file

@ -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<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(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 (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 3, total: 3 })}</p>
<h1>{t("onboarding.allergens.title")}</h1>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : (
<>
<AllergySelect
legend={t("household.form.allergiesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
value={allergyIds}
onChange={setAllergyIds}
/>
<AllergySelect
legend={t("household.form.intolerancesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
value={allergyIds}
onChange={setAllergyIds}
/>
</>
)}
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.finish")}
</button>
</form>
</main>
);
}

View file

@ -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<DietView[]>([]);
const [dietId, setDietId] = useState<number | null>(user?.dietId ?? null);
const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(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 (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
<h1>{t("onboarding.diet.title")}</h1>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : (
<DietSelect diets={diets} value={dietId} onChange={setDietId} />
)}
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.continue")}
</button>
</form>
</main>
);
}

View file

@ -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<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(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 (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
<h1>{t("onboarding.household.title")}</h1>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : (
<HouseNameField value={name} onChange={setName} error={fieldErrors.name} />
)}
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.continue")}
</button>
</form>
</main>
);
}

View file

@ -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);
}

View file

@ -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,
}

View file

@ -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";

View file

@ -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<typeof renameHouseSchema>;

View file

@ -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<typeof updateDietSchema>;
/** 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<typeof updateAllergiesSchema>;

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 `<Navigate>`, 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<br/>(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 `<fieldset>`/`<legend>` + grille de cases à
cocher, pas un `<select multiple>` — 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/<lng>/translation.json` avec les mêmes clés,
ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.