API: endpoints foyer/profil (nom, régime, allergènes) (step 2/6)

- GET/PATCH /house/current — renomme le foyer de l'utilisateur connecté.
  PATCH avec houseId null -> 404 HOUSE_NOT_FOUND.
- PATCH /profile/diet { dietId: number | null } — régime du profil ;
  null l'efface (étape skippable du parcours). dietId invalide ->
  404 DIET_NOT_FOUND.
- GET/PATCH /profile/allergies — allergènes/intolérances, liste d'IDs ;
  PATCH remplace l'ensemble complet (pas une fusion, cohérent avec un
  multi-select). ID invalide -> 404 ALLERGY_NOT_FOUND.
- 3 nouveaux ErrorCode (4041-4043) + libellés fr.
- Extraction de toSafeProfile() dans src/lib/safe-profile.ts —
  auparavant dupliqué dans auth.service.ts et require-auth.ts,
  profile.service.ts le réutilise aussi.
- Tests Mocha (28 passing) + Cucumber (15 scenarios) — même convention
  que le reste, doc README.

Deuxième commit de la feature profil/foyer/régime/allergènes —
composants front partagés dans le commit suivant.
This commit is contained in:
Nicolas 2026-08-16 23:18:46 +02:00
parent 9b7c955019
commit 1d03effc77
22 changed files with 605 additions and 17 deletions

View file

