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:
commit
97ca5f88dd
54 changed files with 2255 additions and 56 deletions
123
README.md
123
README.md
|
|
@ -186,6 +186,63 @@ premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, c
|
||||||
a mis au jour une contrainte générique trop stricte, corrigée à la source :
|
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
|
||||||
|
|
|
||||||
16
apps/api/features/household.feature
Normal file
16
apps/api/features/household.feature
Normal 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"
|
||||||
28
apps/api/features/profile.feature
Normal file
28
apps/api/features/profile.feature
Normal 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"
|
||||||
14
apps/api/features/reference.feature
Normal file
14
apps/api/features/reference.feature
Normal 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"
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
12
apps/api/features/step-definitions/household.steps.ts
Normal file
12
apps/api/features/step-definitions/household.steps.ts
Normal 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);
|
||||||
|
});
|
||||||
46
apps/api/features/step-definitions/profile.steps.ts
Normal file
46
apps/api/features/step-definitions/profile.steps.ts
Normal 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);
|
||||||
|
});
|
||||||
11
apps/api/features/step-definitions/reference.steps.ts
Normal file
11
apps/api/features/step-definitions/reference.steps.ts
Normal 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}"`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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:*",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "category_name_key" ON "category"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "diet_name_key" ON "diet"("name");
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AllergenKind" AS ENUM ('ALLERGY', 'INTOLERANCE');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "category" ADD COLUMN "kind" "AllergenKind" NOT NULL DEFAULT 'ALLERGY';
|
||||||
|
|
@ -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
18
apps/api/prisma/seed.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
61
apps/api/src/db/reference-seed-data.ts
Normal file
61
apps/api/src/db/reference-seed-data.ts
Normal 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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/lib/safe-profile.ts
Normal file
14
apps/api/src/lib/safe-profile.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}` },
|
||||||
|
|
|
||||||
28
apps/api/src/modules/house/house.routes.ts
Normal file
28
apps/api/src/modules/house/house.routes.ts
Normal 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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
40
apps/api/src/modules/house/house.service.ts
Normal file
40
apps/api/src/modules/house/house.service.ts
Normal 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;
|
||||||
|
}
|
||||||
39
apps/api/src/modules/profile/profile.routes.ts
Normal file
39
apps/api/src/modules/profile/profile.routes.ts
Normal 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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
76
apps/api/src/modules/profile/profile.service.ts
Normal file
76
apps/api/src/modules/profile/profile.service.ts
Normal 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;
|
||||||
|
}
|
||||||
26
apps/api/src/modules/reference/reference.routes.ts
Normal file
26
apps/api/src/modules/reference/reference.routes.ts
Normal 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());
|
||||||
|
}),
|
||||||
|
);
|
||||||
25
apps/api/src/modules/reference/reference.service.ts
Normal file
25
apps/api/src/modules/reference/reference.service.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
apps/api/test/house.test.ts
Normal file
81
apps/api/test/house.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
128
apps/api/test/profile.test.ts
Normal file
128
apps/api/test/profile.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
49
apps/api/test/reference.test.ts
Normal file
49
apps/api/test/reference.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
101
apps/web/cypress/e2e/household.cy.ts
Normal file
101
apps/web/cypress/e2e/household.cy.ts
Normal 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] });
|
||||||
|
});
|
||||||
|
});
|
||||||
131
apps/web/cypress/e2e/onboarding.cy.ts
Normal file
131
apps/web/cypress/e2e/onboarding.cy.ts
Normal 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}/`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}</>;
|
||||||
|
|
|
||||||
50
apps/web/src/features/profile/AllergySelect.tsx
Normal file
50
apps/web/src/features/profile/AllergySelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/web/src/features/profile/DietSelect.tsx
Normal file
42
apps/web/src/features/profile/DietSelect.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/web/src/features/profile/HouseNameField.tsx
Normal file
32
apps/web/src/features/profile/HouseNameField.tsx
Normal 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>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/web/src/features/profile/profile-forms.scss
Normal file
74
apps/web/src/features/profile/profile-forms.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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é ✓"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
apps/web/src/pages/HouseholdPage.scss
Normal file
46
apps/web/src/pages/HouseholdPage.scss
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
93
apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
Normal file
93
apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
apps/web/src/pages/onboarding/OnboardingDietPage.tsx
Normal file
80
apps/web/src/pages/onboarding/OnboardingDietPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx
Normal file
88
apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/web/src/pages/onboarding/onboarding.scss
Normal file
74
apps/web/src/pages/onboarding/onboarding.scss
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
12
packages/shared/src/schemas/household.ts
Normal file
12
packages/shared/src/schemas/household.ts
Normal 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>;
|
||||||
17
packages/shared/src/schemas/profile.ts
Normal file
17
packages/shared/src/schemas/profile.ts
Normal 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>;
|
||||||
9
packages/shared/src/types/household.ts
Normal file
9
packages/shared/src/types/household.ts
Normal 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;
|
||||||
|
}
|
||||||
33
packages/shared/src/types/reference.ts
Normal file
33
packages/shared/src/types/reference.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue