Expose côté UI ce que tech-step-matcher.ts détecte déjà à la sauvegarde
(Step.techSteps) mais qui restait backend-only : dans le panneau détail
d'une recette, les mots exacts ayant déclenché une technique sont
surlignés, avec un tooltip (survol/focus clavier) donnant son nom.
- tech-step-matcher.ts : matchTechStepSpans(description, mappings) expose
désormais {techStepId, start, end} en plus de la simple séquence d'ids
(déjà calculé en interne, jusqu'ici jeté). matchTechSteps devient un
wrapper fin dessus — aucun changement à ses ~12 tests existants ni à
recipe-translation.ts.
- StepTechStep gagne start/end (nullable, pas de backfill — même leçon que
l'incident de migration ingredient_unit_catalog : NOT NULL sans défaut
sur une table déjà peuplée casse le déploiement). Une ligne pré-existante
sans span est simplement omise de la réponse API plutôt que de fuiter un
null, jusqu'à ce que la recette soit resauvegardée.
- recipe.service.ts : createRecipe/updateRecipe persistent start/end ;
StepView expose techSteps: { techStep: {id,key}, start, end }[]. Le
recalcul complet à chaque édition (ajout/modif/suppression d'étape) était
déjà garanti par le delete-then-recreate existant d'updateRecipe — testé
explicitement (nouveau test "recomputes techniques from scratch...").
- Frontend : StepDescription.tsx (découpe le texte via
highlight-tech-steps.ts, pur et testé) remplace le <p> brut dans
RecipeDetailPanel. Nouveau Tooltip.tsx (composants/ui, CSS pur, aucune
lib externe — même esprit que Dialog.tsx) : un <button> (focusable
nativement, pas de tabIndex sur un <mark> non interactif) affiche le nom
de la technique (catalog.techSteps.<key>) au survol/focus.
Tests : matchTechStepSpans (spans corrects, chevauchement résolu),
recipe.test.ts (forme API + recalcul complet sur modif/ajout/suppression
d'étape, avec vérification que les anciennes lignes StepTechStep sont bien
supprimées), splitDescriptionByTechSteps (tri, bornes invalides ignorées,
chevauchement résiduel ignoré), scénario Cypress recipes.feature
(surlignage + tooltip au focus).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Ajoute une expression régulière anglaise à chacun des 26 TechStepMapping
du catalogue (locale "en"), en plus du "fr" existant — les recettes en
anglais (TheMealDB, etc.) peuvent désormais matcher leurs étapes.
- Nouveau apps/api/src/lib/ingredient-matcher.ts : moteur de matching pur
(nom d'ingrédient, unité, quantité) contre les catalogues Ingredient/Unit,
à partir de labels anglais écrits à la main (packages/shared/src/data/
catalog-labels-en.ts — 546 INGREDIENT_LABELS_EN + 17 UNIT_LABELS_EN avec
synonymes/abréviations). Tokenise et stem naïvement les deux côtés pour
tolérer pluriels et mots descriptifs superflus ; la correspondance la
plus spécifique (le plus de mots) l'emporte en cas de recoupement.
- extractQuantity() : lit un nombre en tête de texte libre (entier,
décimal, fraction simple ou nombre mixte) pour déduire la quantité et
l'unité quand la source ne les fournit pas séparément.
- Étend recipe-translation.ts : translateRecipe(recipe, locale) résout
aussi ingredientId/unitId/quantity de chaque ligne d'ingrédient — mais
uniquement pour locale "en" (seules langue avec des labels), pour ne pas
interroger la base inutilement ni halluciner un match dans une autre
langue.
- Ajoute cup/ounce/pound au catalogue Unit (toBaseFactor réel), absents
jusqu'ici alors que très fréquents dans les recettes anglaises.
- Vérifié en conditions réelles contre TheMealDB (Teriyaki Chicken
Casserole) : 8/9 ingrédients résolus avec la bonne quantité/unité, le
seul raté ("stir-fry vegetables") étant un mélange sans entrée dédiée au
catalogue — dégradation gracieuse (unitId/ingredientId: null) comme prévu.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ajoute la brique "Traduction en étapes" du pipeline d'import décrit
dans specs/batch-cooking-architecture.md — prend un ParsedRecipe
(sortie de parse() d'un adaptateur, recipe-source-adapter.ts) et
déclare, pour chaque étape, sa séquence de tech steps détectée.
- recipe-translation.ts : TranslatedRecipe/TranslatedRecipeStep
(ParsedRecipe/ParsedRecipeStep + techStepIds: number[], même forme
que Step.techSteps/StepTechStep). translateRecipeSteps() est pure
(prend les mappings en argument, comme matchTechSteps lui-même) ;
translateRecipe() est le wrapper qui charge le catalogue depuis la
DB pour une locale donnée — même séparation pur/DB que
tech-step-matcher.ts.
- Ne touche pas aux ingrédients (résolution vers Ingredient/Unit
toujours hors scope) ni ne produit une Recipe sauvegardable (pas de
dietIds/visibility/auteur) — une seule brique du pipeline, pas tout
le pipeline.
- Documente explicitement la limite actuelle : le catalogue de tech
steps n'a que des mappings "fr", donc une source anglophone comme
TheMealDB traduite avec cette locale obtient des séquences vides
sur toutes ses étapes (vérifié par un test dédié avec du texte
réel de TheMealDB).
8 nouveaux tests (partie pure + partie DB avec le vrai catalogue
"fr" seedé). 180 tests passent au total. Build et lint propres.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Marmiton s'est avéré inaccessible pour du scraping (bloqué même via
WebFetch, signe de protection anti-bot) — TheMealDB (themealdb.com)
est une vraie API JSON publique et gratuite, sans scraping, testée
en conditions réelles (list → fetchDetail → parse fonctionnent
bout en bout contre l'API live).
- Source.iconUrl (nullable) + RecipeSourceAdapter.iconUrl (requis,
même convention que `official`) synchronisé par syncRecipeSources.
- apps/api/src/sources/the-meal-db.ts : premier RecipeSourceAdapter
réel — official: true (API officielle, pas de scraping), utilise
fetch natif (aucune dépendance ajoutée). list() fait une recherche
(pas de vrai "browse" côté TheMealDB, mais une requête vide renvoie
un échantillon de secours) ; parse() éclate les instructions en
étapes par ligne et ignore les emplacements d'ingrédients vides.
- apps/api/src/sources/index.ts : registerAllRecipeSources(), appelé
par server.ts (process réel) et prisma/seed.ts — délibérément PAS
importé par app.ts, pour ne jamais dépendre de l'ordre des tests.
- SourceSelect (web) affiche désormais le logo de la source à côté
de son nom.
Vérifié en conditions réelles : seed → table sources peuplée avec le
vrai logo TheMealDB ; endpoint /reference/sources sur serveur réel ;
parcours navigateur complet (onboarding → étape sources visible avec
icône chargée → activation → paramètres foyer reflète le choix).
186 tests passent (16 nouveaux, dont le moteur TheMealDB testé avec
un stub de fetch — aucun appel réseau réel dans la suite automatisée).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Répond à deux besoins : permettre à chaque foyer de choisir quelles
sources apparaissent dans ses onglets de recettes, et distinguer les
sources à API officielle des sources scrapées.
- RecipeSourceAdapter.official (booléen, sans défaut — chaque
adaptateur doit le déclarer explicitement) synchronisé sur
Source.official par syncRecipeSources.
- HouseSource : table de jointure opt-in (House <-> Source) — aucune
ligne = source masquée. Un foyer nouvellement créé ne voit aucune
source tant qu'il ne les active pas explicitement.
- GET /reference/sources (catalogue des sources implémentées, avec le
flag officiel).
- GET/PATCH /house/current/sources (lecture/remplacement complet des
sources activées par le foyer courant).
- recipe.service.ts : sourceVisibilityWhere() filtre désormais TOUS
les onglets (perso/foyer/publique/favoris) — une recette sans
source reste toujours visible ; une recette importée ne l'est que
si sa source est activée pour le foyer du viewer. Un viewer sans
foyer ne voit aucune recette sourcée.
Côté web :
- Nouvelle étape /onboarding/sources dans le wizard d'inscription,
atteinte uniquement si un foyer vient d'être créé/rejoint (sinon on
saute direct aux allergènes) ; s'auto-saute aussi si aucune source
n'est encore implémentée (catalogue vide aujourd'hui).
- Nouvelle section « Sources de recettes » dans /parametres/foyer
(masquée dans les mêmes conditions), avec sauvegarde à la volée
(même pattern que les autres préférences hot-saved).
- SourceSelect (features/house/), grille de cases à cocher avec badge
officiel/non-officielle, sur le même principe qu'AllergySelect.
172 tests backend passent (dont 25 nouveaux). Build et lint propres.
Vérifié manuellement en navigateur : le parcours d'onboarding saute
bien l'étape sources (catalogue vide) et affiche « 4 sur 4 » quand un
foyer a été créé ; la section paramètres reste invisible tant
qu'aucune source n'existe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Corrige le modèle de données suite à une review sur la PR #34 :
"Dans une poêle chaude, faire chauffer une noix de beurre" combine
deux techniques (preheat + melt), or Step.techStepId ne pouvait en
porter qu'une seule (FK simple nullable).
- Step.techStepId (FK simple) remplacé par StepTechStep, une table de
jointure ordonnée (stepId, techStepId, order) — @@id([stepId,
order]) garantit une séquence propre par étape.
- tech-step-matcher.ts : matchTechStep(...) → number|null devient
matchTechSteps(...) → number[]. Nouvel algorithme : chaque mapping
qui matche devient un candidat avec sa position dans le texte ; on
garde le meilleur candidat par technique (poids, puis position),
on résout les chevauchements entre techniques différentes par poids
décroissant (ex: "cuire au four" ne garde que `bake`, pas `cook` en
plus), puis on trie le résultat par ordre d'apparition dans le
texte — une séquence qui se lit dans le même ordre que l'instruction.
- Ajout de la technique "melt" (faire fondre) au catalogue, pour
pouvoir tester le cas concret du commentaire de review de bout en
bout (préchauffer + faire fondre).
- recipe.service.ts : câble StepTechStep via un create imbriqué à la
place du champ scalaire.
Tests étendus dans tech-step-matcher.test.ts (séquences non
chevauchantes, résolution de chevauchement combinée à une technique
distincte, etc.) et recipe.test.ts (nouveau test de bout en bout avec
deux techniques dans une même étape). 133 tests passent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Répond au besoin identifié précédemment : la table `sources` devient
un vrai catalogue des sources implémentées, et une recette importée
pourra être reliée à l'item source dont elle provient.
- Source.key (unique) — même convention que Diet.key/Unit.key/
TechStep.key. Le catalogue est désormais synchronisé depuis le
registre d'adaptateurs (recipe-source-registry.ts) via
syncRecipeSources() (nouveau apps/api/src/db/recipe-source-sync.ts),
plutôt que maintenu à la main comme DIETS/UNITS — reste vide tant
qu'aucun adaptateur concret n'est enregistré.
- Recipe.externalId (nullable) — l'identifiant de la recette côté
source. Contrainte @@unique([sourceId, externalId]) : empêche
d'importer deux fois la même recette (les recettes manuelles, aux
deux colonnes nulles, ne sont jamais en conflit entre elles).
- findImportedExternalIds(prisma, sourceKey, externalIds) — le
pendant DB de markAlreadyImported (recipe-source-adapter.ts),
ferme la boucle commencée dans la PR précédente pour distinguer les
recettes déjà intégrées lors du browse.
- syncRecipeSources() appelé après seedReferenceData() dans
prisma/seed.ts et test-support/reset-db.ts.
Toujours pas de route HTTP ni de champ sourceId/externalId exposé
dans createRecipeSchema — la sauvegarde effective d'une recette
importée reste pour une PR ultérieure.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ajoute markAlreadyImported(items, importedExternalIds) et le type
BrowsableRecipeItem à recipe-source-adapter.ts : quand on parcourt le
catalogue d'une source (list()), on peut désormais annoter chaque
item pour savoir s'il correspond à une recette déjà intégrée dans
notre base ou non.
Reste une fonction pure, volontairement séparée de list() : un
adaptateur ne connaît que sa source, jamais notre base — même
séparation I/O/pur que tech-step-matcher.ts. La constitution du set
d'externalId déjà importés (où/comment on persiste ce lien) est
laissée à une future couche, pas encore décidée.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pose les bases du pipeline d'import décrit dans
specs/batch-cooking-architecture.md (Import depuis source → Traduction
en étapes → Sauvegarde), en commençant par le premier maillon :
récupérer et parser des recettes brutes depuis une source externe,
indépendamment du site/API concerné.
- RecipeSourceAdapter<TRawDetail> (recipe-source-adapter.ts) : contrat
générique par source — list() pour parcourir un catalogue de façon
paginée (l'utilisateur "browse" les recettes disponibles), puis
fetchDetail(externalId) une fois une recette sélectionnée, puis
parse(raw) pour la transformer en ParsedRecipe. parse() est pure et
synchrone (même séparation I/O vs pur que tech-step-matcher.ts), ce
qui la rend testable sans réseau.
- ParsedRecipe est volontairement distinct de CreateRecipeInput : les
ingrédients restent en texte libre (pas d'ingredientId/unitId) — la
résolution vers nos catalogues Ingredient/Unit est un sujet séparé,
pas encore construit.
- recipe-source-registry.ts : registre en mémoire des adaptateurs,
identifiés par une clé stable (même convention que Diet.key/
Unit.key/TechStep.key), distinct de la table Source (schema.prisma)
qui documente la provenance d'une recette déjà sauvegardée.
- recipe-source-errors.ts : RecipeSourceFetchError/RecipeSourceParseError,
vocabulaire d'erreur dédié en attendant qu'une route les traduise en
HttpError/ErrorCode.
Pas de route HTTP, pas d'écriture en base, pas d'implémentation
concrète pour l'instant — uniquement le module générique, validé par
un adaptateur factice dans les tests. Le câblage (endpoint, sourceId,
un vrai parseur) sera une PR suivante.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rend opérationnel le squelette TechStep/TechStepMapping/Step.techStepId
présent dans le schéma depuis le premier commit mais jamais implémenté :
- TechStep gagne un `key` unique (camelCase, même convention que
Diet/Unit) ; TechStepMapping gagne un `locale` pour pouvoir porter
plusieurs jeux de règles de matching par langue.
- Catalogue statique de 25 techniques françaises courantes (Cuire,
Frire, Déglacer, Mijoter, ...), chacune associée à une ou plusieurs
expressions régulières + un poids, seedées de façon idempotente dans
reference-seed-data.ts.
- Nouveau moteur de matching (apps/api/src/lib/tech-step-matcher.ts) :
normalisation accents/casse (NFD) puis test des expressions,
résolution du meilleur match par poids. Pur et testé unitairement.
- Câblé dans recipe.service.ts : à la création/modification d'une
recette, chaque étape voit son techStepId calculé automatiquement à
partir de sa description (locale "fr" en dur pour l'instant, faute
de préférence de langue utilisateur dans l'app).
- Reste backend-only : StepView n'expose pas encore techStepId,
conformément au commentaire existant.
- Endpoint GET /reference/tech-steps + TechStepView, en cohérence avec
les autres catalogues de référence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- GET/PATCH /house/current — renomme le foyer de l'utilisateur connecté.
PATCH avec houseId null -> 404 HOUSE_NOT_FOUND.
- PATCH /profile/diet { dietId: number | null } — régime du profil ;
null l'efface (étape skippable du parcours). dietId invalide ->
404 DIET_NOT_FOUND.
- GET/PATCH /profile/allergies — allergènes/intolérances, liste d'IDs ;
PATCH remplace l'ensemble complet (pas une fusion, cohérent avec un
multi-select). ID invalide -> 404 ALLERGY_NOT_FOUND.
- 3 nouveaux ErrorCode (4041-4043) + libellés fr.
- Extraction de toSafeProfile() dans src/lib/safe-profile.ts —
auparavant dupliqué dans auth.service.ts et require-auth.ts,
profile.service.ts le réutilise aussi.
- Tests Mocha (28 passing) + Cucumber (15 scenarios) — même convention
que le reste, doc README.
Deuxième commit de la feature profil/foyer/régime/allergènes —
composants front partagés dans le commit suivant.
* 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>
* Add signup/login (profile creation + JWT auth)
API:
- POST /auth/signup — creates a house + user_profile (transactional),
hashes the password with argon2, sets a JWT in an httpOnly cookie
- POST /auth/login — verifies credentials (generic 401 for both wrong
email and wrong password, doesn't leak which), sets the cookie
- POST /auth/logout — clears the cookie
- GET /auth/me — current profile, behind requireAuth middleware
- requireAuth verifies the JWT and re-checks tokenVersion against the
DB, so a stateless JWT can still be invalidated (password change /
logout-everywhere, not built yet but the field is in place)
Schema: user_profiles gets password_hash + token_version (not in the
original spec doc — required for auth). New migration, with
COMMENT ON for the new columns per the established pattern.
Decisions from the auth planning discussion: JWT in httpOnly cookie
(not server-side sessions), first profile created also creates its
house, argon2 for hashing.
argon2 pinned to 0.31.2 (not ^, deliberately): 0.45.1 segfaults at
runtime on this Windows machine — reproduced consistently across bash
(sandboxed and unsandboxed) and PowerShell, while 0.31.2 works fine
with the same API. Documented in the README as a trap for future
upgrades, since `tsc`/`prisma generate` succeeding doesn't catch a
runtime native-binding crash.
Tests: Mocha (unit-style, apps/api/test/auth.test.ts) and a Cucumber
feature (apps/api/features/auth.feature) covering the full signup →
authenticated flow, duplicate email, wrong password. Both share
test-support/reset-db.ts (TRUNCATE ... CASCADE) to start each
test/scenario from a clean slate. Test-only argon2 cost parameters
(NODE_ENV=test) keep the suite fast — argon2's real cost is
deliberately expensive, which made hashing dozens of times per run
slow and occasionally timeout-flaky at default cost.
CI: added a Postgres service container to lint-and-test (previously
none — tests didn't touch a real DB), runs `prisma migrate deploy`
before the test steps.
Verified end-to-end manually against the dev server (curl): signup,
duplicate email (409), wrong password (401), valid login (200),
validation errors (400), /me with and without cookie, logout (204) —
all behave as intended. Full suite (lint, mocha, cucumber, build) run
multiple times locally with no flakiness after the timeout/cost fixes.
* Fix CI: generate Prisma Client via postinstall
CI failed with "@prisma/client did not initialize yet" — pnpm install
never ran `prisma generate`, and `prisma migrate deploy` (unlike
`migrate dev`) doesn't do it either. Worked locally only because prior
`prisma migrate dev` runs had already generated the client as a side
effect.
Adding a postinstall script fixes it for CI and for anyone cloning the
repo fresh and running plain `pnpm install`.