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 (

batchCooking

-

- Bonjour {user?.firstName} {user?.lastName} 👋 -

+

{t("home.greeting", { firstName: user?.firstName, lastName: user?.lastName })}

); 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 (
-

Se connecter

+

{t("auth.login.title")}

- + {fieldErrors.email &&

{fieldErrors.email}

} - + {formError}

}

- 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 (
-

Créer un profil

+

{t("auth.signup.title")}

- + {fieldErrors.firstName &&

{fieldErrors.firstName}

} - + {fieldErrors.lastName &&

{fieldErrors.lastName}

} - + {fieldErrors.email &&

{fieldErrors.email}

} - + {formError}

}

- Déjà un compte ? Se connecter + {t("auth.signup.hasAccount")} {t("auth.signup.loginLink")}

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