Centralize error handling + code quality pass (comments, SCSS theming) (#7)

* Centralize error handling (shared codes + API/client services), code quality pass

## Error handling

Requested: a centralized error-handling service on the API, custom error
codes shared across apps, and a client-side error service for i18n labels.

- packages/shared/src/errors/error-codes.ts — ErrorCode enum + ApiErrorResponse
  contract. Single source of truth: neither side hardcodes a raw error string
  the other has to guess at.
- apps/api: HttpError now carries an ErrorCode (not just a message).
  ErrorHandlerService (new) centralizes every "how do we turn a thrown error
  into an HTTP response" decision — app.ts's error middleware is now a thin
  adapter calling into it. API messages reverted to English/dev-facing (they
  were French from an earlier pass) since user-facing text is now generated
  client-side from the code.
- apps/web: ApiClient (class, singleton instance) throws ApiError carrying
  the code. ErrorMessageService (new) maps every ErrorCode to a localized
  label, structured with a Locale type from the start (only "fr" exists, but
  adding a language later is "add a locale to the map", not "hunt down every
  hardcoded string"). LoginPage/SignupPage now display
  errorMessageService.getLabel(err.code), never err.message directly.
- Tests strengthened to assert on `code`, not just HTTP status (Mocha +
  Cucumber, new "the response error code should be" step). Cypress mocks
  updated to the new {code, message} response shape.

## Code quality pass

Per explicit feedback: heavy JSDoc on every interface/type/class/function/
method/member touched in this PR, explicit public/private visibility on
every class member (ApiClient, ErrorMessageService, ErrorHandlerService,
HttpError), no HTML/logic mixing (styling extracted out of components
entirely, never inline).

ApiClient/ErrorMessageService were initially written as static-only classes;
switched to instance-based singletons (matching ErrorHandlerService's
existing pattern) after Biome's noStaticOnlyClass rule flagged the
static-only shape as an anti-pattern — same "class with visibility
modifiers" outcome, without fighting the linter.

## SCSS + theming

- apps/web/src/styles/_theme.scss — design tokens as CSS custom properties
  on :root (colors, spacing, typography), not plain Sass variables — makes
  them available at runtime, not just compile time, so a future theme
  switch (e.g. dark mode) is "redefine these variables" rather than
  rebuilding stylesheets.
- apps/web/src/styles/global.scss replaces the old single index.css:
  reset + theme import only, loaded once from main.tsx.
- Per-page/component styles colocated (HomePage.tsx + HomePage.scss);
  styles shared by multiple pages within one feature live in that feature's
  folder (features/auth/auth-form.scss, used by both Login/SignupPage) —
  not duplicated per page, not dumped in the global stylesheet either.
- Component-level .scss files intentionally don't `@use` the theme
  partial: they only consume CSS custom properties (global at runtime via
  global.scss), not Sass-level symbols, so importing it would do nothing —
  documented inline rather than left as a silently-redundant import.
- vite.config.ts opts into Sass's modern compiler API to silence a
  legacy-js-api deprecation warning on every build.

## specs/ updates

- New specs/error-handling.md — the ErrorCode/ApiErrorResponse contract,
  both services, with a flow diagram.
- New specs/frontend-architecture.md — apps/web folder structure, routing/
  auth-guard flow, SCSS/theming conventions.
- specs/batch-cooking-architecture.md links to both (original doc content
  otherwise untouched — it's the user's own hand-authored source doc).

## Verification

Full lint/mocha/cucumber/build green. Manually re-verified the whole auth
flow in a real browser against native dev servers (not just the automated
suites): signup, the EMAIL_ALREADY_IN_USE → "Cet email est déjà utilisé"
translation end-to-end (confirmed the raw API response carries the English
dev message + code, and the UI shows the French label), wrong-password
INVALID_CREDENTIALS → its label, and confirmed the theme tokens actually
apply (computed button background-color matches --color-primary, card
max-width matches the token value) rather than trusting the build succeeding.

* Address review: no .d.ts, express-tools package, faker fixtures, numeric codes, real i18n lib

Five explicit review points, addressed on this same PR branch (not a new
PR) per updated preference.

## No .d.ts files in the codebase

- apps/web: vite-env.d.ts removed — its /// <reference types="vite/client" />
  is replaced by "types": ["vite/client"] in tsconfig.app.json, same effect.
- apps/api: src/types/express.d.ts renamed to express-request.augment.ts —
  `declare global` module augmentation works identically in a plain .ts
  file as long as it has a top-level import (making it a module); the
  .d.ts extension wasn't doing anything for us here.

## packages/express-tools — separate package for Express tooling

Moved HttpError and ErrorHandlerService out of apps/api into a new
workspace package, plus a new createErrorMiddleware() factory (the actual
Express 4-arg error-handling middleware, previously inlined in app.ts).
apps/api now just consumes @batch-cooking/express-tools. Has a real build
(tsc -> dist/, same pattern as packages/shared) — required for the same
reason shared needed one: apps/api's Docker image runs plain `node
dist/server.js`, no tsx. apps/api/Dockerfile updated to COPY the new
package's dist alongside shared's.

## faker.js for test fixtures

apps/api/test/auth.test.ts: replaced the hardcoded "Nicolas
Lefevre"/nicolas@example.com fixture (looked like real user data) with
@faker-js/faker, generated fresh per test via buildSignupPayload().
features/step-definitions/auth.steps.ts: fakerized the filler
firstName/lastName/password used for background state the scenarios
don't actually read.

Deliberately did NOT fakerize the literal example values inside
auth.feature itself (alice@example.com etc.) — those are the readable,
illustrative Gherkin examples that are the whole point of BDD scenarios,
not real PII, and randomizing them would make the scenarios harder to
read for no real gain. Flagged this reasoning in the README in case that
call should go the other way.

Caught a real bug while wiring this up: faker.internet.email() sometimes
capitalizes parts of the address, but signupSchema/loginSchema normalize
emails to lowercase — the test fixture needs to match what's actually
stored, so buildSignupPayload() lowercases the generated email too.
Found by actually running the suite repeatedly, not just once.

## ErrorCode: numeric enum, zero hardcoded values

packages/shared/src/errors/error-codes.ts: ErrorCode is now a numeric
enum (4000 VALIDATION_ERROR, 4001 EMAIL_ALREADY_IN_USE, 4010
INVALID_CREDENTIALS, 4011 NOT_AUTHENTICATED, 4040 NOT_FOUND, 5000
INTERNAL_ERROR — grouped by family like HTTP status codes).

Audited and fixed every place that hardcoded a raw code value instead of
referencing the enum: ApiClient's fallback (`"INTERNAL_ERROR" as
ErrorCode` — would no longer even type-check once the enum went numeric,
which is exactly the point), and the Cypress mock bodies (now import
ErrorCode from @batch-cooking/shared instead of typing the string).

Cucumber's "the response error code should be {string}" step still takes
the *name* in the .feature file (readable: "EMAIL_ALREADY_IN_USE") and
resolves it to the real numeric value via ErrorCode[name] — TypeScript's
reverse enum mapping — before comparing, so the Gherkin stays readable
without the step hardcoding a number either.

## Real i18n library (i18next), not a hand-rolled label map

apps/web: added i18next + react-i18next. New locales/fr/translation.json
holds every user-facing string — not just error labels (errors.*), but
the login/signup/home pages' labels, buttons and headings too
(auth.login.*, auth.signup.*, home.*) — via useTranslation()/t() in each
page. ErrorMessageService no longer owns its own label map; it converts
the numeric ErrorCode to its enum member name and delegates the actual
lookup to i18next (errors.<MEMBER_NAME>). Adding a language is now
"add a locale file", not a code change anywhere.

## specs/ and README updated

specs/error-handling.md and specs/frontend-architecture.md rewritten for
the new package, numeric codes, and i18next. New "i18n" and "no .d.ts"
sections. README covers the same, plus a note on the faker.js scope
decision (feature-file literals excluded, on purpose).

## Verification

Full lint/mocha (x3 runs)/cucumber/build green. Re-verified
express-tools' extraction against a real risk (not just tsc passing):
ran `node dist/server.js` standalone (mirrors the Docker runtime, no
tsx) and hit /health, a 404 (confirmed numeric code 4040 over the wire),
and a real signup + duplicate-email 409 (confirmed numeric 4001). Then
re-verified the full pipeline in a real browser against native dev
servers: signup, EMAIL_ALREADY_IN_USE -> i18next -> "Cet email est déjà
utilisé" end-to-end, home page i18next interpolation
({{firstName}}/{{lastName}}) rendering correctly.

* Address second review round: interface comments, res.locals, ExpressServer, assertIsNever

Five more explicit review points, on the same PR branch.

## Every interface key commented

Audited all 6 interfaces in the codebase. Two had partially-commented
members (violates the "every key gets /** */" rule): AuthResult
(apps/api/auth.service.ts) and SafeUserProfile (packages/shared) — both
now fully commented. The other four (AuthTokenPayload,
AuthContextValue, ErrorHandlingResult, ApiErrorResponse) were already
compliant.

## Removed the Express namespace augmentation

apps/api/src/types/express.d.ts (renamed to express-request.augment.ts
in the last round) is gone entirely. requireAuth now attaches the
authenticated profile to `res.locals.userProfile` — Express's own
built-in per-request mechanism for exactly this — typed via a new
AuthLocals interface and `Response<unknown, AuthLocals>`, instead of a
project-wide `declare global` silently changing every Request's type
whether or not it went through the middleware.

## ErrorHandlerService confirmed framework-agnostic

It already had zero Express import. Documented this explicitly (in the
package's index.ts and the new backend-architecture.md spec) as a
deliberate split: ErrorHandlerService is framework-agnostic (would work
behind Fastify too), ExpressServer/createErrorMiddleware are the actual
Express integration layer.

## packages/express-tools: server init + route/middleware utilities

New ExpressServer class, modeled on the pattern shared as a reference
(adapted, not copied 1:1 — deliberately left out the reference's custom
runtime param-type-validation system, since zod already does that job
in this codebase and running two parallel validation mechanisms would
be redundant, not "propre"):
- setupCore() — the common cors/json/cookie-parser stack
- addRoute() — registers a route, warns+skips instead of silently
  double-registering the same method+path
- addMiddleware() / mountRouter() / setErrorHandler()
- listen()
- .instance — the raw Express app, for supertest

Also added wrapAsyncHandler() — forwards a thrown/rejected error from an
async handler to next(err) automatically, removing the manual
try/catch/next(err) every route needed.

apps/api/src/app.ts now builds via ExpressServer (createServer(),
consumed by both server.ts's .listen() and createApp()'s .instance for
tests). auth.routes.ts's signup/login handlers use wrapAsyncHandler
instead of manual try/catch. cookie-parser/cors moved out of apps/api's
own dependencies entirely — they're express-tools' concern now.

## assertIsNever (packages/shared/src/tools/)

Exhaustiveness-check helper for switch/if-chains over a union: takes a
`never`-typed value and throws, so a forgotten case in a later-added
union member becomes a compile error instead of a silent runtime
fallthrough. Verified for real (not just written and assumed correct):
wrote a throwaway switch missing a case and confirmed `tsc` rejects it
with the exact expected error, then deleted the scratch file. No
existing switch/if-chain over a union in the codebase yet to retrofit
it into — noted as ready for when one appears (e.g. the not-yet-built
batch-cooking calculation module or recipe-import pipeline).

## specs/ updated

New specs/backend-architecture.md — ExpressServer, wrapAsyncHandler,
the res.locals decision (with the "why not declare global" reasoning
spelled out), assertIsNever. error-handling.md and
frontend-architecture.md cross-link to it instead of duplicating.
README covers the same, briefly.

## Verification

Full lint/mocha/cucumber/build green. Re-ran `node dist/server.js`
standalone (mirrors Docker, no tsx) after the ExpressServer refactor:
/health, a 404 (numeric 4040), and a real signup + GET /me round trip
confirming res.locals-based auth actually works at runtime, not just
that tsc accepts the types.

* refactor: move ErrorHandlerService/HttpError out of express-tools

ErrorHandlerService has zero dependency on Express — it's a plain
"map an error to {status, body}" service that works identically
behind any HTTP framework. It had no business living in a package
named express-tools.

Extracted HttpError, ErrorHandlerService, and ErrorHandlingResult
into a new packages/error-tools package (same tsc-build-to-dist
pattern as shared/express-tools). express-tools now only keeps the
actual Express-specific layer: ExpressServer, wrapAsyncHandler, and
createErrorMiddleware (which adapts ErrorHandlerService, imported
from error-tools, onto Express).

- packages/error-tools: new package, depends on shared + zod
- packages/express-tools: drops zod dependency, adds error-tools
  dependency for error-middleware.ts's type import
- apps/api: adds error-tools dependency; app.ts, auth.service.ts,
  require-auth.ts now import HttpError/errorHandlerService from
  error-tools instead of express-tools
- apps/api/Dockerfile: adds COPY for packages/error-tools in the
  runtime stage
- specs/error-handling.md, specs/backend-architecture.md, README.md
  updated to reflect the new package split

Verified: pnpm lint, pnpm build (all packages, correct dependency
order), pnpm test (9/9 Mocha), pnpm test:bdd (5/5 Cucumber), full
Docker rebuild + compose up (no crash-loop), curl + browser checks
of /health, unknown-route 404, signup (201), duplicate-email 409
(code 4001 EMAIL_ALREADY_IN_USE) — all going through the moved
ErrorHandlerService/HttpError correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kyuno053 2026-08-16 18:40:53 +02:00 committed by GitHub
parent 3ad854a269
commit de63be7ba4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 2141 additions and 295 deletions

View file

@ -8,8 +8,23 @@ 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`) et types (`SafeUserProfile`) — même règles de validation des deux côtés,
pas de risque de dérive entre front et back.
`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/error-tools` — gestion des erreurs, **indépendante de tout framework
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
[specs/error-handling.md](specs/error-handling.md).
- `packages/express-tools` — outillage Express générique et réutilisable : `ExpressServer`
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
`packages/shared`, `packages/error-tools` 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
@ -155,20 +170,69 @@ provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
## Page de connexion / inscription (apps/web)
- `src/api/client.ts` — client fetch vers l'API (`credentials: "include"`, requis pour
que le cookie de session httpOnly parte/revienne — l'API et le front sont sur des
origines différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
- `src/api/client.ts``ApiClient` (classe, instance unique exportée `apiClient`) :
enveloppe `fetch` vers l'API (`credentials: "include"`, requis pour que le cookie
de session httpOnly parte/revienne — l'API et le front sont sur des origines
différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET /auth/me` au
chargement pour restaurer la session depuis le cookie.
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes de route
(react-router-dom) : `/` exige d'être connecté, `/login` et `/signup` redirigent vers
`/` si on l'est déjà.
- `src/pages/{Login,Signup,Home}Page.tsx` — validation client instantanée via les
schémas zod partagés (`packages/shared`), erreurs API affichées telles quelles
(messages déjà en français côté serveur).
schémas zod partagés (`packages/shared`), erreurs API traduites via
`ErrorMessageService` (voir ci-dessous).
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
[specs/frontend-architecture.md](specs/frontend-architecture.md).
Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` mockent l'API via
`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une vraie
base).
## Gestion des erreurs (API ↔ web)
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`
(`packages/error-tools`) 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).
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
voir [specs/backend-architecture.md](specs/backend-architecture.md) pour le détail
et le pourquoi.
`packages/shared` fournit aussi `assertIsNever` (vérification d'exhaustivité de
switch/if-chain sur une union, erreur de **compilation** si un cas est oublié) —
voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshared--assertisnever).
## i18n
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/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.