@ -211,6 +211,28 @@ spec d'origine) précisément pour permettre cet upsert idempotent par nom.
Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
standard, pas inventée.
## Foyer & profil — nom, régime, allergènes (apps/api)
Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de
référence ci-dessus, ce sont des données propres à l'utilisateur/au foyer.
- `GET`/`PATCH /house/current` — foyer de l'utilisateur connecté. `GET` renvoie
`null` si le profil n'a pas encore de foyer (cas théorique : le signup en crée
toujours un) ; `PATCH { name }` le renomme (`404 HOUSE_NOT_FOUND` si le profil
n'a pas de foyer).
- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
`null` efface le régime (étape "skippable" du parcours). `404 DIET_NOT_FOUND` si
`dietId` ne correspond à aucun régime de référence.
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances du profil connecté,
sous forme de liste d'IDs (`number[]`). `PATCH { allergyIds }` **remplace**
l'ensemble (pas une fusion — le client renvoie toujours la sélection complète,
cohérent avec un composant de multi-sélection). `404 ALLERGY_NOT_FOUND` si un ID
ne correspond à aucun allergène de référence.
`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
(`toSafeProfile`), auparavant dupliqué dans `auth.service.ts` et
`require-auth.ts``profile.service.ts` le réutilise aussi.
## Page de connexion / inscription (apps/web)
- `src/api/client.ts``ApiClient` (classe, instance unique exportée `apiClient`) :

View file

@ -0,0 +1,16 @@
Feature: Household name
As a signed-in user
I want to name my household
So that it's recognizable as ours, not the auto-generated default
Scenario: A visitor without a session cannot read the household
When I send a GET request to "/house/current"
Then the response status should be 401
And the response error code should be "NOT_AUTHENTICATED"
Scenario: A signed-in user renames their household
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I rename my household to "Chez les Martin"
Then the response status should be 200
And my household should be named "Chez les Martin"

View file

@ -0,0 +1,28 @@
Feature: Profile regime and allergens
As a signed-in user
I want to set my dietary regime and allergens/intolerances
So that the household's meal planning can account for them later
Scenario: A visitor without a session cannot set a regime
When I send a PATCH request to "/profile/diet" with body:
"""
{ "dietId": 1 }
"""
Then the response status should be 401
And the response error code should be "NOT_AUTHENTICATED"
Scenario: A signed-in user sets their regime to a valid, seeded diet
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I set my regime to "Végétarien"
Then the response status should be 200
And my profile's regime should be "Végétarien"
Scenario: A signed-in user selects allergens, then replaces the selection
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I set my allergens to "Arachides, Gluten"
Then the response status should be 200
And my selected allergens should be "Arachides, Gluten"
When I set my allergens to "Lait"
Then my selected allergens should be "Lait"

View file

@ -8,6 +8,13 @@ When("I send a GET request to {string}", async function (this: CustomWorld, path
this.response = await request(this.app).get(path);
});
When(
"I send a PATCH request to {string} with body:",
async function (this: CustomWorld, path: string, body: string) {
this.response = await request(this.app).patch(path).send(JSON.parse(body));
},
);
Then("the response status should be {int}", function (this: CustomWorld, status: number) {
assert.equal(this.response.status, status);
});

View file

@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { Then, When } from "@cucumber/cucumber";
import type { CustomWorld } from "../support/world.js";
When("I rename my household to {string}", async function (this: CustomWorld, name: string) {
this.response = await this.agent.patch("/house/current").send({ name });
});
Then("my household should be named {string}", async function (this: CustomWorld, name: string) {
const res = await this.agent.get("/house/current");
assert.equal(res.body.name, name);
});

View file

@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { Then, When } from "@cucumber/cucumber";
import { prisma } from "../../src/db/prisma.js";
import type { CustomWorld } from "../support/world.js";
/** Splits a comma-separated list of names from a `.feature` string into trimmed, non-empty parts. */
function splitNames(names: string): string[] {
return names
.split(",")
.map((name) => name.trim())
.filter(Boolean);
}
/** Resolves allergen names (Category.name) to their Allergy id — see reference.service.ts for why the name lives on Category, not Allergy. */
async function allergyIdsFor(names: string[]): Promise<number[]> {
const allergies = await prisma.allergy.findMany({ include: { category: true } });
return names.map((name) => {
const match = allergies.find((allergy) => allergy.category.name === name);
if (!match) throw new Error(`No seeded allergen named "${name}"`);
return match.id;
});
}
When("I set my regime to {string}", async function (this: CustomWorld, dietName: string) {
const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } });
this.response = await this.agent.patch("/profile/diet").send({ dietId: diet.id });
});
Then(
"my profile's regime should be {string}",
async function (this: CustomWorld, dietName: string) {
const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } });
assert.equal(this.response.body.dietId, diet.id);
},
);
When("I set my allergens to {string}", async function (this: CustomWorld, names: string) {
const allergyIds = await allergyIdsFor(splitNames(names));
this.response = await this.agent.patch("/profile/allergies").send({ allergyIds });
});
Then("my selected allergens should be {string}", async function (this: CustomWorld, names: string) {
const expected = (await allergyIdsFor(splitNames(names))).sort();
const actual = [...this.response.body].sort();
assert.deepEqual(actual, expected);
});

View file

@ -4,7 +4,9 @@ import { ErrorCode } from "@batch-cooking/shared";
import type { Express, Request, Response } from "express";
import { env } from "./config/env.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { houseRouter } from "./modules/house/house.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js";
import { referenceRouter } from "./modules/reference/reference.routes.js";
/**
@ -24,7 +26,9 @@ export function createServer(): ExpressServer {
});
server.mountRouter("/auth", authRouter);
server.mountRouter("/house", houseRouter);
server.mountRouter("/planning", planningRouter);
server.mountRouter("/profile", profileRouter);
server.mountRouter("/reference", referenceRouter);
// No route matched — same shape as every other error response, via the

View file

@ -0,0 +1,14 @@
import type { SafeUserProfile } from "@batch-cooking/shared";
import type { UserProfile } from "@prisma/client";
/**
* Strips `passwordHash` off a Prisma `UserProfile` before it's ever sent to
* a client. Shared by every module that hands a profile back to the
* caller (`auth.service.ts`, `require-auth.ts`, `profile.service.ts`)
* previously duplicated inline in each, consolidated here so there's one
* place this security-relevant stripping happens.
*/
export function toSafeProfile(profile: UserProfile): SafeUserProfile {
const { passwordHash: _passwordHash, ...safeProfile } = profile;
return safeProfile;
}

View file

@ -4,6 +4,7 @@ import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { verifyAuthToken } from "../lib/jwt.js";
import { toSafeProfile } from "../lib/safe-profile.js";
/**
* Shape of `res.locals` once {@link requireAuth} has run successfully. Type
@ -54,8 +55,7 @@ export async function requireAuth(
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
const { passwordHash: _passwordHash, ...safeProfile } = profile;
res.locals.userProfile = safeProfile;
res.locals.userProfile = toSafeProfile(profile);
next();
} catch (err) {
if (err instanceof HttpError) {

View file

@ -1,18 +1,20 @@
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type LoginInput, type SignupInput } from "@batch-cooking/shared";
import type { UserProfile } from "@prisma/client";
import {
ErrorCode,
type LoginInput,
type SafeUserProfile,
type SignupInput,
} from "@batch-cooking/shared";
import argon2 from "argon2";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
import { signAuthToken } from "../../lib/jwt.js";
/** A UserProfile as it's safe to hand back to a client — never the password hash. */
type SafeProfile = Omit<UserProfile, "passwordHash">;
import { toSafeProfile } from "../../lib/safe-profile.js";
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
interface AuthResult {
/** The authenticated profile, safe to hand back to the client. */
profile: SafeProfile;
profile: SafeUserProfile;
/** Signed session JWT — the caller sets this as the session cookie's value. */
token: string;
}
@ -25,12 +27,6 @@ interface AuthResult {
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
/** Strips `passwordHash` off a Prisma UserProfile before it's ever sent to a client. */
function toSafeProfile(profile: UserProfile): SafeProfile {
const { passwordHash: _passwordHash, ...safeProfile } = profile;
return safeProfile;
}
/**
* Creates a new household (`house`) and profile (`user_profiles`) together
* in one transaction, hashes the password, and issues a session token.
@ -45,9 +41,10 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
const passwordHash = await argon2.hash(input.password, hashOptions);
// A profile always belongs to a house; signup creates one (named after
// the new user for now — renaming/joining an existing house is a
// separate, not-yet-built feature).
// A profile always belongs to a house; signup creates one, named after
// the new user for now — renamed via `PATCH /house/current` (the
// household step of the profile journey). Joining an existing house is a
// separate, not-yet-built feature.
const profile = await prisma.$transaction(async (tx) => {
const house = await tx.house.create({
data: { name: `Foyer de ${input.firstName}` },

View file

@ -0,0 +1,28 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { renameHouseSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getCurrentHouse, renameHouse } from "./house.service.js";
/** Router mounted at `/house` in app.ts. Both routes require a session — a household is per-user (via their profile), never public. */
export const houseRouter = Router();
houseRouter.get(
"/current",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
const house = await getCurrentHouse(res.locals.userProfile.houseId);
res.status(200).json(house);
}),
);
/** The household step of the profile journey (signup wizard and the `/foyer` settings page both call this). */
houseRouter.patch(
"/current",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = renameHouseSchema.parse(req.body);
const house = await renameHouse(res.locals.userProfile.houseId, input.name);
res.status(200).json(house);
}),
);

View file

@ -0,0 +1,40 @@
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type HouseView } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/** Returns the profile's household, or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
if (houseId === null) {
return null;
}
return findHouseOrThrow(houseId);
}
/**
* Renames the profile's household.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
*/
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
await findHouseOrThrow(houseId);
return prisma.house.update({ where: { id: houseId }, data: { name } });
}
/**
* A profile's `houseId` is only ever set to a real house (foreign key,
* never assigned by hand) a lookup miss here means the referenced row
* was deleted out from under a still-linked profile, an internal
* inconsistency rather than a normal "not found" a client could hit
* through the API, hence a plain `Error` (500) rather than a
* `HOUSE_NOT_FOUND` HttpError.
*/
async function findHouseOrThrow(houseId: number): Promise<HouseView> {
const house = await prisma.house.findUnique({ where: { id: houseId } });
if (!house) {
throw new Error(`House ${houseId} referenced by a profile but not found`);
}
return house;
}

View file

@ -0,0 +1,39 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { updateAllergiesSchema, updateDietSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getAllergyIds, updateAllergies, updateDiet } from "./profile.service.js";
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
export const profileRouter = Router();
/** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */
profileRouter.patch(
"/diet",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = updateDietSchema.parse(req.body);
const profile = await updateDiet(res.locals.userProfile.id, input.dietId);
res.status(200).json(profile);
}),
);
profileRouter.get(
"/allergies",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
const allergyIds = await getAllergyIds(res.locals.userProfile.id);
res.status(200).json(allergyIds);
}),
);
/** The allergen/intolerance step of the profile journey — same callers as PATCH /diet above. */
profileRouter.patch(
"/allergies",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = updateAllergiesSchema.parse(req.body);
const allergyIds = await updateAllergies(res.locals.userProfile.id, input.allergyIds);
res.status(200).json(allergyIds);
}),
);

View file

@ -0,0 +1,76 @@
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { toSafeProfile } from "../../lib/safe-profile.js";
/**
* Sets (or clears, if `dietId` is `null`) a profile's dietary regime the
* regime step of the profile journey is skippable, so `null` is a normal,
* valid value, not an omission to reject.
*
* @throws {HttpError} `404 DIET_NOT_FOUND` if `dietId` doesn't match a reference `Diet` row.
*/
export async function updateDiet(
userProfileId: number,
dietId: number | null,
): Promise<SafeUserProfile> {
if (dietId !== null) {
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
if (!diet) {
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
}
}
const profile = await prisma.userProfile.update({
where: { id: userProfileId },
data: { dietId },
});
return toSafeProfile(profile);
}
/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */
export async function getAllergyIds(userProfileId: number): Promise<number[]> {
const rows = await prisma.userProfileAllergy.findMany({
where: { userProfileId },
select: { allergyId: true },
});
return rows.map((row) => row.allergyId);
}
/**
* Replaces a profile's full allergen set (not a merge the caller sends
* the complete list every time, same shape the multi-select UI already
* holds). Validates every id up front so a partially-invalid request never
* leaves the set half-updated.
*
* @throws {HttpError} `404 ALLERGY_NOT_FOUND` if any `allergyId` doesn't match a reference `Allergy` row.
*/
export async function updateAllergies(
userProfileId: number,
allergyIds: number[],
): Promise<number[]> {
if (allergyIds.length > 0) {
const found = await prisma.allergy.findMany({
where: { id: { in: allergyIds } },
select: { id: true },
});
const foundIds = new Set(found.map((allergy) => allergy.id));
const missing = allergyIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.ALLERGY_NOT_FOUND,
`Unknown allergy id(s): ${missing.join(", ")}`,
);
}
}
await prisma.$transaction([
prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }),
prisma.userProfileAllergy.createMany({
data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })),
}),
]);
return allergyIds;
}

View file

@ -0,0 +1,81 @@
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js";
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
describe("Household", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /house/current", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/house/current");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("returns the household created at signup", async () => {
const agent = request.agent(app);
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/house/current");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal({ id: signupRes.body.houseId, name: res.body.name });
});
});
describe("PATCH /house/current", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).patch("/house/current").send({ name: "Chez nous" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("renames the household", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.patch("/house/current").send({ name: "Chez les Dupont" });
expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Chez les Dupont");
const refetch = await agent.get("/house/current");
expect(refetch.body.name).to.equal("Chez les Dupont");
});
it("rejects an empty name with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.patch("/house/current").send({ name: "" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
});

View file

@ -0,0 +1,128 @@
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js";
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
describe("Profile", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("PATCH /profile/diet", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).patch("/profile/diet").send({ dietId: 1 });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("sets the profile's regime to a valid, seeded diet", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } });
const res = await agent.patch("/profile/diet").send({ dietId: diet.id });
expect(res.status).to.equal(200);
expect(res.body.dietId).to.equal(diet.id);
});
it("clears the regime when dietId is null", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végan" } });
await agent.patch("/profile/diet").send({ dietId: diet.id });
const res = await agent.patch("/profile/diet").send({ dietId: null });
expect(res.status).to.equal(200);
expect(res.body.dietId).to.equal(null);
});
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.patch("/profile/diet").send({ dietId: 999_999 });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
});
});
describe("GET /profile/allergies + PATCH /profile/allergies", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const getRes = await request(app).get("/profile/allergies");
const patchRes = await request(app).patch("/profile/allergies").send({ allergyIds: [] });
expect(getRes.status).to.equal(401);
expect(patchRes.status).to.equal(401);
});
it("starts empty, then reflects a saved selection", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const allergies = await prisma.allergy.findMany({ include: { category: true } });
const peanuts = allergies.find((a) => a.category.name === "Arachides");
const gluten = allergies.find((a) => a.category.name === "Gluten");
if (!peanuts || !gluten) throw new Error("expected seeded allergens missing");
const initial = await agent.get("/profile/allergies");
expect(initial.body).to.deep.equal([]);
const patchRes = await agent
.patch("/profile/allergies")
.send({ allergyIds: [peanuts.id, gluten.id] });
expect(patchRes.status).to.equal(200);
expect(patchRes.body.sort()).to.deep.equal([peanuts.id, gluten.id].sort());
const refetch = await agent.get("/profile/allergies");
expect(refetch.body.sort()).to.deep.equal([peanuts.id, gluten.id].sort());
});
it("replaces (not merges) the previous selection", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const allergies = await prisma.allergy.findMany({ include: { category: true } });
const peanuts = allergies.find((a) => a.category.name === "Arachides");
const gluten = allergies.find((a) => a.category.name === "Gluten");
if (!peanuts || !gluten) throw new Error("expected seeded allergens missing");
await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] });
await agent.patch("/profile/allergies").send({ allergyIds: [gluten.id] });
const res = await agent.get("/profile/allergies");
expect(res.body).to.deep.equal([gluten.id]);
});
it("rejects an unknown allergyId with 404 ALLERGY_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.patch("/profile/allergies").send({ allergyIds: [999_999] });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.ALLERGY_NOT_FOUND);
});
});
});

View file

@ -5,6 +5,9 @@
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
"NOT_AUTHENTICATED": "Vous devez être connecté",
"NOT_FOUND": "Ressource introuvable",
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
"ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés n'existe pas",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
"auth": {

View file

@ -34,6 +34,12 @@ export enum ErrorCode {
NOT_AUTHENTICATED = 4011,
/** No route/resource matches the request. */
NOT_FOUND = 4040,
/** The profile making the request has no household yet (`houseId` is `null`). */
HOUSE_NOT_FOUND = 4041,
/** A `dietId` was given that doesn't match any reference `Diet` row. */
DIET_NOT_FOUND = 4042,
/** One or more `allergyIds` don't match any reference `Allergy` row. */
ALLERGY_NOT_FOUND = 4043,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = 5000,
}

View file

@ -5,7 +5,10 @@
export * from "./errors/error-codes.js";
export * from "./schemas/auth.js";
export * from "./schemas/household.js";
export * from "./schemas/profile.js";
export * from "./tools/assert-is-never.js";
export * from "./types/household.js";
export * from "./types/planning.js";
export * from "./types/reference.js";
export * from "./types/user-profile.js";

View file

@ -0,0 +1,12 @@
import { z } from "zod";
// Shared between apps/api (server-side validation) and apps/web (client-side
// validation for instant feedback) — see schemas/auth.ts for the full
// rationale, same pattern here.
/** Payload accepted by `PATCH /house/current`. */
export const renameHouseSchema = z.object({
name: z.string().trim().min(1, "Le nom du foyer est requis").max(100),
});
/** Inferred TS type for {@link renameHouseSchema}'s validated output. */
export type RenameHouseInput = z.infer<typeof renameHouseSchema>;

View file

@ -0,0 +1,17 @@
import { z } from "zod";
// See schemas/auth.ts for the shared client/server validation rationale.
/** Payload accepted by `PATCH /profile/diet`. `null` clears the profile's regime — this step of the profile journey is skippable. */
export const updateDietSchema = z.object({
dietId: z.number().int().positive().nullable(),
});
/** Inferred TS type for {@link updateDietSchema}'s validated output. */
export type UpdateDietInput = z.infer<typeof updateDietSchema>;
/** Payload accepted by `PATCH /profile/allergies`. Replaces the profile's full allergy set — an empty array clears it (also skippable). */
export const updateAllergiesSchema = z.object({
allergyIds: z.array(z.number().int().positive()),
});
/** Inferred TS type for {@link updateAllergiesSchema}'s validated output. */
export type UpdateAllergiesInput = z.infer<typeof updateAllergiesSchema>;

View file

@ -0,0 +1,9 @@
/**
* A household, as returned by `GET /house/current` / `PATCH /house/current`.
* Unlike `DietView`/`AllergyView` this isn't reference data — it's the
* current user's own household.
*/
export interface HouseView {
id: number;
name: string;
}