batchCooking/specs/frontend-architecture.md
Nicolas 3a5dc83bf9 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.
2026-08-16 17:00:23 +02:00

7 KiB

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

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