View file

@ -24,6 +24,8 @@ 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/error-tools ./packages/error-tools
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

View file

@ -20,6 +20,7 @@ Feature: Account creation and login
| email | alice@example.com |
| password | correct-horse-battery-staple |
Then the response status should be 409
And the response error code should be "EMAIL_ALREADY_IN_USE"
Scenario: A registered user logs in with correct credentials
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
@ -31,3 +32,4 @@ Feature: Account creation and login
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
When I log in with email "alice@example.com" and password "wrong-password"
Then the response status should be 401
And the response error code should be "INVALID_CREDENTIALS"

View file

@ -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,
});
},
);

View file

@ -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";
@ -14,3 +15,17 @@ Then("the response status should be {int}", function (this: CustomWorld, status:
Then("the response body should be:", function (this: CustomWorld, expectedJson: string) {
assert.deepEqual(this.response.body, JSON.parse(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) {
const expected = ErrorCode[code as keyof typeof ErrorCode];
assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`);
assert.equal(this.response.body.code, expected);
});

View file

@ -14,11 +14,11 @@
"postinstall": "prisma generate"
},
"dependencies": {
"@batch-cooking/error-tools": "workspace:*",
"@batch-cooking/express-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"@prisma/client": "^5.22.0",
"argon2": "0.31.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.3",
@ -26,8 +26,7 @@
},
"devDependencies": {
"@cucumber/cucumber": "^13.2.1",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@faker-js/faker": "^10.6.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.9.0",

View file

@ -1,42 +1,49 @@
import cookieParser from "cookie-parser";
import cors from "cors";
import express, { type NextFunction, type Request, type Response } from "express";
import { ZodError } from "zod";
import { errorHandlerService } from "@batch-cooking/error-tools";
import { ExpressServer, createErrorMiddleware } from "@batch-cooking/express-tools";
import { ErrorCode } from "@batch-cooking/shared";
import type { Express, Request, Response } from "express";
import { env } from "./config/env.js";
import { HttpError } from "./lib/http-error.js";
import { authRouter } from "./modules/auth/auth.routes.js";
// Application factory. Feature modules are added under src/modules/* as
// specs land; auth is the first one (login page / profile creation).
export function createApp() {
const app = express();
/**
* Builds the API's `ExpressServer`: standard middleware, routes, and the
* final error handler, in that order. Returns the `ExpressServer` wrapper
* (not just the raw Express app) so `server.ts` can call `.listen()` on
* it {@link createApp} below is the thinner entry point that exposes
* just the raw `Express` instance, for test tooling (supertest) that
* expects one.
*/
export function createServer(): ExpressServer {
const server = new ExpressServer();
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }));
app.use(express.json());
app.use(cookieParser());
app.get("/health", (_req: Request, res: Response) => {
server.addRoute("get", "/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "ok" });
});
app.use("/auth", authRouter);
server.mountRouter("/auth", authRouter);
app.use((_req: Request, res: Response) => {
res.status(404).json({ error: "Ressource introuvable" });
// No route matched — same shape as every other error response, via the
// shared ErrorCode contract, so clients never special-case 404s.
server.addMiddleware((_req: Request, res: Response) => {
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
});
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof ZodError) {
res.status(400).json({ error: "Erreur de validation", details: err.flatten() });
return;
}
if (err instanceof HttpError) {
res.status(err.status).json({ error: err.message });
return;
}
console.error(err);
res.status(500).json({ error: "Erreur interne du serveur" });
});
// 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, from @batch-cooking/error-tools
// — this stays a thin adapter.
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
return app;
return server;
}
/**
* Builds a fresh Express application instance (no shared mutable state
* between calls used both indirectly by the real server entrypoint
* (`server.ts`, via {@link createServer}) and directly by tests, which
* each get their own app via supertest).
*/
export function createApp(): Express {
return createServer().instance;
}

View file

@ -1,17 +1,30 @@
import "dotenv/config";
import { z } from "zod";
/**
* Schema for every environment variable the API reads. Parsing (below)
* fails fast at startup if something required is missing/invalid, instead
* of surfacing as a confusing runtime error later.
*/
const envSchema = z.object({
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
/** Port the HTTP server listens on. */
PORT: z.coerce.number().int().positive().default(3000),
/** Postgres connection string, consumed by Prisma. */
DATABASE_URL: z.string().url().optional(),
// Auth — no default on purpose, same reasoning as docker-compose.yml's
// POSTGRES_USER/PASSWORD: a secret must never have a working fallback
// baked into committed code.
/** Secret used to sign/verify session JWTs. Required, no default — see comment above. */
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
/** JWT expiry, in `jsonwebtoken`'s duration string format (e.g. "7d"). */
JWT_EXPIRES_IN: z.string().default("7d"),
/** Name of the httpOnly cookie carrying the session JWT. */
AUTH_COOKIE_NAME: z.string().default("session"),
/** Origin allowed by CORS — must match wherever apps/web is served from. */
CORS_ORIGIN: z.string().default("http://localhost:5173"),
});
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
export const env = envSchema.parse(process.env);

View file

@ -1,5 +1,9 @@
import { PrismaClient } from "@prisma/client";
// Single shared instance — Prisma manages its own connection pool
// internally, a new PrismaClient per request would exhaust connections.
/**
* Single shared Prisma client instance for the whole process. Prisma
* manages its own connection pool internally instantiating a new
* `PrismaClient` per request would exhaust database connections instead of
* reusing them.
*/
export const prisma = new PrismaClient();

View file

@ -1,11 +0,0 @@
/** Typed error carrying the HTTP status it should map to, so the central
* error handler in app.ts can respond correctly instead of always 500ing. */
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
}
}

View file

@ -1,11 +1,19 @@
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
/** Decoded contents of a session JWT, once verified. */
export interface AuthTokenPayload {
/** UserProfile.id this token authenticates. */
userProfileId: number;
/** Snapshot of UserProfile.tokenVersion at sign time — checked against the current DB value on every request (see requireAuth) to allow server-side invalidation. */
tokenVersion: number;
}
/**
* Signs a new session JWT for the given profile, expiring per
* `JWT_EXPIRES_IN`. The resulting string is what gets set as the session
* cookie's value.
*/
export function signAuthToken(payload: AuthTokenPayload): string {
// "sub" follows the JWT convention (RFC 7519) of identifying the
// principal as a string; userProfileId/tokenVersion are our own claims.
@ -16,6 +24,13 @@ export function signAuthToken(payload: AuthTokenPayload): string {
);
}
/**
* Verifies a session JWT's signature/expiry and decodes it back into an
* {@link AuthTokenPayload}.
*
* @throws {Error} if the token is invalid/expired (from `jwt.verify`) or
* structurally malformed (missing/wrong-typed claims).
*/
export function verifyAuthToken(token: string): AuthTokenPayload {
const decoded = jwt.verify(token, env.JWT_SECRET);
const userProfileId = typeof decoded === "object" ? Number(decoded.sub) : Number.NaN;

View file

@ -1,35 +1,68 @@
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type SafeUserProfile } 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";
/** Reads the session cookie, verifies the JWT, and re-checks tokenVersion
* against the database (so a password change / logout-everywhere can
* invalidate previously-issued tokens despite JWT being stateless). */
export async function requireAuth(req: Request, _res: Response, next: NextFunction) {
/**
* Shape of `res.locals` once {@link requireAuth} has run successfully. Type
* a route handler's response as `Response<unknown, AuthLocals>` (see
* `auth.routes.ts`'s `/me` handler) to read `res.locals.userProfile` fully
* typed, no cast needed.
*/
export interface AuthLocals {
/** The authenticated profile, resolved from the session cookie's JWT. */
userProfile: SafeUserProfile;
}
/**
* Express middleware guarding routes that require an authenticated
* profile. Reads the session cookie, verifies the JWT, and re-checks
* `tokenVersion` against the database so a stateless JWT can still be
* invalidated server-side (e.g. on password change / logout-everywhere,
* once that feature exists) despite carrying no server-side session.
*
* On success, attaches the resolved profile to `res.locals.userProfile`
* (typed via {@link AuthLocals}) for downstream handlers to use.
* Deliberately `res.locals` rather than augmenting Express's global
* `Request` type via `declare global`: `res.locals` is Express's own
* built-in mechanism for exactly this (passing data from a middleware to
* the next handler), typed per-route through a generic parameter no
* project-wide ambient augmentation silently changing every `Request` in
* the codebase, whether or not it went through this middleware.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure missing
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
* Never distinguishes the reason to the client.
*/
export async function requireAuth(
req: Request,
res: Response<unknown, AuthLocals>,
next: NextFunction,
) {
try {
const token = req.cookies?.[env.AUTH_COOKIE_NAME];
if (typeof token !== "string") {
throw new HttpError(401, "Non authentifié");
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
const payload = verifyAuthToken(token);
const profile = await prisma.userProfile.findUnique({ where: { id: payload.userProfileId } });
if (!profile || profile.tokenVersion !== payload.tokenVersion) {
throw new HttpError(401, "Non authentifié");
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
const { passwordHash: _passwordHash, ...safeProfile } = profile;
req.userProfile = safeProfile;
res.locals.userProfile = safeProfile;
next();
} catch (err) {
if (err instanceof HttpError) {
next(err);
} else {
// Covers jwt.verify failures (expired/invalid/malformed token).
next(new HttpError(401, "Non authentifié"));
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
}
}
}

View file

@ -1,51 +1,62 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { loginSchema, signupSchema } from "@batch-cooking/shared";
import { Router } from "express";
import type { CookieOptions } from "express";
import type { CookieOptions, Response } from "express";
import { env } from "../../config/env.js";
import { requireAuth } from "../../middlewares/require-auth.js";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { login, signup } from "./auth.service.js";
/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
export const authRouter = Router();
// Independent from JWT_EXPIRES_IN on purpose (see auth.routes.ts) — the JWT's
// own expiry is what's actually enforced by requireAuth, this only bounds
// how long the browser keeps sending the cookie.
// Deliberately independent from JWT_EXPIRES_IN: the JWT's own expiry is
// what's actually enforced by requireAuth (a request with an expired JWT
// is rejected regardless of the cookie still being present) — this only
// bounds how long the browser keeps *sending* the cookie at all.
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/** Cookie options shared by every route that sets/clears the session cookie. */
const cookieOptions: CookieOptions = {
httpOnly: true,
// Only require HTTPS in production — local dev/CI serve over plain HTTP.
secure: env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SEVEN_DAYS_MS,
};
authRouter.post("/signup", async (req, res, next) => {
try {
/**
* Creates a profile (+ its household) and logs the new user in
* immediately. `wrapAsyncHandler` forwards a thrown/rejected error to
* Express's error middleware automatically no manual try/catch needed.
*/
authRouter.post(
"/signup",
wrapAsyncHandler(async (req, res) => {
const input = signupSchema.parse(req.body);
const { profile, token } = await signup(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(201).json(profile);
} catch (err) {
next(err);
}
});
}),
);
authRouter.post("/login", async (req, res, next) => {
try {
/** Verifies credentials and starts a new session. */
authRouter.post(
"/login",
wrapAsyncHandler(async (req, res) => {
const input = loginSchema.parse(req.body);
const { profile, token } = await login(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(200).json(profile);
} catch (err) {
next(err);
}
});
}),
);
/** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */
authRouter.post("/logout", (_req, res) => {
res.clearCookie(env.AUTH_COOKIE_NAME, cookieOptions);
res.status(204).end();
});
authRouter.get("/me", requireAuth, (req, res) => {
res.status(200).json(req.userProfile);
/** Returns the currently authenticated profile. Behind requireAuth — 401s if there's no valid session. */
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
res.status(200).json(res.locals.userProfile);
});

View file

@ -1,13 +1,22 @@
import type { LoginInput, SignupInput } from "@batch-cooking/shared";
import { HttpError } from "@batch-cooking/error-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. */
type SafeProfile = Omit<UserProfile, "passwordHash">;
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
interface AuthResult {
/** The authenticated profile, safe to hand back to the client. */
profile: SafeProfile;
/** Signed session JWT — the caller sets this as the session cookie's value. */
token: string;
}
// argon2's defaults (64 MB memory, 3 passes) are deliberately expensive —
// that's the point, for real passwords. In tests we hash/verify dozens of
// times per run against throwaway data, so a much cheaper cost keeps the
@ -16,15 +25,22 @@ type SafeProfile = Omit<UserProfile, "passwordHash">;
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
/** Strips `passwordHash` off a Prisma UserProfile before it's ever sent to a client. */
function toSafeProfile(profile: UserProfile): SafeProfile {
const { passwordHash: _passwordHash, ...safeProfile } = profile;
return safeProfile;
}
export async function signup(input: SignupInput): Promise<{ profile: SafeProfile; token: string }> {
/**
* Creates a new household (`house`) and profile (`user_profiles`) together
* in one transaction, hashes the password, and issues a session token.
*
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
*/
export async function signup(input: SignupInput): Promise<AuthResult> {
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
if (existing) {
throw new HttpError(409, "Cet email est déjà utilisé");
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
}
const passwordHash = await argon2.hash(input.password, hashOptions);
@ -51,13 +67,18 @@ export async function signup(input: SignupInput): Promise<{ profile: SafeProfile
return { profile: toSafeProfile(profile), token };
}
export async function login(input: LoginInput): Promise<{ profile: SafeProfile; token: string }> {
/**
* Verifies credentials and issues a fresh session token.
*
* @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email
* or a wrong password deliberately the same error either way, so a
* caller can never learn whether a given email has an account.
*/
export async function login(input: LoginInput): Promise<AuthResult> {
const profile = await prisma.userProfile.findUnique({ where: { email: input.email } });
// Deliberately generic error/message for both "no such email" and "wrong
// password" — don't leak which one it was.
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
throw new HttpError(401, "Email ou mot de passe incorrect");
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
}
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });

View file

@ -1,8 +1,8 @@
import { createApp } from "./app.js";
import { createServer } from "./app.js";
import { env } from "./config/env.js";
const app = createApp();
const server = createServer();
app.listen(env.PORT, () => {
server.listen(env.PORT, () => {
console.log(`API listening on http://localhost:${env.PORT}`);
});

View file

@ -1,10 +0,0 @@
import type { UserProfile } from "@prisma/client";
declare global {
namespace Express {
interface Request {
/** Set by requireAuth once the session cookie's JWT has been verified. */
userProfile?: Omit<UserProfile, "passwordHash">;
}
}
}

View file

@ -1,15 +1,30 @@
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";
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();
@ -24,80 +39,96 @@ 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");
expect(res.headers["set-cookie"]?.[0]).to.include("session=");
});
it("rejects a duplicate email with 409", async () => {
await request(app).post("/auth/signup").send(validSignup);
const res = await request(app).post("/auth/signup").send(validSignup);
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
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", async () => {
const res = await request(app)
.post("/auth/signup")
.send({ firstName: "X", lastName: "Y", email: "not-an-email", password: "short" });
it("rejects an invalid payload with 400 VALIDATION_ERROR", async () => {
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);
expect(res.body.details).to.have.keys(["email", "password"]);
});
});
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", async () => {
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);
});
it("rejects an unknown email with 401", async () => {
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);
});
});
describe("GET /auth/me", () => {
it("rejects requests without a session cookie", async () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/auth/me");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
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);
});
});
});

