diff --git a/README.md b/README.md
index 1e268c2..7899c97 100644
--- a/README.md
+++ b/README.md
@@ -8,11 +8,17 @@ Monorepo pnpm workspaces :
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
Page de connexion/inscription en place ; le reste est encore un squelette générique.
- `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
- `loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`,
- `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
- même règles des deux côtés, pas de risque de dérive entre front et back. A un vrai
- build (`tsc` → `dist/`, voir son `package.json`) : consommé en JS compilé par
- l'API (runtime Node pur, pas de transpilation à la volée) comme par le web (Vite).
+ `loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
+ numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
+ même règles des deux côtés, pas de risque de dérive entre front et back.
+- `packages/express-tools` — outillage Express générique et réutilisable (`HttpError`,
+ `ErrorHandlerService`, `createErrorMiddleware`), séparé d'`apps/api` : pas de
+ logique métier, juste de l'infra Express.
+
+`packages/shared` et `packages/express-tools` ont un vrai build (`tsc` → `dist/`,
+voir leur `package.json`) : consommés en JS compilé, pas en TS brut — nécessaire
+pour un runtime Node pur (Docker, pas de transpilation à la volée), voir la note
+dans [specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
## Prérequis
@@ -182,12 +188,35 @@ base).
## Gestion des erreurs (API ↔ web)
-Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, `ApiErrorResponse`) :
-l'API renvoie toujours `{ code, message, details? }` (message en anglais,
-dev-facing — jamais affiché tel quel), et le client traduit `code` en libellé
-français via `ErrorMessageService` (`apps/web/src/services/error-message.service.ts`).
-Côté API, `ErrorHandlerService` (`apps/api/src/services/error-handler.service.ts`)
-centralise la transformation de toute erreur levée en réponse HTTP conforme.
+Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
+**numérique** groupée par famille — `4000` validation, `401x` auth, `404x` not
+found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
+`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
+quel), et le client traduit `code` en libellé français via **i18next**
+(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
+`apps/web/src/locales/fr/translation.json`). Côté API, `ErrorHandlerService` et
+`createErrorMiddleware` (`packages/express-tools`) centralisent la transformation
+de toute erreur levée en réponse HTTP conforme — aucune valeur `ErrorCode` codée
+en dur nulle part (toujours `ErrorCode.XXX`, y compris dans les mocks Cypress).
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
[specs/error-handling.md](specs/error-handling.md).
+
+## i18n
+
+**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
+erreurs) vient de fichiers de locale JSON (`apps/web/src/locales//translation.json`),
+jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
+en ajouter une est une question de fichier de locale, pas de code. Détail :
+[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
+
+## Données de test (faker.js)
+
+`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
+de test dans `test/auth.test.ts` (Mocha) et le "bruit" (prénom/nom de remplissage)
+des steps Cucumber — jamais de nom/email qui ressemble à une vraie personne en dur
+dans un fixture. Les valeurs *littérales* des scénarios `.feature` eux-mêmes
+(ex. `alice@example.com`) restent volontairement statiques : c'est le point des
+scénarios Gherkin lisibles (exemples illustratifs conventionnels en BDD, pas des
+données réelles) — seules les données de remplissage hors du texte lisible du
+scénario sont générées.
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index c51a35a..255ca88 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -24,6 +24,7 @@ COPY --from=build /repo/node_modules ./node_modules
COPY --from=build /repo/package.json ./package.json
COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=build /repo/packages/shared ./packages/shared
+COPY --from=build /repo/packages/express-tools ./packages/express-tools
COPY --from=build /repo/apps/api/node_modules ./apps/api/node_modules
COPY --from=build /repo/apps/api/dist ./apps/api/dist
COPY --from=build /repo/apps/api/prisma ./apps/api/prisma
diff --git a/apps/api/features/step-definitions/auth.steps.ts b/apps/api/features/step-definitions/auth.steps.ts
index eda2041..64149e5 100644
--- a/apps/api/features/step-definitions/auth.steps.ts
+++ b/apps/api/features/step-definitions/auth.steps.ts
@@ -1,22 +1,33 @@
import assert from "node:assert/strict";
import type { DataTable } from "@cucumber/cucumber";
import { Given, Then, When } from "@cucumber/cucumber";
+import { faker } from "@faker-js/faker";
import { signup } from "../../src/modules/auth/auth.service.js";
import type { CustomWorld } from "../support/world.js";
+// firstName/lastName/password below are filler for background state the
+// scenario doesn't actually read (only the emails in the .feature file are
+// part of what's being tested) — faker-generated rather than hardcoded so
+// no test fixture ever looks like a real person's data.
+
Given("a profile already exists with email {string}", async (email: string) => {
await signup({
- firstName: "Existing",
- lastName: "User",
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
email,
- password: "some-existing-password",
+ password: faker.internet.password({ length: 16 }),
});
});
Given(
"a profile already exists with email {string} and password {string}",
async (email: string, password: string) => {
- await signup({ firstName: "Existing", lastName: "User", email, password });
+ await signup({
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
+ email,
+ password,
+ });
},
);
diff --git a/apps/api/features/step-definitions/health.steps.ts b/apps/api/features/step-definitions/health.steps.ts
index 5094898..c69fc92 100644
--- a/apps/api/features/step-definitions/health.steps.ts
+++ b/apps/api/features/step-definitions/health.steps.ts
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
+import { ErrorCode } from "@batch-cooking/shared";
import { Then, When } from "@cucumber/cucumber";
import request from "supertest";
import type { CustomWorld } from "../support/world.js";
@@ -18,6 +19,13 @@ Then("the response body should be:", function (this: CustomWorld, expectedJson:
// Generic enough to be reused by any feature asserting on the shared
// ApiErrorResponse contract's `code` field — not health-specific, but this
// file is where the other generic response-assertion steps already live.
+//
+// `code` here is the enum *member name* (readable in the .feature file,
+// e.g. "EMAIL_ALREADY_IN_USE") — ErrorCode[name] resolves it to the real
+// numeric value via TypeScript's reverse enum lookup, so this never
+// compares against a hardcoded number.
Then("the response error code should be {string}", function (this: CustomWorld, code: string) {
- assert.equal(this.response.body.code, code);
+ const expected = ErrorCode[code as keyof typeof ErrorCode];
+ assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`);
+ assert.equal(this.response.body.code, expected);
});
diff --git a/apps/api/package.json b/apps/api/package.json
index 26287d5..530d581 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -14,6 +14,7 @@
"postinstall": "prisma generate"
},
"dependencies": {
+ "@batch-cooking/express-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"@prisma/client": "^5.22.0",
"argon2": "0.31.2",
@@ -26,6 +27,7 @@
},
"devDependencies": {
"@cucumber/cucumber": "^13.2.1",
+ "@faker-js/faker": "^10.6.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 3ac5348..db65ad0 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -1,10 +1,10 @@
+import { createErrorMiddleware, errorHandlerService } from "@batch-cooking/express-tools";
import { ErrorCode } from "@batch-cooking/shared";
import cookieParser from "cookie-parser";
import cors from "cors";
-import express, { type NextFunction, type Request, type Response } from "express";
+import express, { type Request, type Response } from "express";
import { env } from "./config/env.js";
import { authRouter } from "./modules/auth/auth.routes.js";
-import { errorHandlerService } from "./services/error-handler.service.js";
/**
* Builds a fresh Express application instance (no shared mutable state
@@ -37,11 +37,9 @@ export function createApp() {
// Final error-handling middleware: every thrown/`next(err)`-ed error in
// the app ends up here. All the "what status/body does this error map
- // to" logic lives in ErrorHandlerService — this stays a thin adapter.
- app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
- const { status, body } = errorHandlerService.handle(err);
- res.status(status).json(body);
- });
+ // to" logic lives in ErrorHandlerService, from @batch-cooking/express-tools
+ // — this stays a thin adapter.
+ app.use(createErrorMiddleware(errorHandlerService));
return app;
}
diff --git a/apps/api/src/middlewares/require-auth.ts b/apps/api/src/middlewares/require-auth.ts
index f10bf81..dacb37a 100644
--- a/apps/api/src/middlewares/require-auth.ts
+++ b/apps/api/src/middlewares/require-auth.ts
@@ -1,8 +1,8 @@
+import { HttpError } from "@batch-cooking/express-tools";
import { ErrorCode } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
-import { HttpError } from "../lib/http-error.js";
import { verifyAuthToken } from "../lib/jwt.js";
/**
@@ -13,7 +13,7 @@ import { verifyAuthToken } from "../lib/jwt.js";
* once that feature exists) despite carrying no server-side session.
*
* On success, attaches the resolved profile to `req.userProfile` (see
- * `src/types/express.d.ts`) for downstream handlers to use.
+ * `src/types/express-request.augment.ts`) for downstream handlers to use.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts
index 1fc9482..ec6b740 100644
--- a/apps/api/src/modules/auth/auth.service.ts
+++ b/apps/api/src/modules/auth/auth.service.ts
@@ -1,9 +1,9 @@
+import { HttpError } from "@batch-cooking/express-tools";
import { ErrorCode, type LoginInput, type SignupInput } from "@batch-cooking/shared";
import type { UserProfile } from "@prisma/client";
import argon2 from "argon2";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
-import { HttpError } from "../../lib/http-error.js";
import { signAuthToken } from "../../lib/jwt.js";
/** A UserProfile as it's safe to hand back to a client — never the password hash. */
diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express-request.augment.ts
similarity index 64%
rename from apps/api/src/types/express.d.ts
rename to apps/api/src/types/express-request.augment.ts
index 61290ec..3c20ed9 100644
--- a/apps/api/src/types/express.d.ts
+++ b/apps/api/src/types/express-request.augment.ts
@@ -3,6 +3,11 @@ import type { UserProfile } from "@prisma/client";
// Module augmentation: adds a `userProfile` field to Express's Request type
// so `requireAuth` can attach the authenticated profile and downstream
// handlers get it fully typed, without an `as` cast at every call site.
+//
+// Deliberately a plain .ts file, not a .d.ts: `declare global` augmentation
+// works identically either way as long as the file has a top-level import
+// (which makes it a module TS will include in the program) — no need for
+// the .d.ts extension just for this.
declare global {
namespace Express {
interface Request {
diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts
index aab4118..c071e25 100644
--- a/apps/api/test/auth.test.ts
+++ b/apps/api/test/auth.test.ts
@@ -1,17 +1,30 @@
-import { ErrorCode } from "@batch-cooking/shared";
+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";
-/** Valid signup payload reused across tests. */
-const validSignup = {
- firstName: "Nicolas",
- lastName: "Lefevre",
- email: "nicolas@example.com",
- password: "correct-horse-battery-staple",
-};
+/**
+ * Builds a fresh, fake (never real-looking) signup payload. Called anew per
+ * test rather than sharing one module-level constant, so tests never
+ * accidentally depend on a specific fixture value and each run exercises
+ * different data — closer to how the app actually gets used.
+ */
+function buildSignupPayload(): SignupInput {
+ const firstName = faker.person.firstName();
+ const lastName = faker.person.lastName();
+ return {
+ firstName,
+ lastName,
+ // Lowercased to match what signupSchema/loginSchema normalize the email
+ // to (`.toLowerCase()`) — faker sometimes capitalizes parts of it, and
+ // without this the fixture value stops matching what's actually stored.
+ email: faker.internet.email({ firstName, lastName }).toLowerCase(),
+ password: faker.internet.password({ length: 16 }),
+ };
+}
describe("Auth", () => {
const app = createApp();
@@ -26,13 +39,14 @@ describe("Auth", () => {
describe("POST /auth/signup", () => {
it("creates a profile and its house, and sets a session cookie", async () => {
- const res = await request(app).post("/auth/signup").send(validSignup);
+ const payload = buildSignupPayload();
+ const res = await request(app).post("/auth/signup").send(payload);
expect(res.status).to.equal(201);
expect(res.body).to.include({
- firstName: "Nicolas",
- lastName: "Lefevre",
- email: "nicolas@example.com",
+ firstName: payload.firstName,
+ lastName: payload.lastName,
+ email: payload.email,
});
expect(res.body).to.not.have.property("passwordHash");
expect(res.body.houseId).to.be.a("number");
@@ -40,17 +54,21 @@ describe("Auth", () => {
});
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
- await request(app).post("/auth/signup").send(validSignup);
- const res = await request(app).post("/auth/signup").send(validSignup);
+ const payload = buildSignupPayload();
+ await request(app).post("/auth/signup").send(payload);
+ const res = await request(app).post("/auth/signup").send(payload);
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE);
});
it("rejects an invalid payload with 400 VALIDATION_ERROR", async () => {
- const res = await request(app)
- .post("/auth/signup")
- .send({ firstName: "X", lastName: "Y", email: "not-an-email", password: "short" });
+ const res = await request(app).post("/auth/signup").send({
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
+ email: "not-an-email",
+ password: "short",
+ });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
@@ -59,23 +77,26 @@ describe("Auth", () => {
});
describe("POST /auth/login", () => {
+ let payload: SignupInput;
+
beforeEach(async () => {
- await request(app).post("/auth/signup").send(validSignup);
+ payload = buildSignupPayload();
+ await request(app).post("/auth/signup").send(payload);
});
it("logs in with correct credentials", async () => {
const res = await request(app)
.post("/auth/login")
- .send({ email: validSignup.email, password: validSignup.password });
+ .send({ email: payload.email, password: payload.password });
expect(res.status).to.equal(200);
- expect(res.body.email).to.equal(validSignup.email);
+ expect(res.body.email).to.equal(payload.email);
});
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app)
.post("/auth/login")
- .send({ email: validSignup.email, password: "wrong-password" });
+ .send({ email: payload.email, password: faker.internet.password({ length: 16 }) });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
@@ -84,7 +105,7 @@ describe("Auth", () => {
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app)
.post("/auth/login")
- .send({ email: "nobody@example.com", password: validSignup.password });
+ .send({ email: faker.internet.email(), password: payload.password });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
@@ -100,13 +121,14 @@ describe("Auth", () => {
});
it("returns the current profile when authenticated", async () => {
+ const payload = buildSignupPayload();
const agent = request.agent(app);
- await agent.post("/auth/signup").send(validSignup);
+ await agent.post("/auth/signup").send(payload);
const res = await agent.get("/auth/me");
expect(res.status).to.equal(200);
- expect(res.body.email).to.equal(validSignup.email);
+ expect(res.body.email).to.equal(payload.email);
});
});
});
diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts
index b2fa37a..825ddb0 100644
--- a/apps/web/cypress/e2e/auth.cy.ts
+++ b/apps/web/cypress/e2e/auth.cy.ts
@@ -1,3 +1,5 @@
+import { ErrorCode } from "@batch-cooking/shared";
+
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml), and it keeps these specs focused on frontend
// behavior. Backend behavior itself is covered by apps/api's Mocha/Cucumber
@@ -50,7 +52,7 @@ describe("Signup", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/signup", {
statusCode: 409,
- body: { code: "EMAIL_ALREADY_IN_USE", message: "Email already in use" },
+ body: { code: ErrorCode.EMAIL_ALREADY_IN_USE, message: "Email already in use" },
}).as("signup");
cy.visit("/signup");
@@ -94,7 +96,7 @@ describe("Login", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/login", {
statusCode: 401,
- body: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" },
+ body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid email or password" },
}).as("login");
cy.visit("/login");
diff --git a/apps/web/cypress/e2e/smoke.cy.ts b/apps/web/cypress/e2e/smoke.cy.ts
index b26544b..5333aa7 100644
--- a/apps/web/cypress/e2e/smoke.cy.ts
+++ b/apps/web/cypress/e2e/smoke.cy.ts
@@ -1,8 +1,10 @@
+import { ErrorCode } from "@batch-cooking/shared";
+
describe("smoke test", () => {
it("redirects an unauthenticated visitor to the login page", () => {
cy.intercept("GET", "**/auth/me", {
statusCode: 401,
- body: { code: "NOT_AUTHENTICATED", message: "Not authenticated" },
+ body: { code: ErrorCode.NOT_AUTHENTICATED, message: "Not authenticated" },
});
cy.visit("/");
cy.url().should("include", "/login");
diff --git a/apps/web/package.json b/apps/web/package.json
index bb10d80..890c1fc 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,8 +14,10 @@
},
"dependencies": {
"@batch-cooking/shared": "workspace:*",
+ "i18next": "^26.3.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-i18next": "^17.0.11",
"react-router-dom": "^7.18.2",
"zod": "^3.25.76"
},
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 32a476c..49e4779 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -1,9 +1,9 @@
-import type {
- ApiErrorResponse,
+import {
+ type ApiErrorResponse,
ErrorCode,
- LoginInput,
- SafeUserProfile,
- SignupInput,
+ type LoginInput,
+ type SafeUserProfile,
+ type SignupInput,
} from "@batch-cooking/shared";
/** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */
@@ -59,9 +59,11 @@ export class ApiClient {
if (!response.ok) {
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
+ // Fallback for a response that couldn't even be parsed as JSON — no
+ // hardcoded string, always the real enum member.
throw new ApiError(
response.status,
- body ?? { code: "INTERNAL_ERROR" as ErrorCode, message: "Something went wrong" },
+ body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
);
}
diff --git a/apps/web/src/i18n/i18n.ts b/apps/web/src/i18n/i18n.ts
new file mode 100644
index 0000000..014bc37
--- /dev/null
+++ b/apps/web/src/i18n/i18n.ts
@@ -0,0 +1,26 @@
+import i18next from "i18next";
+import { initReactI18next } from "react-i18next";
+import fr from "../locales/fr/translation.json";
+
+/**
+ * i18next instance for the whole app, imported once for its side effect
+ * (`main.tsx`) before anything renders. Only French exists today —
+ * `packages/shared`'s `ErrorCode` enum members double as translation keys
+ * under the `errors` namespace (see `services/error-message.service.ts`).
+ *
+ * Adding a language later is "add a `resources.` entry pointing at a
+ * new locale file", not touching a single component.
+ */
+void i18next.use(initReactI18next).init({
+ resources: {
+ fr: { translation: fr },
+ },
+ lng: "fr",
+ fallbackLng: "fr",
+ // React already escapes interpolated values when rendering JSX — letting
+ // i18next also HTML-escape them would double-escape (e.g. turn "é" text
+ // into visible "é" in some setups).
+ interpolation: { escapeValue: false },
+});
+
+export default i18next;
diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json
new file mode 100644
index 0000000..b343580
--- /dev/null
+++ b/apps/web/src/locales/fr/translation.json
@@ -0,0 +1,36 @@
+{
+ "errors": {
+ "VALIDATION_ERROR": "Erreur de validation",
+ "EMAIL_ALREADY_IN_USE": "Cet email est déjà utilisé",
+ "INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
+ "NOT_AUTHENTICATED": "Vous devez être connecté",
+ "NOT_FOUND": "Ressource introuvable",
+ "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
+ },
+ "auth": {
+ "login": {
+ "title": "Se connecter",
+ "emailLabel": "Email",
+ "passwordLabel": "Mot de passe",
+ "submit": "Se connecter",
+ "submitting": "Connexion…",
+ "noAccount": "Pas encore de compte ?",
+ "createProfileLink": "Créer un profil"
+ },
+ "signup": {
+ "title": "Créer un profil",
+ "firstNameLabel": "Prénom",
+ "lastNameLabel": "Nom",
+ "emailLabel": "Email",
+ "passwordLabel": "Mot de passe",
+ "submit": "Créer mon profil",
+ "submitting": "Création…",
+ "hasAccount": "Déjà un compte ?",
+ "loginLink": "Se connecter"
+ }
+ },
+ "home": {
+ "greeting": "Bonjour {{firstName}} {{lastName}} 👋",
+ "logout": "Se déconnecter"
+ }
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 40db779..7416923 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -3,6 +3,9 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AuthProvider } from "./features/auth/AuthContext";
+// Side-effect import: initializes the i18next instance before anything
+// renders (react-i18next reads it via context under the hood). See i18n/i18n.ts.
+import "./i18n/i18n";
// Global stylesheet (theme tokens + minimal reset) — the only .scss import
// that isn't colocated with a specific component/page. See styles/global.scss.
import "./styles/global.scss";
diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx
index ea5e329..895abca 100644
--- a/apps/web/src/pages/HomePage.tsx
+++ b/apps/web/src/pages/HomePage.tsx
@@ -1,14 +1,17 @@
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../features/auth/AuthContext";
import "./HomePage.scss";
/**
* Landing page for an authenticated visitor. Behind {@link RequireAuth} —
- * `user` is guaranteed non-null by the time this renders.
+ * `user` is guaranteed non-null by the time this renders. Static copy
+ * comes from i18next (`locales/fr/translation.json`, `home` namespace).
*/
export function HomePage() {
const { user, logout } = useAuth();
const navigate = useNavigate();
+ const { t } = useTranslation();
/** Ends the session and returns to the login page. */
async function handleLogout() {
@@ -19,11 +22,9 @@ export function HomePage() {
return (
);
diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx
index e49fce3..98a931b 100644
--- a/apps/web/src/pages/LoginPage.tsx
+++ b/apps/web/src/pages/LoginPage.tsx
@@ -1,5 +1,6 @@
import { ErrorCode, loginSchema } from "@batch-cooking/shared";
import { type FormEvent, useState } from "react";
+import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { useAuth } from "../features/auth/AuthContext";
@@ -14,11 +15,14 @@ import { errorMessageService } from "../services/error-message.service";
* same rules the API enforces) for instant feedback with no network round
* trip; only calls the API once the payload is locally valid, and
* translates any API failure into a localized label via
- * {@link ErrorMessageService}.
+ * {@link ErrorMessageService}. All static copy comes from i18next
+ * (`locales/fr/translation.json`, `auth.login` namespace) via
+ * {@link useTranslation}, not hardcoded JSX text.
*/
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
+ const { t } = useTranslation();
// Controlled form fields.
const [email, setEmail] = useState("");
@@ -61,9 +65,9 @@ export function LoginPage() {
return (
}
- Pas encore de compte ? Créer un profil
+ {t("auth.login.noAccount")} {t("auth.login.createProfileLink")}
diff --git a/apps/web/src/pages/SignupPage.tsx b/apps/web/src/pages/SignupPage.tsx
index a6b0a5d..662d839 100644
--- a/apps/web/src/pages/SignupPage.tsx
+++ b/apps/web/src/pages/SignupPage.tsx
@@ -1,5 +1,6 @@
import { ErrorCode, signupSchema } from "@batch-cooking/shared";
import { type FormEvent, useState } from "react";
+import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { useAuth } from "../features/auth/AuthContext";
@@ -15,10 +16,13 @@ import { errorMessageService } from "../services/error-message.service";
* feedback with no network round trip; only calls the API once the
* payload is locally valid, and translates any API failure (e.g. email
* already taken) into a localized label via {@link ErrorMessageService}.
+ * All static copy comes from i18next (`locales/fr/translation.json`,
+ * `auth.signup` namespace) via {@link useTranslation}, not hardcoded JSX text.
*/
export function SignupPage() {
const { signup } = useAuth();
const navigate = useNavigate();
+ const { t } = useTranslation();
// Controlled form fields.
const [firstName, setFirstName] = useState("");
@@ -63,9 +67,9 @@ export function SignupPage() {
return (
diff --git a/apps/web/src/services/error-message.service.ts b/apps/web/src/services/error-message.service.ts
index caf3d67..c2d7a73 100644
--- a/apps/web/src/services/error-message.service.ts
+++ b/apps/web/src/services/error-message.service.ts
@@ -1,51 +1,30 @@
import { ErrorCode } from "@batch-cooking/shared";
-
-/** Locales this service knows how to label errors in. Extend here when adding a new language. */
-export type Locale = "fr";
-
-/** Per-locale map of every {@link ErrorCode} to its user-facing label. */
-type LabelsByLocale = Record>;
+import i18n from "../i18n/i18n";
/**
- * Centralizes every user-facing error label in the app, keyed by the
- * {@link ErrorCode} the API returns. Components never hardcode error text —
- * they call `errorMessageService.getLabel(...)` and get back the right
- * string for the current locale.
+ * Centralizes lookup of the user-facing label for a given {@link ErrorCode},
+ * delegating the actual translation storage/lookup to i18next (see
+ * `i18n/i18n.ts` and `locales/fr/translation.json`) — components never
+ * hardcode error text, and adding a language is a locale file, not a
+ * code change.
*
- * Only French exists today (the whole UI is French), but the `Locale`
- * type and the per-locale label map exist so adding a second language
- * later is "add a locale to the map", not "hunt down every hardcoded
- * string in every component". Used as a single shared instance
- * (`errorMessageService`, exported below).
+ * A numeric `ErrorCode` value isn't a valid i18next key by itself (and
+ * numeric JSON keys would be far less readable in the locale file than
+ * names), so this reverse-maps the enum value to its member name (e.g.
+ * `4001` → `"EMAIL_ALREADY_IN_USE"`) via TypeScript's numeric-enum reverse
+ * mapping, then looks that name up under the `errors` namespace.
*/
export class ErrorMessageService {
- /** Locale used when none is explicitly requested — the only one that exists today. */
- private readonly defaultLocale: Locale = "fr";
-
- /** Every known error code's label, per locale. */
- private readonly labels: LabelsByLocale = {
- fr: {
- [ErrorCode.VALIDATION_ERROR]: "Erreur de validation",
- [ErrorCode.EMAIL_ALREADY_IN_USE]: "Cet email est déjà utilisé",
- [ErrorCode.INVALID_CREDENTIALS]: "Email ou mot de passe incorrect",
- [ErrorCode.NOT_AUTHENTICATED]: "Vous devez être connecté",
- [ErrorCode.NOT_FOUND]: "Ressource introuvable",
- [ErrorCode.INTERNAL_ERROR]: "Une erreur est survenue, réessayez plus tard",
- },
- };
-
/**
* Returns the localized, user-facing label for a given error code.
*
- * @param code - Error code as returned by the API. Typed as `string` (not
- * strictly `ErrorCode`) because it's coming off the network — an
- * unrecognized value falls back to the generic "internal error" label
- * instead of throwing.
- * @param locale - Defaults to {@link defaultLocale}.
+ * @param code - Error code as returned by the API. An unrecognized value
+ * (e.g. the client is older than the API and doesn't know a newer code)
+ * falls back to the generic `INTERNAL_ERROR` label instead of throwing.
*/
- public getLabel(code: string, locale: Locale = this.defaultLocale): string {
- const labelsForLocale = this.labels[locale];
- return labelsForLocale[code as ErrorCode] ?? labelsForLocale[ErrorCode.INTERNAL_ERROR];
+ public getLabel(code: ErrorCode): string {
+ const memberName = ErrorCode[code] ?? ErrorCode[ErrorCode.INTERNAL_ERROR];
+ return i18n.t(`errors.${memberName}`);
}
}
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
deleted file mode 100644
index 11f02fe..0000000
--- a/apps/web/src/vite-env.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-///
diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json
index 31fce0b..af5b6cb 100644
--- a/apps/web/tsconfig.app.json
+++ b/apps/web/tsconfig.app.json
@@ -5,6 +5,7 @@
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
+ "types": ["vite/client"],
"noEmit": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
diff --git a/packages/express-tools/package.json b/packages/express-tools/package.json
new file mode 100644
index 0000000..27d3d09
--- /dev/null
+++ b/packages/express-tools/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@batch-cooking/express-tools",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "test": "echo \"no tests yet\" && exit 0",
+ "build": "tsc -p tsconfig.json",
+ "postinstall": "tsc -p tsconfig.json"
+ },
+ "devDependencies": {
+ "@types/express": "^4.17.21",
+ "@types/node": "^22.9.0",
+ "typescript": "^5.7.2"
+ },
+ "dependencies": {
+ "@batch-cooking/shared": "workspace:*",
+ "express": "^4.21.1",
+ "zod": "^3.25.76"
+ }
+}
diff --git a/apps/api/src/services/error-handler.service.ts b/packages/express-tools/src/error-handler.service.ts
similarity index 90%
rename from apps/api/src/services/error-handler.service.ts
rename to packages/express-tools/src/error-handler.service.ts
index ddf8df9..177bb19 100644
--- a/apps/api/src/services/error-handler.service.ts
+++ b/packages/express-tools/src/error-handler.service.ts
@@ -1,6 +1,6 @@
import { type ApiErrorResponse, ErrorCode } from "@batch-cooking/shared";
import { ZodError } from "zod";
-import { HttpError } from "../lib/http-error.js";
+import { HttpError } from "./http-error.js";
/** Return value of {@link ErrorHandlerService.handle}: everything an Express error middleware needs to send a response. */
export interface ErrorHandlingResult {
@@ -12,8 +12,9 @@ export interface ErrorHandlingResult {
/**
* Centralizes every "how do we turn a thrown error into an HTTP response"
- * decision for the API in one place, so route handlers and the Express
- * error middleware in `app.ts` never duplicate this logic.
+ * decision for an Express app in one place, so route handlers and the
+ * error middleware never duplicate this logic. Wire it into Express via
+ * {@link createErrorMiddleware} (see error-middleware.ts).
*
* Recognizes three error shapes today (zod validation failures, our own
* `HttpError`, and anything else) and always falls back to a safe, generic
diff --git a/packages/express-tools/src/error-middleware.ts b/packages/express-tools/src/error-middleware.ts
new file mode 100644
index 0000000..fcaf9eb
--- /dev/null
+++ b/packages/express-tools/src/error-middleware.ts
@@ -0,0 +1,27 @@
+import type { NextFunction, Request, Response } from "express";
+import type { ErrorHandlerService } from "./error-handler.service.js";
+
+/** Express error-handling middleware signature (the 4-arg form Express detects as an error handler). */
+type ExpressErrorMiddleware = (
+ err: unknown,
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) => void;
+
+/**
+ * Builds the final Express error-handling middleware for an app: every
+ * thrown/`next(err)`-ed error ends up here, gets mapped by the given
+ * {@link ErrorHandlerService}, and sent as the response. Keeps the actual
+ * "what does this error mean" logic in the service, testable on its own —
+ * this factory is just the thin Express adapter.
+ *
+ * @example
+ * app.use(createErrorMiddleware(errorHandlerService));
+ */
+export function createErrorMiddleware(errorHandler: ErrorHandlerService): ExpressErrorMiddleware {
+ return (err, _req, res, _next) => {
+ const { status, body } = errorHandler.handle(err);
+ res.status(status).json(body);
+ };
+}
diff --git a/apps/api/src/lib/http-error.ts b/packages/express-tools/src/http-error.ts
similarity index 81%
rename from apps/api/src/lib/http-error.ts
rename to packages/express-tools/src/http-error.ts
index bfc5bd5..df4893b 100644
--- a/apps/api/src/lib/http-error.ts
+++ b/packages/express-tools/src/http-error.ts
@@ -5,9 +5,9 @@ import type { ErrorCode } from "@batch-cooking/shared";
* business {@link ErrorCode} that identifies *why* it happened.
*
* Route handlers throw this (or let it bubble from a service call) instead
- * of manually setting a status/body — `ErrorHandlerService` is the single
- * place that turns it into an actual HTTP response, so every error path in
- * the API is shaped consistently. See `services/error-handler.service.ts`.
+ * of manually setting a status/body — {@link ErrorHandlerService} is the
+ * single place that turns it into an actual HTTP response, so every error
+ * path in an Express app built with these tools is shaped consistently.
*/
export class HttpError extends Error {
/** HTTP status code to respond with (e.g. 401, 404, 409). */
diff --git a/packages/express-tools/src/index.ts b/packages/express-tools/src/index.ts
new file mode 100644
index 0000000..69abfd0
--- /dev/null
+++ b/packages/express-tools/src/index.ts
@@ -0,0 +1,8 @@
+// Public entry point of the Express-specific tooling shared across any
+// Express app in this monorepo (currently apps/api). Generic HTTP/Express
+// infrastructure lives here — domain-specific code (auth, business logic)
+// stays in the consuming app.
+
+export * from "./error-handler.service.js";
+export * from "./error-middleware.js";
+export * from "./http-error.js";
diff --git a/packages/express-tools/tsconfig.json b/packages/express-tools/tsconfig.json
new file mode 100644
index 0000000..3cf7309
--- /dev/null
+++ b/packages/express-tools/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts
index a59082a..52a99a3 100644
--- a/packages/shared/src/errors/error-codes.ts
+++ b/packages/shared/src/errors/error-codes.ts
@@ -4,28 +4,38 @@
* This is the single source of truth for error identification across the
* whole monorepo: `apps/api` throws errors carrying one of these codes,
* and `apps/web` maps each code to a localized, user-facing label (see
- * `apps/web/src/services/error-message.service.ts`). Neither side should
- * ever hardcode a raw error string that the other side has to guess at —
- * the code is the contract.
+ * `apps/web/src/services/error-message.service.ts`, backed by i18next
+ * locale files under `apps/web/src/locales/`). Neither side should ever
+ * hardcode a raw error value that the other side has to guess at — always
+ * reference `ErrorCode.XXX`, never a bare number/string.
+ *
+ * Numeric values (not string codes): grouped by category so the number
+ * itself hints at the kind of failure, similar in spirit to HTTP status
+ * code families —
+ * - `4000`–`4099`: request validation
+ * - `4010`–`4019`: authentication
+ * - `4040`–`4049`: not found
+ * - `5000`–`5099`: internal/unexpected
*
* When adding a new failure case in the API:
- * 1. Add a new member here.
- * 2. Throw it via `HttpError` (apps/api/src/lib/http-error.ts).
- * 3. Add its translation in `ErrorMessageService` (apps/web).
+ * 1. Add a new member here, in the right range, with the next free number.
+ * 2. Throw it via `HttpError` (`@batch-cooking/express-tools`).
+ * 3. Add its translation key to every locale file under
+ * `apps/web/src/locales` (one `translation.json` per language).
*/
export enum ErrorCode {
/** Request body/query failed zod schema validation. */
- VALIDATION_ERROR = "VALIDATION_ERROR",
+ VALIDATION_ERROR = 4000,
/** Signup attempted with an email that already has a profile. */
- EMAIL_ALREADY_IN_USE = "EMAIL_ALREADY_IN_USE",
+ EMAIL_ALREADY_IN_USE = 4001,
/** Login failed — wrong email or wrong password (never say which). */
- INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
+ INVALID_CREDENTIALS = 4010,
/** Request required a session cookie/JWT that is missing, invalid, or stale. */
- NOT_AUTHENTICATED = "NOT_AUTHENTICATED",
+ NOT_AUTHENTICATED = 4011,
/** No route/resource matches the request. */
- NOT_FOUND = "NOT_FOUND",
+ NOT_FOUND = 4040,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
- INTERNAL_ERROR = "INTERNAL_ERROR",
+ INTERNAL_ERROR = 5000,
}
/**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b54cba6..5586805 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
apps/api:
dependencies:
+ '@batch-cooking/express-tools':
+ specifier: workspace:*
+ version: link:../../packages/express-tools
'@batch-cooking/shared':
specifier: workspace:*
version: link:../../packages/shared
@@ -48,6 +51,9 @@ importers:
'@cucumber/cucumber':
specifier: ^13.2.1
version: 13.2.1
+ '@faker-js/faker':
+ specifier: ^10.6.0
+ version: 10.6.0
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@4.17.25)
@@ -93,12 +99,18 @@ importers:
'@batch-cooking/shared':
specifier: workspace:*
version: link:../../packages/shared
+ i18next:
+ specifier: ^26.3.6
+ version: 26.3.6(typescript@5.9.3)
react:
specifier: ^18.3.1
version: 18.3.1
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
+ react-i18next:
+ specifier: ^17.0.11
+ version: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
react-router-dom:
specifier: ^7.18.2
version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -134,6 +146,28 @@ importers:
specifier: ^5.4.11
version: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
+ packages/express-tools:
+ dependencies:
+ '@batch-cooking/shared':
+ specifier: workspace:*
+ version: link:../shared
+ express:
+ specifier: ^4.21.1
+ version: 4.22.2
+ zod:
+ specifier: ^3.25.76
+ version: 3.25.76
+ devDependencies:
+ '@types/express':
+ specifier: ^4.17.21
+ version: 4.17.25
+ '@types/node':
+ specifier: ^22.9.0
+ version: 22.20.1
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+
packages/shared:
dependencies:
zod:
@@ -217,6 +251,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==, tarball: https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz}
engines: {node: '>=6.9.0'}
@@ -658,6 +696,10 @@ packages:
cpu: [x64]
os: [win32]
+ '@faker-js/faker@10.6.0':
+ resolution: {integrity: sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==, tarball: https://registry.npmjs.org/@faker-js/faker/-/faker-10.6.0.tgz}
+ engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
+
'@hapi/address@5.1.1':
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==, tarball: https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz}
engines: {node: '>=14.0.0'}
@@ -1760,6 +1802,9 @@ packages:
resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==, tarball: https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz}
engines: {node: ^20.17.0 || >=22.9.0}
+ html-parse-stringify@4.0.1:
+ resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==, tarball: https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz}
+
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz}
engines: {node: '>= 0.8'}
@@ -1780,6 +1825,14 @@ packages:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, tarball: https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz}
engines: {node: '>=10.17.0'}
+ i18next@26.3.6:
+ resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==, tarball: https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz}
+ peerDependencies:
+ typescript: ^5 || ^6 || ^7
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz}
engines: {node: '>=0.10.0'}
@@ -2296,6 +2349,22 @@ packages:
peerDependencies:
react: ^18.3.1
+ react-i18next@17.0.11:
+ resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==, tarball: https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz}
+ peerDependencies:
+ i18next: '>= 26.2.0'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5 || ^6 || ^7
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==, tarball: https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz}
engines: {node: '>=0.10.0'}
@@ -2664,6 +2733,11 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==, tarball: https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
util-arity@1.1.0:
resolution: {integrity: sha512-kkyIsXKwemfSy8ZEoaIz06ApApnWsk5hQO0vLjZS6UkBiGiW++Jsyb8vSBoc0WKlffGoGs5yYy/j5pp8zckrFA==, tarball: https://registry.npmjs.org/util-arity/-/util-arity-1.1.0.tgz}
@@ -2889,6 +2963,8 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
+ '@babel/runtime@7.29.7': {}
+
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -3233,6 +3309,8 @@ snapshots:
'@esbuild/win32-x64@0.28.2':
optional: true
+ '@faker-js/faker@10.6.0': {}
+
'@hapi/address@5.1.1':
dependencies:
'@hapi/hoek': 11.0.7
@@ -4411,6 +4489,8 @@ snapshots:
dependencies:
lru-cache: 11.5.2
+ html-parse-stringify@4.0.1: {}
+
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -4436,6 +4516,10 @@ snapshots:
human-signals@2.1.0: {}
+ i18next@26.3.6(typescript@5.9.3):
+ optionalDependencies:
+ typescript: 5.9.3
+
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -4887,6 +4971,17 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
+ react-i18next@17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ html-parse-stringify: 4.0.1
+ i18next: 26.3.6(typescript@5.9.3)
+ react: 18.3.1
+ use-sync-external-store: 1.6.0(react@18.3.1)
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
+ typescript: 5.9.3
+
react-refresh@0.17.0: {}
react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -5301,6 +5396,10 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
+ use-sync-external-store@1.6.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
util-arity@1.1.0: {}
util-deprecate@1.0.2: {}
diff --git a/specs/error-handling.md b/specs/error-handling.md
index 581947e..f9a2303 100644
--- a/specs/error-handling.md
+++ b/specs/error-handling.md
@@ -6,26 +6,35 @@
## Vue d'ensemble
-Trois pièces travaillent ensemble pour que **toute** erreur, du serveur jusqu'à
+Quatre pièces travaillent ensemble pour que **toute** erreur, du serveur jusqu'à
l'affichage utilisateur, passe par un chemin unique et prévisible :
-- **`packages/shared`** — le contrat : `ErrorCode` (énumération de tous les codes
- d'erreur métier) et `ApiErrorResponse` (forme JSON de toute réponse d'erreur de
- l'API). Ni l'API ni le web ne définissent leur propre liste de codes.
-- **`apps/api` → `ErrorHandlerService`** — centralise la traduction de n'importe
- quelle erreur levée (validation zod, `HttpError` métier, erreur inattendue) en
- `{ status, body }` conforme au contrat. Le middleware d'erreur d'Express
- (`app.ts`) ne fait qu'appeler ce service.
-- **`apps/web` → `ErrorMessageService`** — centralise la traduction de chaque
- `ErrorCode` en libellé affichable, avec un système de locale (`fr` aujourd'hui,
- extensible). Les composants n'écrivent jamais de texte d'erreur en dur.
+- **`packages/shared`** — le contrat : `ErrorCode` (énumération **numérique** de
+ tous les codes d'erreur métier) et `ApiErrorResponse` (forme JSON de toute
+ réponse d'erreur de l'API). Ni l'API ni le web ne définissent leur propre liste
+ de codes, et aucune valeur n'est jamais codée en dur ailleurs (toujours
+ `ErrorCode.XXX`, jamais un nombre/une chaîne littérale).
+- **`packages/express-tools`** — package séparé pour l'outillage Express générique
+ (réutilisable par n'importe quel service Express du monorepo, pas seulement
+ `apps/api`) : `HttpError`, `ErrorHandlerService`, `createErrorMiddleware`.
+- **`apps/api`** — consomme `express-tools` : lève des `HttpError`, le middleware
+ d'erreur final n'est qu'un appel à `createErrorMiddleware(errorHandlerService)`.
+- **`apps/web` → `ErrorMessageService`** — associe chaque `ErrorCode` à une clé de
+ traduction, résolue via **i18next** (fichiers de locale sous `src/locales/`).
+ Les composants n'écrivent jamais de texte d'erreur en dur.
```mermaid
flowchart LR
+ subgraph TOOLS["packages/express-tools"]
+ HTTPERR["HttpError"]
+ EHS["ErrorHandlerService.handle()"]
+ MW["createErrorMiddleware()"]
+ end
+
subgraph API["apps/api"]
THROW["Route / service throw new HttpError(status, code, message)"]
- EHS["ErrorHandlerService.handle()"]
THROW --> EHS
+ MW -->|"app.use(...)"| EHS
end
EHS -->|"JSON: { code, message, details? }"| HTTP["Réponse HTTP"]
@@ -33,18 +42,20 @@ flowchart LR
subgraph WEB["apps/web"]
CLIENT["ApiClient lève ApiError(status, code, ...)"]
EMS["ErrorMessageService.getLabel(code)"]
+ I18N["i18next locales/fr/translation.json"]
UI["Composant (LoginPage, SignupPage...)"]
- CLIENT --> EMS --> UI
+ CLIENT --> EMS --> I18N --> UI
end
HTTP --> CLIENT
- SHARED[("packages/shared ErrorCode, ApiErrorResponse")]
+ SHARED[("packages/shared ErrorCode (numérique), ApiErrorResponse")]
SHARED -. contrat .-> THROW
SHARED -. contrat .-> CLIENT
SHARED -. contrat .-> EMS
style SHARED fill:none,stroke:#888,stroke-width:1px
+ style TOOLS fill:none,stroke:#888,stroke-width:1px
```
---
@@ -53,12 +64,12 @@ flowchart LR
```ts
enum ErrorCode {
- VALIDATION_ERROR,
- EMAIL_ALREADY_IN_USE,
- INVALID_CREDENTIALS,
- NOT_AUTHENTICATED,
- NOT_FOUND,
- INTERNAL_ERROR,
+ VALIDATION_ERROR = 4000,
+ EMAIL_ALREADY_IN_USE = 4001,
+ INVALID_CREDENTIALS = 4010,
+ NOT_AUTHENTICATED = 4011,
+ NOT_FOUND = 4040,
+ INTERNAL_ERROR = 5000,
}
interface ApiErrorResponse {
@@ -68,45 +79,75 @@ interface ApiErrorResponse {
}
```
+**Codes numériques, groupés par famille** (comme les codes HTTP) : `4000`–`4099`
+validation, `4010`–`4019` authentification, `4040`–`4049` ressource introuvable,
+`5000`–`5099` interne. Le numéro donne une indication de la catégorie même sans
+regarder l'enum.
+
**Règle** : `message` est destiné aux logs/au débogage (toujours en anglais, jamais
localisé). Le texte affiché à l'utilisateur vient **toujours** de
`ErrorMessageService.getLabel(code)` côté client, jamais de `message` directement.
+Et **aucune valeur `ErrorCode` n'est jamais écrite en dur** (ni en nombre, ni en
+chaîne) — toujours une référence `ErrorCode.XXX`, y compris dans les tests/mocks.
Pour ajouter un nouveau cas d'erreur :
-1. Ajouter le membre dans `ErrorCode`.
+1. Ajouter le membre dans `ErrorCode`, dans la bonne plage numérique.
2. Le lever via `new HttpError(status, ErrorCode.XXX, "message dev-facing")`.
-3. Ajouter sa traduction dans `ErrorMessageService.LABELS.fr`.
+3. Ajouter sa traduction dans **chaque** fichier `apps/web/src/locales/*/translation.json`, sous `errors.XXX`.
---
+## `packages/express-tools` — outillage Express générique
+
+Séparé d'`apps/api` volontairement : ce sont des briques génériques (n'importe quel
+service Express du monorepo pourrait les utiliser), pas de logique métier.
+
+- **`http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP) et
+ `code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
+ construire une réponse HTTP à la main.
+- **`error-handler.service.ts`** — `ErrorHandlerService` : un seul point qui sait
+ transformer n'importe quelle erreur JS (`ZodError`, `HttpError`, n'importe quoi
+ d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`, 500) logue
+ l'erreur côté serveur sans jamais exposer de détail interne au client.
+- **`error-middleware.ts`** — `createErrorMiddleware(service)` : construit le
+ middleware d'erreur Express (signature à 4 arguments) à partir du service —
+ adaptateur fin, aucune logique de mapping n'y vit.
+
+Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé,
+pas en TS brut — voir la note dans
+[frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
+sur pourquoi ça compte pour un runtime Node pur (Docker).
+
## Côté API (`apps/api`)
-- **`lib/http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP)
- et `code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
- construire une réponse HTTP à la main.
-- **`services/error-handler.service.ts`** — `ErrorHandlerService` : un seul point
- qui sait transformer n'importe quelle erreur JS (`ZodError`, `HttpError`,
- n'importe quoi d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`,
- 500) logue l'erreur côté serveur sans jamais exposer de détail interne au client.
-- **`app.ts`** — le middleware d'erreur final d'Express ne fait qu'appeler
- `errorHandlerService.handle(err)` et renvoyer le résultat ; aucune logique de
- mapping n'y vit directement.
+- **`app.ts`** — le middleware d'erreur final est
+ `app.use(createErrorMiddleware(errorHandlerService))` ; aucune logique de
+ mapping n'y vit directement, tout est dans `express-tools`.
+- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
+ importent `HttpError` depuis `@batch-cooking/express-tools` et `ErrorCode` depuis
+ `@batch-cooking/shared`.
## Côté Web (`apps/web`)
- **`api/client.ts`** — `ApiClient` : lève `ApiError` (porteur de `status`, `code`,
`fieldErrors`) pour toute réponse non-2xx.
-- **`services/error-message.service.ts`** — `ErrorMessageService` : associe chaque
- `ErrorCode` à un libellé, par locale (`Record>`).
- Une seule langue existe aujourd'hui (`fr`), mais la structure est prête pour en
- ajouter une deuxième sans toucher aux composants.
+- **`services/error-message.service.ts`** — `ErrorMessageService` : convertit le
+ `ErrorCode` numérique reçu en nom de membre (`ErrorCode[code]`, ex. `4001` →
+ `"EMAIL_ALREADY_IN_USE"`), puis délègue la traduction à **i18next**
+ (`i18n.t(\`errors.${memberName}\`)`). N'a pas sa propre table de libellés — c'est
+ i18next + les fichiers de locale qui la portent.
+- **`i18n/i18n.ts`** + **`locales/fr/translation.json`** — configuration et
+ ressources i18next. Ajouter une langue = ajouter une entrée `resources.`
+ pointant vers un nouveau fichier de locale, sans toucher un seul composant.
- Les pages (`LoginPage`, `SignupPage`) attrapent `ApiError`, récupèrent `err.code`,
et appellent `errorMessageService.getLabel(err.code)` pour l'afficher — jamais
`err.message`.
## Validation côté formulaire (distincte du contrat d'erreurs API)
-Les schémas zod partagés (`packages/shared/src/schemas/auth.ts`) portent déjà des
-messages en français, utilisés pour la validation **avant** l'appel réseau (retour
-instantané, aucun aller-retour serveur). C'est un mécanisme séparé du contrat
-`ErrorCode` : ces messages ne quittent jamais le navigateur.
+Les schémas zod partagés (`packages/shared/src/schemas/auth.ts`) portent leurs
+propres messages en français, utilisés pour la validation **avant** l'appel réseau
+(retour instantané, aucun aller-retour serveur). C'est un mécanisme séparé du
+contrat `ErrorCode`/i18next : ces messages ne quittent jamais le navigateur, et ne
+vivent pas dans les fichiers de locale (ils sont dans `packages/shared`, consommé
+aussi par l'API qui ne dépend pas d'i18next).
diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md
index 46f5913..81cef66 100644
--- a/specs/frontend-architecture.md
+++ b/specs/frontend-architecture.md
@@ -11,8 +11,12 @@
apps/web/src/
├── api/
│ └── client.ts # ApiClient — appels fetch vers l'API (voir error-handling.md)
+├── i18n/
+│ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord
+├── locales/
+│ └── fr/translation.json # libellés français (errors.*, auth.*, home.*)
├── services/
-│ └── error-message.service.ts # ErrorMessageService — libellés d'erreur i18n
+│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
├── features/
│ └── auth/ # tout ce qui concerne l'authentification
│ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout)
@@ -29,7 +33,7 @@ apps/web/src/
├── lib/
│ └── zod-errors.ts # utilitaire : erreurs zod → { champ: message }
├── App.tsx # table de routes
-└── main.tsx # point d'entrée : providers (Router, AuthProvider) + import du CSS global
+└── main.tsx # point d'entrée : providers (Router, AuthProvider) + imports i18n/CSS globaux
```
**Règle de placement des styles** : un style spécifique à un seul composant/page vit
@@ -82,9 +86,40 @@ partagé avec l'API. En résumé côté frontend :
le cookie de session httpOnly parte/revienne, l'API et le web étant sur des
origines différentes). Lève `ApiError` (porteuse du `code` d'erreur) pour toute
réponse non-2xx.
-- `ErrorMessageService` (`services/error-message.service.ts`) — traduit un `code`
- d'erreur en libellé affichable, avec support de locale (`fr` uniquement pour
- l'instant).
+- `ErrorMessageService` (`services/error-message.service.ts`) — convertit un `code`
+ d'erreur numérique en clé de traduction, résolue via i18next.
+
+---
+
+## i18n (internationalisation)
+
+**i18next** + **react-i18next** — pas de solution maison : tout le texte affiché
+(libellés de formulaire, boutons, messages d'erreur) vient de fichiers de locale
+JSON, jamais codé en dur dans un composant.
+
+- `i18n/i18n.ts` — initialise l'instance i18next (langue par défaut `fr`), importé
+ une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu.
+- `locales/fr/translation.json` — toutes les chaînes françaises, organisées par
+ namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
+ `auth.login.*` / `auth.signup.*`, `home.*`.
+- Dans un composant : `const { t } = useTranslation(); t("auth.login.title")`.
+- Ajouter une langue : créer `locales//translation.json` avec les mêmes clés,
+ ajouter `resources.` dans `i18n/i18n.ts` — aucun composant à toucher.
+
+---
+
+## Note sur les fichiers `.d.ts`
+
+Aucun fichier `.d.ts` écrit à la main dans `apps/web` : le
+`/// ` généré par défaut par Vite (habituellement
+`vite-env.d.ts`) est remplacé par `"types": ["vite/client"]` dans
+`tsconfig.app.json` — même effet (typage de `import.meta.env`, imports d'assets),
+sans fichier dédié.
+
+Même logique côté `apps/api` : l'augmentation du type `Express.Request` (pour
+`req.userProfile`) vit dans `src/types/express-request.augment.ts`, un fichier
+`.ts` classique (pas `.d.ts`) — une augmentation `declare global` fonctionne
+identiquement dans les deux, tant que le fichier a un import qui en fait un module.
---