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.
## 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.
* Add login/signup UI (apps/web)
Wires the frontend to the existing auth API: signup, login, logout,
session restore on load.
- src/api/client.ts — fetch wrapper, credentials: "include" (required
for the httpOnly session cookie — api and web run on different
origins)
- src/features/auth/AuthContext.tsx — global auth state; calls
GET /auth/me on mount to restore the session from the cookie
- src/features/auth/RequireAuth.tsx / RedirectIfAuthenticated.tsx —
react-router-dom route guards (/ requires auth, /login and /signup
redirect away if already authenticated)
- src/pages/{Login,Signup,Home}Page.tsx — forms with client-side
validation via the shared zod schemas, API errors displayed as-is
Moves signupSchema/loginSchema from apps/api into packages/shared
(new SafeUserProfile type too) so frontend and backend validate with
the exact same rules — this is what that package was scaffolded for.
apps/api's auth.schema.ts is gone, auth.routes.ts/auth.service.ts now
import from @batch-cooking/shared directly.
Translated all user-facing API error messages and zod validation
messages to French (were English, inconsistent with the rest of the
UI) — found by actually clicking through the flow in a browser, not
just reading the code.
zod pinned to the same v3 range across api/web/shared on purpose:
apps/web's `pnpm add zod` initially resolved v4, which would have let
a major-version mismatch slip in silently (zod v3 and v4 aren't drop-in
compatible) since shared's schemas are built with v3.
Cypress specs updated for the new routing (unauthenticated visitors
now land on /login, not the old placeholder) and a new auth.cy.ts
mocking the API via cy.intercept — the e2e CI job has no live
backend, so these test frontend behavior only. Real API behavior is
covered by apps/api's Mocha/Cucumber suites against a real database.
Verified end-to-end in a real browser (not just curl): signup, session
persistence across reload, logout (confirmed the cookie was actually
cleared server-side, not just client state), wrong-password error
display, client-side validation blocking short passwords without a
network round trip, duplicate-email conflict. Full lint/mocha/
cucumber/build suite green.
* Fix packages/shared: build to dist/ instead of shipping raw TS
Found by Docker-packaging apps/api and actually running the container:
it crash-looped with "Cannot find module
'/repo/packages/shared/src/schemas/auth.js'" — Node's plain ESM loader
(node dist/server.js, no tsx/ts-node registered) can't execute .ts
source files.
This was invisible everywhere else: tsx (dev, mocha, cucumber) and
Vite both transpile TS on the fly regardless of what package.json
points to, so every dev/test/build path masked the problem. The
Docker container is the first place this code path actually runs
through a plain Node runtime — exactly the kind of thing "package
every change in Docker and run it" is supposed to catch.
Fix: give packages/shared a real build (tsc emitting to dist/, with
.d.ts), and point package.json's main/types/exports at dist/ instead
of src/index.ts. Added as a postinstall (same pattern as apps/api's
`prisma generate`) so dist/ regenerates automatically after any
`pnpm install`; after editing packages/shared's source directly,
`pnpm --filter shared build` (or `pnpm build`) is needed before the
change is visible to consumers pointing at the compiled dist/.
Verified: `node dist/server.js` (plain node, no tsx — mirrors exactly
what the Docker container runs) starts and responds on /health.
Rebuilt the Docker images and re-ran a full signup through the
containerized stack end-to-end. Full lint/mocha/cucumber/build suite
still green.
apps/api/Dockerfile: multi-stage build on node:22-slim (not alpine —
avoids musl-vs-glibc native binding surprises for argon2/Prisma's
engine binaries; same base image family for build and runtime stages
keeps "native" binaries compatible across stages). Runtime stage
copies the monorepo structure as-is rather than flattening to a single
package, so pnpm's symlinked node_modules stay valid. Container runs
`prisma migrate deploy` on startup before starting the server, so the
review environment's schema is always in sync automatically.
Installs openssl explicitly in the base image: without it, Prisma
can't detect the right engine binary and silently defaults to a guess
that may not match what's actually on the image — caught by checking
the build log, not just a successful build.
apps/web/Dockerfile: builds with Vite, serves the static output via
nginx (not a Node static server) — avoids the devDependency problem of
needing `vite preview` in a --prod-deployed image, and is the more
standard way to serve a built SPA. nginx.conf has an SPA fallback
(try_files ... /index.html) ready for when client-side routing lands.
docker-compose.yml: adds `api` and `web` services alongside the
existing `postgres`. api's DATABASE_URL targets the `postgres` service
name over the compose network (not localhost/POSTGRES_PORT, which is
only the host-side mapping). Both new services require JWT_SECRET/
ports via env vars with no defaults, consistent with the project's
existing no-hardcoded-credentials rule.
.dockerignore added — without it, the Windows-built node_modules
(with Windows-specific native binaries) would get copied into the
Linux build context.
Verified: full build (api + web images), `docker compose up -d`
brought up all three containers, curled /health and the web root,
ran a real signup through the containerized stack end-to-end.
* Scaffold generic pnpm monorepo (api + web + shared)
Sets up the initial project infrastructure only, no business modules yet:
- apps/api: Express/TypeScript backend skeleton (healthcheck route, zod-validated
env config, error handling, Prisma initialized with no models yet, Postgres
as the target DB)
- apps/web: React/Vite/TypeScript frontend skeleton, Capacitor-ready for the
future mobile app
- packages/shared: empty placeholder for types/schemas shared between api and
web once the data model is defined
- Tooling: Biome (lint/format), Mocha+Chai+Supertest (api tests), Cypress
(web e2e smoke test), GitHub Actions CI (lint + test + build + e2e)
- docker-compose.yml for local Postgres
- README documents setup steps, including the Cypress binary caveat (pnpm
install doesn't always fetch the native binary — needs `cypress install`
run locally per machine)
Fixes along the way:
- apps/web/cypress.config.ts: disable GPU on browser launch for
headless/sandboxed environments
- apps/web/tsconfig.*: split into solution/app/node tsconfig files (standard
Vite pattern) — the previous single-file setup caused `tsc -b` to emit
compiled .js/.d.ts next to vite.config.ts and cypress.config.ts
* Remove hardcoded credentials from committed env/compose files
.env.example and apps/api/.env.example had a real usable default
credential pair (batchcooking/batchcooking) baked in, and
docker-compose.yml fell back to the same values via ${VAR:-default}
if .env was missing. Neither should ship a working credential:
- .env.example / apps/api/.env.example now use "changeme" placeholders
that must be edited before use.
- docker-compose.yml uses ${VAR:?...} instead of ${VAR:-default} for
POSTGRES_USER/PASSWORD/DB, so compose fails loudly if .env isn't set
up rather than silently falling back to a guessable credential.
Healthcheck reads the container's own env var ($$POSTGRES_USER)
instead of duplicating the value in the compose file.
- README updated to say .env.example must be edited, not just copied.
Verified: `docker compose config` fails with a clear message when
.env is absent, and resolves correctly once .env is filled in.
* Fix CI: remove pnpm version conflict with packageManager field
pnpm/action-setup@v4 errored with "Multiple versions of pnpm
specified" because the workflow pinned version: 10 while
package.json's packageManager field pins pnpm@10.12.4. The action
already reads packageManager automatically, so drop the redundant
version input.
* Fix CI: install Cypress binary explicitly before running e2e
Same root cause as the README caveat: pnpm install doesn't reliably
trigger Cypress's postinstall binary download, so `cypress run` failed
in CI with "The cypress npm package is installed, but the Cypress
binary is missing." Add an explicit `cypress install` step, and cache
~/.cache/Cypress keyed on the lockfile so subsequent runs don't
re-download it.
* Fix Cypress config loading: give the solution tsconfig a module system
apps/web/tsconfig.json (the tsc -b "solution" file) had no
compilerOptions, only files/references. Cypress's bundled ts-node
picks the nearest tsconfig.json to transpile cypress.config.ts, and
with no "module" specified it defaulted to CommonJS while
package.json declares "type": "module" — causing:
ReferenceError: exports is not defined in ES module scope
Adding module/moduleResolution to the solution config (harmless for
tsc -b itself, since it only builds the referenced projects) fixes
the mismatch. This regressed after the earlier fix for the stray
vite.config.js emission and was never re-verified against Cypress
until CI caught it.