View file

@ -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: { error: "Cet email est déjà utilisé" },
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: { error: "Email ou mot de passe incorrect" },
body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid email or password" },
}).as("login");
cy.visit("/login");

View file

@ -1,6 +1,11 @@
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: { error: "Non authentifié" } });
cy.intercept("GET", "**/auth/me", {
statusCode: 401,
body: { code: ErrorCode.NOT_AUTHENTICATED, message: "Not authenticated" },
});
cy.visit("/");
cy.url().should("include", "/login");
cy.contains("h1", "Se connecter").should("be.visible");

View file

@ -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"
},
@ -25,6 +27,7 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"cypress": "^13.15.2",
"sass": "^1.102.0",
"start-server-and-test": "^2.0.8",
"typescript": "^5.7.2",
"vite": "^5.4.11"

View file

@ -5,6 +5,12 @@ import { HomePage } from "./pages/HomePage";
import { LoginPage } from "./pages/LoginPage";
import { SignupPage } from "./pages/SignupPage";
/**
* Top-level route table. `/` requires an authenticated session (see
* {@link RequireAuth}); `/login` and `/signup` redirect an already-logged-in
* visitor to `/` instead (see {@link RedirectIfAuthenticated}). Anything
* else falls back to `/`, which itself redirects to `/login` if needed.
*/
export function App() {
return (
<Routes>

View file

@ -1,51 +1,99 @@
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
import {
type ApiErrorResponse,
ErrorCode,
type LoginInput,
type SafeUserProfile,
type SignupInput,
} from "@batch-cooking/shared";
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
/** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */
const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
/**
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
* status. Carries the same {@link ErrorCode} the API returned, so callers
* can branch on `error.code` (and UI code can look up its label via
* `ErrorMessageService.getLabel(error.code)`) instead of parsing text.
*/
export class ApiError extends Error {
status: number;
fieldErrors?: Record<string, string[] | undefined>;
/** HTTP status code of the failed response. */
public readonly status: number;
/** Machine-readable error code — see {@link ErrorCode}. */
public readonly code: ErrorCode;
/** Per-field validation messages, present only when `code` is `VALIDATION_ERROR`. */
public readonly fieldErrors?: Record<string, string[] | undefined>;
constructor(status: number, message: string, fieldErrors?: Record<string, string[] | undefined>) {
super(message);
public constructor(status: number, body: ApiErrorResponse) {
super(body.message);
this.name = "ApiError";
this.status = status;
this.fieldErrors = fieldErrors;
this.code = body.code;
this.fieldErrors = body.details;
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
/**
* Thin fetch wrapper around the auth endpoints. A class (rather than plain
* functions) so it reads as a cohesive service and stays easy to extend
* (e.g. swapping the transport, adding request interceptors) without
* touching every call site. Used as a single shared instance (`apiClient`,
* exported below) it's stateless, so there's no reason for more than one.
*/
export class ApiClient {
/**
* Performs a JSON request against the API and returns the parsed body.
*
* @throws {ApiError} if the response status is not in the 2xx range.
*/
private async request<TResponseBody>(
path: string,
options: RequestInit = {},
): Promise<TResponseBody> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
// Required for the httpOnly session cookie to be sent/received —
// the API and the web app run on different origins.
// Required for the httpOnly session cookie to be sent/received — the
// API and the web app run on different origins.
credentials: "include",
headers: { "Content-Type": "application/json", ...options.headers },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(res.status, body.error ?? "Something went wrong", body.details?.fieldErrors);
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: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
);
}
if (res.status === 204) {
return undefined as T;
// 204 No Content (e.g. logout) has no body to parse.
if (response.status === 204) {
return undefined as TResponseBody;
}
return res.json() as Promise<T>;
return response.json() as Promise<TResponseBody>;
}
export function signup(input: SignupInput): Promise<SafeUserProfile> {
return request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
/** Creates a profile (+ household) and starts a session. */
public signup(input: SignupInput): Promise<SafeUserProfile> {
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
}
export function login(input: LoginInput): Promise<SafeUserProfile> {
return request("/auth/login", { method: "POST", body: JSON.stringify(input) });
/** Verifies credentials and starts a session. */
public login(input: LoginInput): Promise<SafeUserProfile> {
return this.request("/auth/login", { method: "POST", body: JSON.stringify(input) });
}
export function logout(): Promise<void> {
return request("/auth/logout", { method: "POST" });
/** Ends the current session. */
public logout(): Promise<void> {
return this.request("/auth/logout", { method: "POST" });
}
export function me(): Promise<SafeUserProfile> {
return request("/auth/me");
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
public me(): Promise<SafeUserProfile> {
return this.request("/auth/me");
}
}
/** Single shared instance — this client is stateless, no need for one per caller. */
export const apiClient = new ApiClient();

View file

@ -1,40 +1,53 @@
import type { LoginInput, SafeUserProfile, SignupInput } from "@batch-cooking/shared";
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
import * as api from "../../api/client";
import { apiClient } from "../../api/client";
/** Shape of the auth state/actions exposed via {@link useAuth}. */
interface AuthContextValue {
/** Currently authenticated profile, or `null` if no active session. */
user: SafeUserProfile | null;
/** True only while the initial /auth/me check (on app load) is pending. */
/** True only while the initial `/auth/me` check (on app load) is pending — lets route guards avoid a premature redirect. */
isLoading: boolean;
/** Creates a profile (+ household) and updates `user` on success. Throws `ApiError` on failure. */
signup: (input: SignupInput) => Promise<void>;
/** Verifies credentials and updates `user` on success. Throws `ApiError` on failure. */
login: (input: LoginInput) => Promise<void>;
/** Ends the session and clears `user`. */
logout: () => Promise<void>;
}
/** React context carrying {@link AuthContextValue} — always accessed through {@link useAuth}, never directly. */
const AuthContext = createContext<AuthContextValue | null>(null);
/**
* Provides authentication state to the whole app. On mount, calls
* `GET /auth/me` once to restore the session from the httpOnly cookie (if
* any) this is what lets a page reload keep the user logged in.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SafeUserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
api
apiClient
.me()
.then(setUser)
// No session cookie (or it's invalid/expired) — that's the normal
// state for a first-time visitor, not an error to surface.
.catch(() => setUser(null))
.finally(() => setIsLoading(false));
}, []);
const signup = useCallback(async (input: SignupInput) => {
setUser(await api.signup(input));
setUser(await apiClient.signup(input));
}, []);
const login = useCallback(async (input: LoginInput) => {
setUser(await api.login(input));
setUser(await apiClient.login(input));
}, []);
const logout = useCallback(async () => {
await api.logout();
await apiClient.logout();
setUser(null);
}, []);
@ -45,6 +58,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
);
}
/** Reads the current auth state/actions. Must be called from within an {@link AuthProvider}. */
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {

View file

@ -2,7 +2,11 @@ import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "./AuthContext";
/** Sends already-logged-in visitors away from /login and /signup. */
/**
* Route guard for pages that make no sense to an already-authenticated
* visitor (`/login`, `/signup`). Mirrors {@link RequireAuth}'s waiting
* behavior while the initial session check is pending.
*/
export function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth();

View file

@ -2,11 +2,18 @@ import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "./AuthContext";
/** Redirects to /login if there's no authenticated session. */
/**
* Route guard for pages that require an authenticated session (e.g. the
* home page). Renders nothing while the initial session check is pending,
* to avoid a flash-then-redirect; once resolved, either renders `children`
* or redirects to `/login`.
*/
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth();
if (isLoading) {
// Initial GET /auth/me still in flight — don't redirect yet, we don't
// know the auth state.
return null;
}
if (!user) {

View file

@ -0,0 +1,88 @@
// =============================================================================
// Styles shared by LoginPage and SignupPage both render the same card/form
// layout, so this lives in features/auth/ (the concern both pages share)
// rather than being duplicated in each page's own stylesheet. Imported by
// both LoginPage.tsx and SignupPage.tsx.
// =============================================================================
// No `@use` of the theme partial needed here: every design token below is a
// CSS custom property (--color-*, --space-*...) declared once on :root in
// styles/global.scss and available globally at runtime not a Sass-level
// variable/mixin that would require an explicit compile-time import.
// Full-viewport centering wrapper for the auth card.
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
}
// The form itself: a vertically-stacked card, capped width so it stays
// readable on wide screens.
.auth-card {
display: flex;
flex-direction: column;
gap: var(--space-xs);
width: 100%;
max-width: var(--max-width-form);
// Field labels sit directly above their input, with a little breathing
// room from the previous field.
label {
font-size: var(--font-size-sm);
font-weight: 600;
margin-top: var(--space-sm);
}
input {
padding: var(--space-sm);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
}
// Submit button: full-width, visually separated from the fields above it.
button {
margin-top: var(--space-md);
padding: 0.6rem;
font-size: var(--font-size-base);
cursor: pointer;
border-radius: var(--radius-base);
border: none;
background: var(--color-primary);
color: white;
&:hover:not(:disabled) {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
// Per-field validation message (client-side, from zod) sits directly
// under its input.
.field-error {
color: var(--color-error);
font-size: var(--font-size-xs);
margin: 0;
}
// Whole-form error message (from the API, e.g. wrong credentials) sits
// above the submit button.
.form-error {
color: var(--color-error);
font-size: var(--font-size-sm);
}
// "Already have an account? / No account yet?" link row under the form.
.auth-switch {
font-size: var(--font-size-sm);
margin-top: var(--space-md);
text-align: center;
}

26
apps/web/src/i18n/i18n.ts Normal file
View file

@ -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.<lng>` 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 "&eacute;" in some setups).
interpolation: { escapeValue: false },
});
export default i18next;

View file

@ -1,64 +0,0 @@
:root {
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
body {
margin: 0;
}
.auth-page,
.home-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem;
}
.auth-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
width: 100%;
max-width: 22rem;
}
.auth-card label {
font-size: 0.875rem;
font-weight: 600;
margin-top: 0.5rem;
}
.auth-card input {
padding: 0.5rem;
font-size: 1rem;
border: 1px solid #888;
border-radius: 4px;
}
.auth-card button {
margin-top: 1rem;
padding: 0.6rem;
font-size: 1rem;
cursor: pointer;
}
.field-error {
color: #c0392b;
font-size: 0.8rem;
margin: 0;
}
.form-error {
color: #c0392b;
font-size: 0.9rem;
}
.auth-switch {
font-size: 0.875rem;
margin-top: 1rem;
text-align: center;
}

View file

@ -1,13 +1,18 @@
import type { ZodError } from "zod";
/** First error message per field, for simple inline form display. */
/**
* Flattens a zod validation error into `{ fieldName: firstMessage }`, for
* simple inline display under each form field (only the first message per
* field is shown good enough for the single-rule-per-field schemas this
* app uses today).
*/
export function fieldErrorsFrom(error: ZodError): Record<string, string> {
const flat = error.flatten().fieldErrors;
const result: Record<string, string> = {};
for (const [key, messages] of Object.entries(flat)) {
const fieldErrors = error.flatten().fieldErrors;
const firstMessagePerField: Record<string, string> = {};
for (const [field, messages] of Object.entries(fieldErrors)) {
if (messages?.[0]) {
result[key] = messages[0];
firstMessagePerField[field] = messages[0];
}
}
return result;
return firstMessagePerField;
}

View file

@ -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"
}
}

View file

@ -3,7 +3,12 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AuthProvider } from "./features/auth/AuthContext";
import "./index.css";
// 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";
const rootElement = document.getElementById("root");
if (!rootElement) {

View file

@ -0,0 +1,35 @@
// =============================================================================
// Styles specific to HomePage colocated next to HomePage.tsx since nothing
// else uses these classes.
// =============================================================================
// No `@use` of the theme partial needed here: every design token below is a
// CSS custom property (--color-*, --space-*...) declared once on :root in
// styles/global.scss and available globally at runtime not a Sass-level
// variable/mixin that would require an explicit compile-time import.
// Full-viewport centering wrapper, mirroring .auth-page's layout so the app
// doesn't visually jump between the login/signup screens and the home page.
.home-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
padding: var(--space-md);
button {
padding: 0.6rem var(--space-md);
font-size: var(--font-size-base);
cursor: pointer;
border-radius: var(--radius-base);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
&:hover {
background: var(--color-background);
}
}
}

View file

@ -1,10 +1,19 @@
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. 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() {
await logout();
navigate("/login");
@ -13,11 +22,9 @@ export function HomePage() {
return (
<main className="home-page">
<h1>batchCooking</h1>
<p>
Bonjour {user?.firstName} {user?.lastName} 👋
</p>
<p>{t("home.greeting", { firstName: user?.firstName, lastName: user?.lastName })}</p>
<button type="button" onClick={handleLogout}>
Se déconnecter
{t("home.logout")}
</button>
</main>
);

View file

@ -1,20 +1,41 @@
import { loginSchema } from "@batch-cooking/shared";
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";
// Shared with SignupPage — see the file for why it's colocated in
// features/auth/ rather than duplicated per page.
import "../features/auth/auth-form.scss";
import { fieldErrorsFrom } from "../lib/zod-errors";
import { errorMessageService } from "../services/error-message.service";
/**
* Login form. Validates client-side first (via the shared `loginSchema`,
* 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}. 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("");
const [password, setPassword] = useState("");
// Per-field validation messages (client-side, from zod) and a whole-form
// error message (from the API), kept separate since they're displayed
// in different places and cleared at different times.
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
/** Validates, then submits the form; navigates home on success. */
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
@ -31,7 +52,11 @@ export function LoginPage() {
await login(result.data);
navigate("/");
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "Something went wrong");
// ApiError.code is looked up through ErrorMessageService so the
// label is centralized and localized — never display err.message
// directly, it's the API's developer-facing (English) text.
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
@ -40,9 +65,9 @@ export function LoginPage() {
return (
<main className="auth-page">
<form className="auth-card" onSubmit={handleSubmit} noValidate>
<h1>Se connecter</h1>
<h1>{t("auth.login.title")}</h1>
<label htmlFor="email">Email</label>
<label htmlFor="email">{t("auth.login.emailLabel")}</label>
<input
id="email"
type="email"
@ -52,7 +77,7 @@ export function LoginPage() {
/>
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
<label htmlFor="password">Mot de passe</label>
<label htmlFor="password">{t("auth.login.passwordLabel")}</label>
<input
id="password"
type="password"
@ -65,11 +90,11 @@ export function LoginPage() {
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Connexion…" : "Se connecter"}
{isSubmitting ? t("auth.login.submitting") : t("auth.login.submit")}
</button>
<p className="auth-switch">
Pas encore de compte ? <Link to="/signup">Créer un profil</Link>
{t("auth.login.noAccount")} <Link to="/signup">{t("auth.login.createProfileLink")}</Link>
</p>
</form>
</main>

View file

@ -1,22 +1,43 @@
import { signupSchema } from "@batch-cooking/shared";
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";
// Shared with LoginPage — see the file for why it's colocated in
// features/auth/ rather than duplicated per page.
import "../features/auth/auth-form.scss";
import { fieldErrorsFrom } from "../lib/zod-errors";
import { errorMessageService } from "../services/error-message.service";
/**
* Signup form (profile creation). Validates client-side first (via the
* shared `signupSchema`, 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 (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("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
// Per-field validation messages (client-side, from zod) and a whole-form
// error message (from the API), kept separate since they're displayed
// in different places and cleared at different times.
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
/** Validates, then submits the form; navigates home on success. */
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
@ -33,7 +54,11 @@ export function SignupPage() {
await signup(result.data);
navigate("/");
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "Something went wrong");
// ApiError.code is looked up through ErrorMessageService so the
// label is centralized and localized — never display err.message
// directly, it's the API's developer-facing (English) text.
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
@ -42,9 +67,9 @@ export function SignupPage() {
return (
<main className="auth-page">
<form className="auth-card" onSubmit={handleSubmit} noValidate>
<h1>Créer un profil</h1>
<h1>{t("auth.signup.title")}</h1>
<label htmlFor="firstName">Prénom</label>
<label htmlFor="firstName">{t("auth.signup.firstNameLabel")}</label>
<input
id="firstName"
value={firstName}
@ -53,7 +78,7 @@ export function SignupPage() {
/>
{fieldErrors.firstName && <p className="field-error">{fieldErrors.firstName}</p>}
<label htmlFor="lastName">Nom</label>
<label htmlFor="lastName">{t("auth.signup.lastNameLabel")}</label>
<input
id="lastName"
value={lastName}
@ -62,7 +87,7 @@ export function SignupPage() {
/>
{fieldErrors.lastName && <p className="field-error">{fieldErrors.lastName}</p>}
<label htmlFor="email">Email</label>
<label htmlFor="email">{t("auth.signup.emailLabel")}</label>
<input
id="email"
type="email"
@ -72,7 +97,7 @@ export function SignupPage() {
/>
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
<label htmlFor="password">Mot de passe</label>
<label htmlFor="password">{t("auth.signup.passwordLabel")}</label>
<input
id="password"
type="password"
@ -85,11 +110,11 @@ export function SignupPage() {
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Création…" : "Créer mon profil"}
{isSubmitting ? t("auth.signup.submitting") : t("auth.signup.submit")}
</button>
<p className="auth-switch">
Déjà un compte ? <Link to="/login">Se connecter</Link>
{t("auth.signup.hasAccount")} <Link to="/login">{t("auth.signup.loginLink")}</Link>
</p>
</form>
</main>

View file

@ -0,0 +1,32 @@
import { ErrorCode } from "@batch-cooking/shared";
import i18n from "../i18n/i18n";
/**
* 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.
*
* 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 {
/**
* Returns the localized, user-facing label for a given error code.
*
* @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: ErrorCode): string {
const memberName = ErrorCode[code] ?? ErrorCode[ErrorCode.INTERNAL_ERROR];
return i18n.t(`errors.${memberName}`);
}
}
/** Single shared instance — this service is stateless, no need for one per caller. */
export const errorMessageService = new ErrorMessageService();

View file

@ -0,0 +1,55 @@
// =============================================================================
// Design tokens the single source of truth for colors, spacing, typography
// and other reusable values across the whole app.
//
// Exposed as CSS custom properties on :root (not plain SCSS variables) so
// they're available at *runtime*, not just compile time — this is what
// would let a future dark-mode toggle (or any theme switch) just redefine
// these variables instead of rebuilding the stylesheet. Every other .scss
// file should reference `var(--token-name)`, never a hardcoded color/size.
//
// Import this partial once, globally (see global.scss) never re-import it
// from a component-level .scss file, `:root` only needs to be declared once.
// =============================================================================
:root {
// --- Color palette --------------------------------------------------------
// Neutral surface: page background vs. the "card" surface content sits on.
--color-background: #ffffff;
--color-surface: #ffffff;
// Text.
--color-text: #1a1a1a;
--color-text-muted: #555555;
// Brand/accent used for primary buttons and links.
--color-primary: #2f6f4f;
--color-primary-hover: #24573e;
// Feedback.
--color-error: #c0392b;
--color-border: #888888;
// --- Spacing scale ---------------------------------------------------------
// Multiples of a 4px base unit use these instead of ad hoc px values so
// spacing stays visually consistent as the app grows.
--space-xs: 0.25rem; // 4px
--space-sm: 0.5rem; // 8px
--space-md: 1rem; // 16px
--space-lg: 1.5rem; // 24px
--space-xl: 2rem; // 32px
// --- Typography --------------------------------------------------------
--font-family-base: system-ui, sans-serif;
--font-size-base: 1rem;
--font-size-sm: 0.875rem;
--font-size-xs: 0.8rem;
// --- Shape / misc --------------------------------------------------------
--radius-base: 4px;
--max-width-form: 22rem;
}
// Lets the browser pick sensible default colors (form controls, scrollbars)
// for whichever mode (light/dark) the user's OS is in, until this app has
// its own explicit dark theme wired to the tokens above.
:root {
color-scheme: light dark;
}

View file

@ -0,0 +1,17 @@
// =============================================================================
// Global stylesheet imported exactly once, in main.tsx. Contains only
// truly app-wide rules: the theme tokens and a minimal reset/base styling
// that every page inherits. Anything specific to one component or page
// belongs in a .scss file colocated next to that component/page instead.
// =============================================================================
@use "./theme";
// Minimal reset: remove the default body margin so pages can control their
// own layout without fighting the browser's default 8px margin.
body {
margin: 0;
font-family: var(--font-family-base);
color: var(--color-text);
background: var(--color-background);
}

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -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"

View file

@ -3,4 +3,14 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
css: {
preprocessorOptions: {
// Opts into Dart Sass's modern API — avoids the "legacy-js-api"
// deprecation warning on every build (Vite still defaults to the
// legacy API for backward compatibility).
scss: {
api: "modern-compiler",
},
},
},
});

View file

@ -0,0 +1,27 @@
{
"name": "@batch-cooking/error-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/node": "^22.9.0",
"typescript": "^5.7.2"
},
"dependencies": {
"@batch-cooking/shared": "workspace:*",
"zod": "^3.25.76"
}
}

View file

@ -0,0 +1,76 @@
import { type ApiErrorResponse, ErrorCode } from "@batch-cooking/shared";
import { ZodError } from "zod";
import { HttpError } from "./http-error.js";
/** Return value of {@link ErrorHandlerService.handle}: everything a caller needs to send an HTTP response. */
export interface ErrorHandlingResult {
/** HTTP status code to respond with. */
status: number;
/** JSON body to respond with — matches the shared {@link ApiErrorResponse} contract. */
body: ApiErrorResponse;
}
/**
* Centralizes every "how do we turn a thrown error into an HTTP response"
* decision in one place, so route handlers and framework-specific
* middleware (e.g. `createErrorMiddleware` in `@batch-cooking/express-tools`)
* never duplicate this logic. Framework-agnostic on purpose it only maps
* an error to `{ status, body }` and has zero dependency on Express or any
* other HTTP framework.
*
* Recognizes three error shapes today (zod validation failures, our own
* `HttpError`, and anything else) and always falls back to a safe, generic
* 500 for the unknown case a caller of `handle()` never needs its own
* fallback branch.
*/
export class ErrorHandlerService {
/**
* Maps any thrown value into a status + body pair ready to send to the
* client. Always succeeds an error that doesn't match a known shape
* becomes a generic {@link ErrorCode.INTERNAL_ERROR} and is logged.
*/
public handle(error: unknown): ErrorHandlingResult {
if (error instanceof ZodError) {
return this.fromZodError(error);
}
if (error instanceof HttpError) {
return this.fromHttpError(error);
}
return this.fromUnknownError(error);
}
/** Request body/query failed schema validation — always a 400. */
private fromZodError(error: ZodError): ErrorHandlingResult {
return {
status: 400,
body: {
code: ErrorCode.VALIDATION_ERROR,
message: "Validation error",
details: error.flatten().fieldErrors,
},
};
}
/** Our own typed error — status/code were decided by whoever threw it. */
private fromHttpError(error: HttpError): ErrorHandlingResult {
return {
status: error.status,
body: { code: error.code, message: error.message },
};
}
/**
* Anything unrecognized: logged server-side (so it's still diagnosable)
* but never leaks internal details to the client always a generic 500.
*/
private fromUnknownError(error: unknown): ErrorHandlingResult {
console.error(error);
return {
status: 500,
body: { code: ErrorCode.INTERNAL_ERROR, message: "Internal server error" },
};
}
}
/** Single shared instance — this service is stateless, no need for one per request. */
export const errorHandlerService = new ErrorHandlerService();

View file

@ -0,0 +1,30 @@
import type { ErrorCode } from "@batch-cooking/shared";
/**
* Typed error carrying both the HTTP status it should map to and the
* 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 {@link ErrorHandlerService} is the
* single place that turns it into an actual HTTP response, so every error
* path in an app built with these tools is shaped consistently.
*/
export class HttpError extends Error {
/** HTTP status code to respond with (e.g. 401, 404, 409). */
public readonly status: number;
/** Machine-readable error code, shared with the client — see {@link ErrorCode}. */
public readonly code: ErrorCode;
/**
* @param status - HTTP status code to respond with.
* @param code - Business error code identifying the failure (shared with the client).
* @param message - Developer-facing description (English). Logged/used for
* debugging only; end-user-facing text is derived client-side from `code`.
*/
public constructor(status: number, code: ErrorCode, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
this.code = code;
}
}

View file

@ -0,0 +1,11 @@
// Public entry point of the framework-agnostic error-handling tooling
// shared across apps in this monorepo. Everything here — HttpError,
// ErrorHandlerService — has zero dependency on Express or any other HTTP
// framework; it only knows how to map an error to a {status, body} pair.
//
// Framework-specific adapters (e.g. Express's `createErrorMiddleware`) live
// in their own package (`@batch-cooking/express-tools`) and consume these
// types instead of duplicating the mapping logic.
export * from "./error-handler.service.js";
export * from "./http-error.js";

View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -0,0 +1,33 @@
{
"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/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/node": "^22.9.0",
"typescript": "^5.7.2"
},
"dependencies": {
"@batch-cooking/error-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"express": "^4.21.1"
}
}

View file

@ -0,0 +1,34 @@
import type { NextFunction, Request, RequestHandler, Response } from "express";
/**
* An Express route handler whose body is `async` (returns a `Promise`).
* Only `ResBody`/`Locals` are made generic (what this codebase actually
* varies per-route) params/request-body/query stay at Express's own
* internal defaults, same as an unparameterized `Request`.
*/
export type AsyncRequestHandler<
ResBody = unknown,
Locals extends Record<string, unknown> = Record<string, unknown>,
> = (req: Request, res: Response<ResBody, Locals>, next: NextFunction) => Promise<void>;
/**
* Wraps an async Express handler so a thrown error or rejected promise is
* forwarded to `next(err)` automatically. Without this, an unhandled
* rejection inside an `async` route handler never reaches Express's error
* middleware every route ends up needing its own `try { ... } catch (err)
* { next(err); }` boilerplate, which this removes.
*
* @example
* router.post("/signup", wrapAsyncHandler(async (req, res) => {
* const profile = await signup(req.body);
* res.status(201).json(profile);
* }));
*/
export function wrapAsyncHandler<
ResBody = unknown,
Locals extends Record<string, unknown> = Record<string, unknown>,
>(handler: AsyncRequestHandler<ResBody, Locals>): RequestHandler {
return (req, res, next) => {
handler(req, res as Response<ResBody, Locals>, next).catch(next);
};
}

View file

@ -0,0 +1,27 @@
import type { ErrorHandlerService } from "@batch-cooking/error-tools";
import type { NextFunction, Request, Response } from "express";
/** 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);
};
}

View file

@ -0,0 +1,96 @@
import cookieParser from "cookie-parser";
import cors from "cors";
import express, {
type ErrorRequestHandler,
type Express,
type RequestHandler,
type Router,
} from "express";
/** HTTP verbs {@link ExpressServer.addRoute} accepts. */
export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
/** Options for {@link ExpressServer.setupCore}. */
export interface ExpressServerCoreOptions {
/** Origin allowed by CORS — must match wherever the frontend is served from. */
corsOrigin: string;
}
/**
* Thin wrapper around an Express application: bundles the common
* "set up the standard middleware stack, register routes without
* duplicates, wire the error handler, start listening" concerns behind a
* small typed API, instead of every service in the monorepo repeating the
* same raw `express()` setup by hand.
*
* Framework-specific on purpose unlike `ErrorHandlerService` (which has
* no Express dependency at all), this class *is* the Express integration
* layer. Business/domain code should never import `express` directly;
* it goes through this instead.
*/
export class ExpressServer {
/** The underlying Express application. */
private readonly app: Express;
/** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */
private readonly registeredRoutes = new Set<string>();
public constructor() {
this.app = express();
}
/** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */
public get instance(): Express {
return this.app;
}
/**
* Registers the standard middleware stack every service in this
* monorepo needs: CORS (with credentials, for the session cookie),
* JSON body parsing, and cookie parsing. Call once, before registering
* any route.
*/
public setupCore(options: ExpressServerCoreOptions): void {
this.app.use(cors({ origin: options.corsOrigin, credentials: true }));
this.app.use(express.json());
this.app.use(cookieParser());
}
/** Registers a middleware that runs on every request (e.g. logging, a catch-all 404 handler). */
public addMiddleware(middleware: RequestHandler): void {
this.app.use(middleware);
}
/**
* Registers the final Express error-handling middleware (the 4-argument
* form). Must be added last Express only treats a middleware as an
* error handler by its arity, and only the last matching one runs.
*/
public setErrorHandler(middleware: ErrorRequestHandler): void {
this.app.use(middleware);
}
/** Mounts a whole `express.Router` under a base path (e.g. `mountRouter("/auth", authRouter)`). */
public mountRouter(basePath: string, router: Router): void {
this.app.use(basePath, router);
}
/**
* Registers a single route with its handler(s). Warns and skips instead
* of registering if the same method+path was already added catches a
* copy-paste mistake at startup instead of silently shadowing a route.
*/
public addRoute(method: HttpMethod, path: string, ...handlers: RequestHandler[]): void {
const key = `${method.toUpperCase()} ${path}`;
if (this.registeredRoutes.has(key)) {
console.warn(`[ExpressServer] Route already registered, skipping: ${key}`);
return;
}
this.registeredRoutes.add(key);
this.app[method](path, ...handlers);
}
/** Starts listening on the given port. `onListening` is called once the server is up (e.g. to log the URL). */
public listen(port: number, onListening?: () => void): void {
this.app.listen(port, onListening);
}
}

View file

@ -0,0 +1,13 @@
// 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.
//
// Framework-agnostic error-handling pieces (ErrorHandlerService, HttpError)
// live in `@batch-cooking/error-tools` instead, since they have zero
// dependency on Express. `createErrorMiddleware` here is the thin Express
// adapter that wires that service into an Express app.
export * from "./async-handler.js";
export * from "./error-middleware.js";
export * from "./express-server.js";

View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -0,0 +1,55 @@
/**
* Enumeration of every business/domain error code the API can return.
*
* 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`, 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, 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 = 4000,
/** Signup attempted with an email that already has a profile. */
EMAIL_ALREADY_IN_USE = 4001,
/** Login failed — wrong email or wrong password (never say which). */
INVALID_CREDENTIALS = 4010,
/** Request required a session cookie/JWT that is missing, invalid, or stale. */
NOT_AUTHENTICATED = 4011,
/** No route/resource matches the request. */
NOT_FOUND = 4040,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = 5000,
}
/**
* Shape of every JSON error body the API returns, whatever the failure.
* Kept intentionally small and stable: `code` is what clients should
* branch on, `message` is a human-readable (English, developer-facing)
* description useful for logs/debugging never shown to end users as-is,
* since end-user-facing text is localized client-side from `code`.
*/
export interface ApiErrorResponse {
/** Machine-readable error identifier — see {@link ErrorCode}. */
code: ErrorCode;
/** Developer-facing description (English). Not localized, not for UI display. */
message: string;
/** Present only for VALIDATION_ERROR: per-field error messages from zod. */
details?: Record<string, string[] | undefined>;
}

View file

@ -1,2 +1,9 @@
// Public entry point of the code shared between apps/api and apps/web.
// Anything exported here is part of the cross-app contract — keep it
// intentional (types, validation schemas, error codes), not an implementation
// detail specific to one side.
export * from "./errors/error-codes.js";
export * from "./schemas/auth.js";
export * from "./tools/assert-is-never.js";
export * from "./types/user-profile.js";

View file

@ -3,9 +3,13 @@ import { z } from "zod";
// Shared between apps/api (server-side validation, source of truth) and
// apps/web (client-side validation for instant feedback before the round
// trip) — one set of rules, no risk of the two drifting apart.
// Messages are in French: this is the only place end users ever see zod's
// text (surfaced as-is in apps/web's forms), and the whole UI is French.
// Zod's own `.min()`/`.email()` messages are in French: this is the only
// place end users ever see them (surfaced as-is in apps/web's forms), and
// the whole UI is French. This is distinct from the ErrorCode-based i18n
// used for *API* errors (see error-codes.ts) — these are purely
// client-side, pre-submit validation messages that never leave the browser.
/** Payload accepted by `POST /auth/signup`. */
export const signupSchema = z.object({
firstName: z.string().trim().min(1, "Le prénom est requis").max(100),
lastName: z.string().trim().min(1, "Le nom est requis").max(100),
@ -15,10 +19,13 @@ export const signupSchema = z.object({
// complexity rules mostly push users toward predictable patterns.
password: z.string().min(8, "8 caractères minimum").max(200),
});
/** Inferred TS type for {@link signupSchema}'s validated output. */
export type SignupInput = z.infer<typeof signupSchema>;
/** Payload accepted by `POST /auth/login`. */
export const loginSchema = z.object({
email: z.string().trim().toLowerCase().email("Email invalide"),
password: z.string().min(1, "Le mot de passe est requis"),
});
/** Inferred TS type for {@link loginSchema}'s validated output. */
export type LoginInput = z.infer<typeof loginSchema>;

View file

@ -0,0 +1,35 @@
/**
* Exhaustiveness check for a `switch`/`if`-chain over a union type. Call
* this in the `default` case (or final `else`) with the value being
* switched on: if every member of the union has been handled by an
* earlier branch, TypeScript narrows that value to `never` there, and the
* call type-checks. If a new member is later added to the union and a
* branch is forgotten, `value` is no longer `never` at that point the
* call becomes a **compile error**, catching the missing case before it
* ships, instead of silently falling through at runtime.
*
* @example
* type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
*
* function area(shape: Shape): number {
* switch (shape.kind) {
* case "circle":
* return Math.PI * shape.radius ** 2;
* case "square":
* return shape.side ** 2;
* default:
* // Compile error here if a new `Shape` variant is added without a
* // matching case above — `shape` wouldn't be `never` anymore.
* return assertIsNever(shape);
* }
* }
*
* @param value - The value that should be `never` at this point (compile-time check).
* @param message - Optional custom error message; defaults to including the offending value.
* @throws Always throws this is also a real runtime safety net for a
* value that reaches here despite the type system (e.g. unvalidated
* external data cast to the union type).
*/
export function assertIsNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}

View file

@ -1,12 +1,22 @@
// Mirrors apps/api's Omit<PrismaUserProfile, "passwordHash"> — declared by
// hand rather than derived from the Prisma type, since apps/web must not
// depend on @prisma/client.
/**
* Public shape of a user profile, as returned by the API (never includes
* the password hash). Mirrors apps/api's `Omit<PrismaUserProfile,
* "passwordHash">` — declared by hand rather than derived from the Prisma
* type, since apps/web must not depend on `@prisma/client`.
*/
export interface SafeUserProfile {
/** Primary key. */
id: number;
/** First name. */
firstName: string;
/** Last name. */
lastName: string;
/** Email address — unique, used as the login identifier. */
email: string;
/** Incremented server-side to invalidate previously-issued JWTs (e.g. on password change). Not used directly by the client. */
tokenVersion: number;
/** FK to the household this profile belongs to, or `null` if not yet assigned to one. */
houseId: number | null;
/** FK to this profile's diet preference, or `null` if unset. */
dietId: number | null;
}

View file

@ -17,6 +17,12 @@ importers:
apps/api:
dependencies:
'@batch-cooking/error-tools':
specifier: workspace:*
version: link:../../packages/error-tools
'@batch-cooking/express-tools':
specifier: workspace:*
version: link:../../packages/express-tools
'@batch-cooking/shared':
specifier: workspace:*
version: link:../../packages/shared
@ -26,12 +32,6 @@ importers:
argon2:
specifier: 0.31.2
version: 0.31.2
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
cors:
specifier: ^2.8.6
version: 2.8.6
dotenv:
specifier: ^16.4.5
version: 16.6.1
@ -48,12 +48,9 @@ importers:
'@cucumber/cucumber':
specifier: ^13.2.1
version: 13.2.1
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@4.17.25)
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
'@faker-js/faker':
specifier: ^10.6.0
version: 10.6.0
'@types/express':
specifier: ^4.17.21
version: 4.17.25
@ -93,12 +90,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)
@ -117,10 +120,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.3
version: 4.7.0(vite@5.4.21(@types/node@22.20.1))
version: 4.7.0(vite@5.4.21(@types/node@22.20.1)(sass@1.102.0))
cypress:
specifier: ^13.15.2
version: 13.17.0
sass:
specifier: ^1.102.0
version: 1.102.0
start-server-and-test:
specifier: ^2.0.8
version: 2.1.5
@ -129,7 +135,57 @@ importers:
version: 5.9.3
vite:
specifier: ^5.4.11
version: 5.4.21(@types/node@22.20.1)
version: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
packages/error-tools:
dependencies:
'@batch-cooking/shared':
specifier: workspace:*
version: link:../shared
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@types/node':
specifier: ^22.9.0
version: 22.20.1
typescript:
specifier: ^5.7.2
version: 5.9.3
packages/express-tools:
dependencies:
'@batch-cooking/error-tools':
specifier: workspace:*
version: link:../error-tools
'@batch-cooking/shared':
specifier: workspace:*
version: link:../shared
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
cors:
specifier: ^2.8.6
version: 2.8.6
express:
specifier: ^4.21.1
version: 4.22.2
devDependencies:
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@4.17.25)
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
'@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:
@ -214,6 +270,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'}
@ -655,6 +715,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'}
@ -708,6 +772,82 @@ packages:
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==, tarball: https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz}
'@parcel/watcher-android-arm64@2.6.0':
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==, tarball: https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.6.0':
resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==, tarball: https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.6.0':
resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==, tarball: https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.6.0':
resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==, tarball: https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.6.0':
resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm-musl@2.6.0':
resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm64-glibc@2.6.0':
resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.6.0':
resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.6.0':
resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.6.0':
resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.6.0':
resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==, tarball: https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-x64@2.6.0':
resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==, tarball: https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.6.0':
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==, tarball: https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz}
engines: {node: '>= 10.0.0'}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==, tarball: https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz}
engines: {node: '>=10'}
@ -1184,6 +1324,10 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz}
engines: {node: '>= 8.10.0'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz}
engines: {node: '>= 20.19.0'}
chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==, tarball: https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz}
engines: {node: '>=10'}
@ -1677,6 +1821,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'}
@ -1697,6 +1844,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'}
@ -1704,6 +1859,9 @@ packages:
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==, tarball: https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz}
immutable@5.1.9:
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==, tarball: https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz}
indent-string@4.0.0:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==, tarball: https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz}
engines: {node: '>=8'}
@ -2139,6 +2297,10 @@ packages:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz}
engines: {node: '>=8.6'}
picomatch@4.0.5:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz}
engines: {node: '>=12'}
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==, tarball: https://registry.npmjs.org/pify/-/pify-2.3.0.tgz}
engines: {node: '>=0.10.0'}
@ -2206,6 +2368,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'}
@ -2247,6 +2425,10 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz}
engines: {node: '>=8.10.0'}
readdirp@5.1.1:
resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz}
engines: {node: '>= 20.19.0'}
regexp-match-indices@1.0.2:
resolution: {integrity: sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==, tarball: https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz}
@ -2291,6 +2473,11 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, tarball: https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz}
sass@1.102.0:
resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==, tarball: https://registry.npmjs.org/sass/-/sass-1.102.0.tgz}
engines: {node: '>=20.19.0'}
hasBin: true
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, tarball: https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz}
@ -2565,6 +2752,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}
@ -2790,6 +2982,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
@ -3134,6 +3328,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
@ -3193,6 +3389,63 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
'@parcel/watcher-android-arm64@2.6.0':
optional: true
'@parcel/watcher-darwin-arm64@2.6.0':
optional: true
'@parcel/watcher-darwin-x64@2.6.0':
optional: true
'@parcel/watcher-freebsd-x64@2.6.0':
optional: true
'@parcel/watcher-linux-arm-glibc@2.6.0':
optional: true
'@parcel/watcher-linux-arm-musl@2.6.0':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.6.0':
optional: true
'@parcel/watcher-linux-arm64-musl@2.6.0':
optional: true
'@parcel/watcher-linux-x64-glibc@2.6.0':
optional: true
'@parcel/watcher-linux-x64-musl@2.6.0':
optional: true
'@parcel/watcher-win32-arm64@2.6.0':
optional: true
'@parcel/watcher-win32-x64@2.6.0':
optional: true
'@parcel/watcher@2.6.0':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.5
optionalDependencies:
'@parcel/watcher-android-arm64': 2.6.0
'@parcel/watcher-darwin-arm64': 2.6.0
'@parcel/watcher-darwin-x64': 2.6.0
'@parcel/watcher-freebsd-x64': 2.6.0
'@parcel/watcher-linux-arm-glibc': 2.6.0
'@parcel/watcher-linux-arm-musl': 2.6.0
'@parcel/watcher-linux-arm64-glibc': 2.6.0
'@parcel/watcher-linux-arm64-musl': 2.6.0
'@parcel/watcher-linux-x64-glibc': 2.6.0
'@parcel/watcher-linux-x64-musl': 2.6.0
'@parcel/watcher-win32-arm64': 2.6.0
'@parcel/watcher-win32-x64': 2.6.0
optional: true
'@phc/format@1.0.0': {}
'@prisma/client@5.22.0(prisma@5.22.0)':
@ -3427,7 +3680,7 @@ snapshots:
'@types/node': 22.20.1
optional: true
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.1))':
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.1)(sass@1.102.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@ -3435,7 +3688,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.21(@types/node@22.20.1)
vite: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
transitivePeerDependencies:
- supports-color
@ -3654,6 +3907,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
chokidar@5.0.0:
dependencies:
readdirp: 5.1.1
chownr@2.0.0: {}
ci-info@4.4.0: {}
@ -4251,6 +4508,8 @@ snapshots:
dependencies:
lru-cache: 11.5.2
html-parse-stringify@4.0.1: {}
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@ -4276,12 +4535,18 @@ 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
ieee754@1.2.1: {}
immutable@5.1.9: {}
indent-string@4.0.0: {}
indent-string@5.0.0: {}
@ -4656,6 +4921,9 @@ snapshots:
picomatch@2.3.2: {}
picomatch@4.0.5:
optional: true
pify@2.3.0: {}
postcss@8.5.26:
@ -4722,6 +4990,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):
@ -4766,6 +5045,8 @@ snapshots:
dependencies:
picomatch: 2.3.2
readdirp@5.1.1: {}
regexp-match-indices@1.0.2:
dependencies:
regexp-tree: 0.1.27
@ -4831,6 +5112,14 @@ snapshots:
safer-buffer@2.1.2: {}
sass@1.102.0:
dependencies:
chokidar: 5.0.0
immutable: 5.1.9
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.6.0
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
@ -5126,6 +5415,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: {}
@ -5147,7 +5440,7 @@ snapshots:
core-util-is: 1.0.2
extsprintf: 1.3.0
vite@5.4.21(@types/node@22.20.1):
vite@5.4.21(@types/node@22.20.1)(sass@1.102.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.26
@ -5155,6 +5448,7 @@ snapshots:
optionalDependencies:
'@types/node': 22.20.1
fsevents: 2.3.3
sass: 1.102.0
wait-on@9.0.4(debug@4.4.3):
dependencies:

View file

@ -0,0 +1,138 @@
# Architecture backend — Projet Batch-cooking
> Documentation de l'organisation d'`apps/api` et de l'outillage partagé
> (`packages/express-tools`, `packages/error-tools`, `packages/shared`).
---
## `packages/express-tools` — outillage Express générique
Package séparé, réutilisable par n'importe quel service Express du monorepo (pas
seulement `apps/api`) : pas de logique métier, juste de l'infra Express.
### `ExpressServer` — init serveur, routes, middlewares
Enveloppe une application Express derrière une API typée, au lieu que chaque
service refasse le même `express()` à la main :
```ts
const server = new ExpressServer();
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); // cors + json + cookie-parser
server.addRoute("get", "/health", (_req, res) => res.status(200).json({ status: "ok" }));
server.mountRouter("/auth", authRouter);
server.addMiddleware(notFoundHandler);
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
server.listen(port, () => console.log(`Listening on ${port}`));
```
- `setupCore(options)` — middleware stack commun (CORS avec credentials, JSON,
cookies).
- `addRoute(method, path, ...handlers)` — enregistre une route ; avertit et
ignore au lieu d'écraser silencieusement si la même route (méthode + chemin)
est déjà enregistrée.
- `addMiddleware` / `mountRouter` / `setErrorHandler` — ajout de middleware
générique, montage d'un `Router` complet, middleware d'erreur final (4
arguments — doit être ajouté en dernier).
- `.instance` — l'app Express brute, nécessaire pour les outils de test
(supertest) qui attendent une instance `Express`, pas le wrapper.
- `.listen(port, onListening?)` — démarre le serveur.
`apps/api/src/app.ts` expose deux fonctions : `createServer(): ExpressServer`
(utilisée par `server.ts`, qui appelle `.listen()`) et `createApp(): Express`
(= `createServer().instance`, utilisée par les tests).
### `wrapAsyncHandler` — plus de try/catch répété dans les routes
```ts
router.post("/signup", wrapAsyncHandler(async (req, res) => {
const profile = await signup(req.body); // une erreur/rejet ici va automatiquement à next()
res.status(201).json(profile);
}));
```
Sans ça, une exception dans un handler `async` ne remonte jamais tout seule au
middleware d'erreur d'Express — chaque route devait faire son propre
`try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise.
### `createErrorMiddleware` — adaptateur Express pour `packages/error-tools`
Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et
`ErrorHandlerService` vivent dans **`packages/error-tools`**, pas ici :
`ErrorHandlerService` **n'a aucune dépendance à Express** — c'est un service
générique `erreur → { status, body }` qui fonctionnerait à l'identique derrière
Fastify ou n'importe quel autre framework, donc il n'a rien à faire dans un
package *express*-tools. `ExpressServer` et `createErrorMiddleware` (ici) sont
la vraie couche Express : elles adaptent des pièces indépendantes du framework
(`ErrorHandlerService`, importé depuis `@batch-cooking/error-tools`) à l'API
d'Express.
---
## Auth : `res.locals`, pas d'augmentation du namespace Express
`requireAuth` (`apps/api/src/middlewares/require-auth.ts`) attache le profil
authentifié à **`res.locals.userProfile`**, typé via l'interface `AuthLocals` :
```ts
export interface AuthLocals {
userProfile: SafeUserProfile;
}
export async function requireAuth(req: Request, res: Response<unknown, AuthLocals>, next: NextFunction) {
// ...
res.locals.userProfile = safeProfile;
next();
}
```
Un handler derrière ce middleware type sa réponse `Response<unknown, AuthLocals>`
et lit `res.locals.userProfile` sans cast :
```ts
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
res.status(200).json(res.locals.userProfile);
});
```
**Pourquoi pas `declare global { namespace Express { interface Request {...} } }`**
(l'approche initialement utilisée, retirée depuis) : `res.locals` est le
mécanisme natif d'Express prévu exactement pour ça (faire passer des données
d'un middleware au handler suivant), typé par route via un paramètre
générique — pas une augmentation globale et permanente qui change
silencieusement le type de **toutes** les `Request` du projet, qu'elles soient
passées par ce middleware ou non.
---
## `packages/shared``assertIsNever`
`packages/shared/src/tools/assert-is-never.ts` — vérification d'exhaustivité
pour un `switch`/`if`-chain sur une union :
```ts
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
default: return assertIsNever(shape); // erreur de compilation si un cas manque
}
```
Si un membre de l'union n'est pas traité par une branche précédente, `shape`
n'est plus de type `never` au niveau du `default` → **erreur de compilation**
(vérifié : `tsc` rejette bien un cas manquant). Lève aussi une vraie erreur au
runtime, en filet de sécurité si une valeur invalide échappe au système de
types (ex. donnée externe non validée).
Pas encore de point d'usage réel dans le code métier actuel (aucun
switch/if-chain exhaustif sur une union n'existe encore) — prêt à l'emploi dès
qu'un cas s'y prête (le module « Calcul batch-cooking » ou le pipeline d'import
de recette, tous deux encore à construire, en auront probablement).
---
## Pas de fichiers `.d.ts` écrits à la main
Voir [frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
pour le détail côté `apps/web`. Côté `apps/api` : aucune augmentation de type
globale (`declare global`) n'est utilisée — voir la section `res.locals`
ci-dessus, qui est précisément ce qui aurait nécessité ce genre de fichier.

View file

@ -70,3 +70,13 @@ Stockage de l'ensemble des données de l'application (voir le modèle de donnée
- Le module de calcul batch-cooking est le principal chantier restant côté serveur (TODO).
- Le websocket est utilisé pour la communication temps réel, en complément de l'API.
---
## Documents liés
Documentation d'implémentation (ajoutée au fil des features, complète ce document
conceptuel sans le remplacer) :
- [error-handling.md](./error-handling.md) — contrat d'erreurs partagé entre l'API et le client
- [frontend-architecture.md](./frontend-architecture.md) — organisation d'`apps/web`

176
specs/error-handling.md Normal file
View file

@ -0,0 +1,176 @@
# Gestion des erreurs — Projet Batch-cooking
> Documentation du contrat d'erreurs partagé entre `apps/api` et `apps/web`.
---
## Vue d'ensemble
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 **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/error-tools`** — package séparé, **indépendant de tout framework
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Le mapping
« erreur → `{ status, body }` » n'a rien de spécifique à Express, donc il ne vit
pas dans `express-tools`.
- **`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`) : `createErrorMiddleware` (adapte `ErrorHandlerService` à l'API
Express), `ExpressServer`, `wrapAsyncHandler`.
- **`apps/api`** — consomme les deux : lève des `HttpError` (`error-tools`), le
middleware d'erreur final n'est qu'un appel à
`createErrorMiddleware(errorHandlerService)` (`express-tools`).
- **`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 ERRTOOLS["packages/error-tools"]
HTTPERR["HttpError"]
EHS["ErrorHandlerService.handle()"]
end
subgraph TOOLS["packages/express-tools"]
MW["createErrorMiddleware()"]
end
subgraph API["apps/api"]
THROW["Route / service<br/>throw new HttpError(status, code, message)"]
THROW --> EHS
MW -->|"app.use(...)"| EHS
end
EHS -->|"JSON: { code, message, details? }"| HTTP["Réponse HTTP"]
subgraph WEB["apps/web"]
CLIENT["ApiClient<br/>lève ApiError(status, code, ...)"]
EMS["ErrorMessageService.getLabel(code)"]
I18N["i18next<br/>locales/fr/translation.json"]
UI["Composant (LoginPage, SignupPage...)"]
CLIENT --> EMS --> I18N --> UI
end
HTTP --> CLIENT
SHARED[("packages/shared<br/>ErrorCode (numérique), ApiErrorResponse")]
SHARED -. contrat .-> THROW
SHARED -. contrat .-> CLIENT
SHARED -. contrat .-> EMS
style SHARED fill:none,stroke:#888,stroke-width:1px
style ERRTOOLS fill:none,stroke:#888,stroke-width:1px
style TOOLS fill:none,stroke:#888,stroke-width:1px
```
---
## Le contrat (`packages/shared/src/errors/error-codes.ts`)
```ts
enum ErrorCode {
VALIDATION_ERROR = 4000,
EMAIL_ALREADY_IN_USE = 4001,
INVALID_CREDENTIALS = 4010,
NOT_AUTHENTICATED = 4011,
NOT_FOUND = 4040,
INTERNAL_ERROR = 5000,
}
interface ApiErrorResponse {
code: ErrorCode;
message: string; // anglais, dev-facing — jamais affiché tel quel côté UI
details?: Record<string, string[] | undefined>; // uniquement pour VALIDATION_ERROR
}
```
**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`, dans la bonne plage numérique.
2. Le lever via `new HttpError(status, ErrorCode.XXX, "message dev-facing")`.
3. Ajouter sa traduction dans **chaque** fichier `apps/web/src/locales/*/translation.json`, sous `errors.XXX`.
---
## `packages/error-tools` — les pièces liées aux erreurs, indépendantes du framework
- **`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.
**N'importe pas `express`** — c'est un service générique, indépendant du
framework HTTP, qui fonctionnerait à l'identique derrière Fastify ou autre. C'est
précisément pour ça qu'il vit dans son propre package plutôt que dans
`express-tools` : rien ici ne dépend d'Express, donc rien ici n'a sa place dans
un package *express*-tools.
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).
## `packages/express-tools` — l'adaptateur Express
`packages/express-tools` contient `ExpressServer` (init serveur, enregistrement
de routes/middlewares) et `wrapAsyncHandler` — voir
[backend-architecture.md](./backend-architecture.md) pour le détail complet du
package. La pièce qui concerne spécifiquement les erreurs :
- **`error-middleware.ts`** — `createErrorMiddleware(service: ErrorHandlerService)` :
construit le middleware d'erreur Express (signature à 4 arguments) à partir
d'un `ErrorHandlerService` importé de `@batch-cooking/error-tools` — c'est LUI
la vraie couche Express, `ErrorHandlerService` reste agnostique. `express-tools`
dépend de `error-tools`, jamais l'inverse.
## Côté API (`apps/api`)
- **`app.ts`** — le middleware d'erreur final est enregistré via
`server.setErrorHandler(createErrorMiddleware(errorHandlerService))` (voir
[backend-architecture.md](./backend-architecture.md) pour `ExpressServer`) ;
aucune logique de mapping n'y vit directement, tout est dans `error-tools`.
- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
importent `HttpError` depuis `@batch-cooking/error-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` : 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.<lng>`
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 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).

View file

@ -0,0 +1,145 @@
# Architecture frontend — Projet Batch-cooking
> Documentation de l'organisation d'`apps/web` : structure des dossiers, routing,
> gestion des erreurs, et conventions de style (SCSS/theming).
---
## Structure des dossiers
```
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 — code d'erreur → clé i18next
├── features/
│ └── auth/ # tout ce qui concerne l'authentification
│ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout)
│ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
│ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
│ └── auth-form.scss # styles partagés par LoginPage et SignupPage
├── pages/
│ ├── LoginPage.tsx / .scss (via auth-form.scss, partagé)
│ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé)
│ └── HomePage.tsx + HomePage.scss
├── styles/
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx)
├── lib/
│ └── zod-errors.ts # utilitaire : erreurs zod → { champ: message }
├── App.tsx # table de routes
└── 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
dans un fichier `.scss` au même niveau que ce composant (`HomePage.tsx` +
`HomePage.scss`). Un style partagé par plusieurs composants d'une même feature vit
dans le dossier de la feature (`features/auth/auth-form.scss`, utilisé par
`LoginPage` et `SignupPage`). Seuls le reset et les tokens globaux vivent dans
`styles/`.
---
## Routing et gardes d'authentification
```mermaid
flowchart TB
START(("Visite de l'app"))
CHECK{"AuthProvider :<br/>GET /auth/me"}
START --> CHECK
CHECK -->|"200 (session valide)"| AUTHED["user défini"]
CHECK -->|"401 (pas de session)"| ANON["user = null"]
AUTHED --> ROUTE_HOME["/ → HomePage"]
AUTHED --> ROUTE_LOGIN_A["/login ou /signup"]
ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME
ANON --> ROUTE_HOME_A["/"]
ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"]
ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"]
```
- `AuthContext` (`features/auth/AuthContext.tsx`) appelle `GET /auth/me` une seule
fois au montage pour restaurer la session depuis le cookie httpOnly — c'est ce qui
permet à un rechargement de page de garder l'utilisateur connecté.
- `RequireAuth` et `RedirectIfAuthenticated` sont deux gardes de route
(`react-router-dom`) qui lisent cet état : la première protège `/`, la seconde
protège `/login` et `/signup` (redirige un utilisateur déjà connecté vers `/`).
Les deux affichent `null` tant que la vérification initiale est en cours, pour
éviter un flash de contenu suivi d'une redirection.
---
## Client API et gestion des erreurs
Voir [error-handling.md](./error-handling.md) pour le détail du contrat d'erreurs
partagé avec l'API. En résumé côté frontend :
- `ApiClient` (`api/client.ts`) — classe avec instance unique exportée
(`apiClient`), enveloppe `fetch` avec `credentials: "include"` (requis pour que
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`) — 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/<lng>/translation.json` avec les mêmes clés,
ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.
---
## Note sur les fichiers `.d.ts`
Aucun fichier `.d.ts` écrit à la main dans `apps/web` : le
`/// <reference types="vite/client" />` 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é.
Côté `apps/api`, aucune augmentation de type globale n'est utilisée du tout — voir
[backend-architecture.md](./backend-architecture.md#auth--reslocals-pas-daugmentation-du-namespace-express)
: le profil authentifié passe par `res.locals` (mécanisme natif d'Express), pas
par un `declare global` sur `Express.Request`.
---
## SCSS et theming
- **`sass`** (Dart Sass) est utilisé via le support natif de Vite — aucune config
supplémentaire needed au-delà d'avoir le package installé (`vite.config.ts` fixe
juste l'API moderne de Sass pour éviter un warning de dépréciation).
- **`styles/_theme.scss`** — tokens de design exposés en **custom properties CSS**
sur `:root` (`--color-primary`, `--space-md`, etc.), pas en simples variables
SCSS : ça les rend disponibles au runtime, pas seulement à la compilation — ce qui
permettrait un futur switch de thème (ex. mode sombre) en redéfinissant juste ces
variables, sans reconstruire les feuilles de style. Toute nouvelle règle CSS doit
référencer `var(--token)`, jamais une couleur/valeur en dur.
- **`styles/global.scss`** — importé une seule fois, dans `main.tsx`. Contient
uniquement le reset minimal et l'import du thème (`@use "./theme"`). Rien de
spécifique à une page/un composant n'y va.
- Les tokens étant des **custom properties CSS** (pas des variables Sass), ils sont
disponibles globalement au runtime dès que `global.scss` a été chargé une fois —
un fichier `.scss` de composant/page les consomme directement via `var(--token)`,
sans avoir besoin de `@use` le partiel theme (ce serait un import sans effet,
puisqu'aucun symbole Sass n'en est consommé). Chaque fichier documente en
commentaire à quoi correspond chaque règle un peu non-triviale.