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 : a mis au jour une contrainte générique trop stricte, corrigée à la source :
[specs/backend-architecture.md](specs/backend-architecture.md). [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) ## Page de connexion / inscription (apps/web)
- `src/api/client.ts``ApiClient` (classe, instance unique exportée `apiClient`) : - `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 `<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` 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`, (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 voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes` et `Liste de
courses` et `Foyer & profil` n'ont pas encore de backend dédié et rendent pour courses` n'ont pas encore de backend dédié et rendent pour l'instant le même
l'instant le même composant `ComingSoonPage`. Détail complet (pourquoi une seule composant `ComingSoonPage``Foyer & profil` (`src/pages/HouseholdPage.tsx`), lui,
route parente, pourquoi un composant stub partagé) : 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é). [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` + 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 `home-planning.cy.ts` + `onboarding.cy.ts` + `household.cy.ts` mockent l'API via
vrai backend — le job e2e de la CI ne provisionne pas de Postgres/API, seulement le `cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
serveur de dev Vite. Le comportement réel de l'API est couvert par les suites provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
Mocha/Cucumber d'`apps/api` (contre une vraie base). 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é** : > **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
> Chromium/Electron headless plante au lancement du process GPU > 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); 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) { Then("the response status should be {int}", function (this: CustomWorld, status: number) {
assert.equal(this.response.status, status); 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", "test:bdd": "cross-env NODE_ENV=test NODE_OPTIONS=--import=tsx cucumber-js",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:seed": "prisma db seed",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": { "dependencies": {
"@batch-cooking/error-tools": "workspace:*", "@batch-cooking/error-tools": "workspace:*",
"@batch-cooking/express-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") @@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 { model Diet {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String @unique
users UserProfile[] users UserProfile[]
@@map("diet") @@map("diet")
} }
/// Not in the original spec doc — a category is either a true (IgE-mediated)
/// allergy or a non-immune intolerance; the UI groups selectable allergens
/// into two separate lists (`AllergySelect`, apps/web) instead of one flat
/// "allergies & intolérances" list.
enum AllergenKind {
ALLERGY
INTOLERANCE
}
/// Enumeration-style table, meant to grow over time (e.g. allergy nuances). /// Enumeration-style table, meant to grow over time (e.g. allergy nuances).
/// `name` is `@unique` for the same reason as `Diet.name` above. `kind` is
/// also not in the original spec doc — see {@link AllergenKind}.
model Category { model Category {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String @unique
kind AllergenKind @default(ALLERGY)
allergies 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 type { Express, Request, Response } from "express";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { authRouter } from "./modules/auth/auth.routes.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 { 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 * Builds the API's `ExpressServer`: standard middleware, routes, and the
@ -23,7 +26,10 @@ export function createServer(): ExpressServer {
}); });
server.mountRouter("/auth", authRouter); server.mountRouter("/auth", authRouter);
server.mountRouter("/house", houseRouter);
server.mountRouter("/planning", planningRouter); server.mountRouter("/planning", planningRouter);
server.mountRouter("/profile", profileRouter);
server.mountRouter("/reference", referenceRouter);
// No route matched — same shape as every other error response, via the // No route matched — same shape as every other error response, via the
// shared ErrorCode contract, so clients never special-case 404s. // 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 { env } from "../config/env.js";
import { prisma } from "../db/prisma.js"; import { prisma } from "../db/prisma.js";
import { verifyAuthToken } from "../lib/jwt.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 * 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"); throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
} }
const { passwordHash: _passwordHash, ...safeProfile } = profile; res.locals.userProfile = toSafeProfile(profile);
res.locals.userProfile = safeProfile;
next(); next();
} catch (err) { } catch (err) {
if (err instanceof HttpError) { if (err instanceof HttpError) {

View file

@ -1,18 +1,20 @@
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type LoginInput, type SignupInput } from "@batch-cooking/shared"; import {
import type { UserProfile } from "@prisma/client"; ErrorCode,
type LoginInput,
type SafeUserProfile,
type SignupInput,
} from "@batch-cooking/shared";
import argon2 from "argon2"; import argon2 from "argon2";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { signAuthToken } from "../../lib/jwt.js"; import { signAuthToken } from "../../lib/jwt.js";
import { toSafeProfile } from "../../lib/safe-profile.js";
/** A UserProfile as it's safe to hand back to a client — never the password hash. */
type SafeProfile = Omit<UserProfile, "passwordHash">;
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */ /** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
interface AuthResult { interface AuthResult {
/** The authenticated profile, safe to hand back to the client. */ /** 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. */ /** Signed session JWT — the caller sets this as the session cookie's value. */
token: string; token: string;
} }
@ -25,12 +27,6 @@ interface AuthResult {
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 }; const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined; 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 * Creates a new household (`house`) and profile (`user_profiles`) together
* in one transaction, hashes the password, and issues a session token. * 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); const passwordHash = await argon2.hash(input.password, hashOptions);
// A profile always belongs to a house; signup creates one (named after // A profile always belongs to a house; signup creates one, named after
// the new user for now — renaming/joining an existing house is a // the new user for now — renamed via `PATCH /house/current` (the
// separate, not-yet-built feature). // 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 profile = await prisma.$transaction(async (tx) => {
const house = await tx.house.create({ const house = await tx.house.create({
data: { name: `Foyer de ${input.firstName}` }, 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 { prisma } from "../src/db/prisma.js";
import { seedReferenceData } from "../src/db/reference-seed-data.js";
// Single TRUNCATE ... CASCADE covers FK ordering and resets identity // Single TRUNCATE ... CASCADE covers FK ordering and resets identity
// sequences — used between tests/scenarios to start from a clean slate. // 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() { export async function resetDatabase() {
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
TRUNCATE TABLE TRUNCATE TABLE
@ -12,4 +17,5 @@ export async function resetDatabase() {
"user_profiles", "diet", "house" "user_profiles", "diet", "house"
RESTART IDENTITY CASCADE; 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. // suites against a real database.
describe("Signup", () => { 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", "**/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", { cy.intercept("POST", "**/auth/signup", {
statusCode: 201, statusCode: 201,
body: { body: {
@ -30,8 +35,11 @@ describe("Signup", () => {
cy.contains("button", "Créer mon profil").click(); cy.contains("button", "Créer mon profil").click();
cy.wait("@signup"); cy.wait("@signup");
cy.url().should("not.include", "/signup"); // Not the home page directly — signup hands off to the onboarding
cy.contains("Bonjour Alice").should("be.visible"); // 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", () => { 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 { RecipesPage } from "./pages/RecipesPage";
import { ShoppingListPage } from "./pages/ShoppingListPage"; import { ShoppingListPage } from "./pages/ShoppingListPage";
import { SignupPage } from "./pages/SignupPage"; 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 * 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 * `/signup` redirect an already-logged-in visitor to `/` instead (see
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which * {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
* itself redirects to `/login` if needed. * 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() { export function App() {
return ( return (
@ -32,6 +41,30 @@ export function App() {
<Route path="/liste-de-courses" element={<ShoppingListPage />} /> <Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/foyer" element={<HouseholdPage />} /> <Route path="/foyer" element={<HouseholdPage />} />
</Route> </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 <Route
path="/login" path="/login"
element={ element={

View file

@ -1,6 +1,9 @@
import { import {
type AllergyView,
type ApiErrorResponse, type ApiErrorResponse,
type DietView,
ErrorCode, ErrorCode,
type HouseView,
type LoginInput, type LoginInput,
type PlanningView, type PlanningView,
type SafeUserProfile, type SafeUserProfile,
@ -99,6 +102,44 @@ export class ApiClient {
public getCurrentPlanning(): Promise<PlanningView | null> { public getCurrentPlanning(): Promise<PlanningView | null> {
return this.request("/planning/current"); 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. */ /** 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>; login: (input: LoginInput) => Promise<void>;
/** Ends the session and clears `user`. */ /** Ends the session and clears `user`. */
logout: () => Promise<void>; 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. */ /** React context carrying {@link AuthContextValue} — always accessed through {@link useAuth}, never directly. */
@ -51,8 +59,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null); setUser(null);
}, []); }, []);
const refreshUser = useCallback(async () => {
setUser(await apiClient.me());
}, []);
return ( return (
<AuthContext.Provider value={{ user, isLoading, signup, login, logout }}> <AuthContext.Provider value={{ user, isLoading, signup, login, logout, refreshUser }}>
{children} {children}
</AuthContext.Provider> </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 { Navigate } from "react-router-dom";
import { useAuth } from "./AuthContext"; import { useAuth } from "./AuthContext";
@ -6,14 +6,32 @@ import { useAuth } from "./AuthContext";
* Route guard for pages that make no sense to an already-authenticated * Route guard for pages that make no sense to an already-authenticated
* visitor (`/login`, `/signup`). Mirrors {@link RequireAuth}'s waiting * visitor (`/login`, `/signup`). Mirrors {@link RequireAuth}'s waiting
* behavior while the initial session check is pending. * 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 }) { export function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth(); 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; return null;
} }
if (user) { if (shouldRedirect.current) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
return <>{children}</>; 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", "INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
"NOT_AUTHENTICATED": "Vous devez être connecté", "NOT_AUTHENTICATED": "Vous devez être connecté",
"NOT_FOUND": "Ressource introuvable", "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" "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
}, },
"auth": { "auth": {
@ -29,6 +32,21 @@
"loginLink": "Se connecter" "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": { "layout": {
"nav": { "nav": {
"planning": "Planning", "planning": "Planning",
@ -60,6 +78,14 @@
}, },
"household": { "household": {
"title": "Foyer & profil", "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 { 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() { export function HouseholdPage() {
const { t } = useTranslation(); 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 [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); 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) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
@ -52,7 +52,7 @@ export function SignupPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await signup(result.data); await signup(result.data);
navigate("/"); navigate("/onboarding/foyer");
} catch (err) { } catch (err) {
// ApiError.code is looked up through ErrorMessageService so the // ApiError.code is looked up through ErrorMessageService so the
// label is centralized and localized — never display err.message // 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, NOT_AUTHENTICATED = 4011,
/** No route/resource matches the request. */ /** No route/resource matches the request. */
NOT_FOUND = 4040, 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. */ /** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = 5000, INTERNAL_ERROR = 5000,
} }

View file

@ -5,6 +5,10 @@
export * from "./errors/error-codes.js"; export * from "./errors/error-codes.js";
export * from "./schemas/auth.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 "./tools/assert-is-never.js";
export * from "./types/household.js";
export * from "./types/planning.js"; export * from "./types/planning.js";
export * from "./types/reference.js";
export * from "./types/user-profile.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/
│ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord │ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord
├── locales/ ├── 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/ ├── services/
│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next │ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
├── features/ ├── features/
│ └── auth/ # tout ce qui concerne l'authentification │ ├── auth/ # tout ce qui concerne l'authentification
│ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout) │ │ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout, refreshUser)
│ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté │ │ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
│ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup) │ │ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
│ └── auth-form.scss # styles partagés par LoginPage et SignupPage │ │ └── 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/ ├── layouts/
│ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas │ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas
├── pages/ ├── pages/
@ -30,7 +33,11 @@ apps/web/src/
│ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé) │ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé)
│ ├── HomePage.tsx + HomePage.scss # planning de la semaine (routée sur "/") │ ├── HomePage.tsx + HomePage.scss # planning de la semaine (routée sur "/")
│ ├── ComingSoonPage.tsx + .scss # placeholder partagé par les sections sans backend encore │ ├── 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/ ├── styles/
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie) │ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx) │ └── 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 (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 que la vérification initiale est en cours, pour éviter un flash de contenu suivi
d'une redirection. 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` ### Sections sans backend — `ComingSoonPage`
`Recettes`, `Liste de courses` et `Foyer & profil` n'ont pas encore de backend `Recettes` et `Liste de courses` n'ont pas encore de backend dédié (seuls
dédié (seul `/planning/current` existe, voir le README). Chacune a néanmoins sa `/planning/current` et le parcours foyer/profil ci-dessous existent, voir le
propre route/page (`RecipesPage.tsx`, etc. — choix délibéré pour que construire la README). Chacune a néanmoins sa propre route/page (`RecipesPage.tsx`, etc. — choix
vraie fonctionnalité plus tard soit réécrire un fichier dédié, pas éclater une délibéré pour que construire la vraie fonctionnalité plus tard soit réécrire un
route générique), mais toutes rendent le même composant `ComingSoonPage` fichier dédié, pas éclater une route générique), mais toutes rendent le même
(`title`/`description`) pour éviter de tripler un même bloc de markup. 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)), namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
`auth.login.*` / `auth.signup.*`, `layout.*` (nav de la sidebar, salutation, `auth.login.*` / `auth.signup.*`, `layout.*` (nav de la sidebar, salutation,
déconnexion — `AppLayout`), `home.*` (planning), `recipes.*` / `shoppingList.*` 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")`. - 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 une langue : créer `locales/<lng>/translation.json` avec les mêmes clés,
ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher. ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.