Compare commits

..

No commits in common. "main" and "feat/recipe-source-adapter" have entirely different histories.

544 changed files with 3376 additions and 67063 deletions

View file

@ -6,12 +6,6 @@
"runtimeExecutable": "pnpm", "runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "web", "dev"], "runtimeArgs": ["--filter", "web", "dev"],
"port": 5173 "port": 5173
},
{
"name": "api",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "api", "dev"],
"port": 3000
} }
] ]
} }

View file

@ -15,66 +15,9 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# (docker-compose.yml), serving both the API and the built frontend. # (docker-compose.yml), serving both the API and the built frontend.
# APP_PORT=3000 # APP_PORT=3000
# Optional — tag of the prebuilt images the release CI pushes to the Forgejo
# container registry (git.dev.kyuno.fr/kyuno/batchcooking-{app,intent-service,
# llm-worker}). Used by `docker compose pull` and a pull-based Portainer
# stack; leave it `latest` or pin a release tag (e.g. v0.2.4). Ignored when
# building locally instead (`docker compose build`, or `up` with no image
# present) — see docker-compose.yml's `image:`/`build:` comment.
# IMAGE_TAG=latest
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
# All optional: an instance that doesn't run the admin app needs none of
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
# leaving it out simply disables every /admin/* route. The admin UI is
# served by the same app/container as the rest of the frontend — no
# separate origin, so no CORS entry of its own.
#
# Secret for the admin session JWT — MUST be different from JWT_SECRET so an
# end-user token can never be replayed against /admin/*. Generate your own
# the same way as JWT_SECRET above.
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — read only by `src/scripts/create-admin.ts` when its --email /
# --password / --name flags are omitted (e.g. to bootstrap the first admin
# from inside the container). Never read by the running server.
# ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops
#
# Optional — lets an admin open a Forgejo ticket from a contact message
# (apps/web's /admin/messages, issue #81). All three needed together: the
# button stays hidden and the endpoint answers 503 unless all are set to a
# non-empty value. docker-compose.yml forwards these into the "app"
# container, so setting them here is enough (a blank value counts as
# unset). FORGEJO_TOKEN is a personal access token with the write:issue
# scope — generate it in Forgejo, never commit the real value.
# FORGEJO_BASE_URL=https://git.example.com
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
# FORGEJO_REPO=owner/repo
# Optional — only set this to false if THIS deployment is served over # Optional — only set this to false if THIS deployment is served over
# plain HTTP (no TLS in front of it). Left unset, the session cookie # plain HTTP (no TLS in front of it). Left unset, the session cookie
# requires HTTPS (Secure attribute) as it should for a real deployment; # requires HTTPS (Secure attribute) as it should for a real deployment;
# over plain HTTP a Secure cookie is silently never sent back by the # over plain HTTP a Secure cookie is silently never sent back by the
# browser, so login "succeeds" but every subsequent request 401s. # browser, so login "succeeds" but every subsequent request 401s.
# COOKIE_SECURE=false # COOKIE_SECURE=false
# Required — secret shared between "app" and "tech-step-intent-service"
# (docker-compose.yml, apps/api/src/config/env.ts). Unlike
# INTERNAL_WORKER_SECRET below, there's no "leave it unset" escape hatch:
# tech-step-intent-service is a core dependency, not an optional background
# job — without it, no recipe step can have its techniques detected at all.
# Generate your own the same way as JWT_SECRET above.
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Only needed to run the optional `tech-step-llm-worker` service — shared
# between it and "app" (docker-compose.yml). Generate your own the same
# way as JWT_SECRET above; leave both this and the service commented
# out/unset to run without it.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — cron expression (node-cron syntax) the worker wakes up on to
# run its audit/feedback-loop jobs. Default: weekly, Sunday 03:00 — a
# provisional floor, not a calibrated value (see
# services/tech-step-llm-worker/README.md).
# TECH_STEP_WORKER_CRON=0 3 * * 0

View file

@ -12,53 +12,22 @@ on:
push: push:
env: env:
# Hostname `postgres` = the container the `test` job starts itself (see DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
# that job's "Start Postgres" step) — a plain `docker run`, not a
# `services:` block, for the same reason the intent-service below can't be
# one: this repo's Forgejo `act` runner does not keep a `services:`
# container as the *same* instance across a job's steps, so `migrate
# deploy` would land the schema in one Postgres and `pnpm --filter api
# test`'s `beforeAll` would then hit an empty one ("table … does not
# exist" right after "migrations successfully applied"). One long-lived
# container in the job, shared by every step, removes that failure mode.
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
# Test-only secret, never used outside CI — real deployments must set their own. # Test-only secret, never used outside CI — real deployments must set their own.
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+" JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
# exercise the success path (matching secret), not just the "unset"
# rejection every environment that doesn't set this gets by default.
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
# Same reasoning — lets admin-auth.test.ts exercise the admin login
# success path + the requireAdmin-guarded routes, not just the "no admin
# secret configured -> 401" path.
ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+"
# Shared between the `test` job's own uvicorn step (below) and apps/api's
# IntentServiceClient — that step runs `uvicorn` as a plain background
# process (not a `services:` block or even a container) because CI can
# only pull a published image, not build
# services/tech-step-intent-service/Dockerfile from this checkout.
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
# Same reasoning — lets forgejo.service.test.ts / admin-contact.test.ts
# exercise the "configured" path (issue #81). `globalThis.fetch` is stubbed
# in those tests, so no real request ever leaves CI; these values only need
# to parse (a valid URL and an `owner/repo`).
FORGEJO_BASE_URL: "https://forgejo.ci.invalid"
FORGEJO_TOKEN: "ci-only-forgejo-token-not-used-anywhere-else"
FORGEJO_REPO: "ci/batchcooking"
jobs: jobs:
# Five independent jobs, no needs: between them — each starts in parallel # Four independent jobs, no needs: between them — each starts in parallel
# and reports as its own check, instead of the previous single chained # and reports as its own check, instead of the previous single chained
# "lint-and-test then e2e" pipeline. # "lint-and-test then e2e" pipeline.
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
@ -68,166 +37,42 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# ~16-19 min of actual work (runs 117-124); the extra budget covers time services:
# spent queued in "Wait for any earlier test job" below. Also a runaway postgres:
# ceiling — Mocha has no `--exit` (`apps/api/.mocharc.json`), so one image: postgres:16-alpine
# leaked handle would otherwise hang the job until the runner's global env:
# timeout (a `test` job zombied ~1h once). If a hung job hits this POSTGRES_USER: ci
# ceiling the runner removes its container, which unblocks the queue. POSTGRES_PASSWORD: ci
timeout-minutes: 45 POSTGRES_DB: batchcooking_ci
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps: steps:
- name: Install curl and the docker CLI - uses: actions/checkout@v4
run: |
apt-get update && apt-get install -y curl docker.io
# Serialize `test` across every concurrent run. Two `test` jobs must
# never overlap: they share one `--name postgres` container on the
# runner's shared docker network (next step), so their `prisma migrate
# deploy` + `seedReferenceData` (root Mocha `beforeAll`) race —
# `Unique constraint failed on (key)` in `seedReferenceData`, "No
# pending migrations" when there should be some, `tech_step`/`diet` id
# shifts breaking unrelated assertions; and even past that they starve
# each other's RAM/CPU (each also trains a spaCy model) until
# `resetDatabase()` overruns Mocha's 10s hook timeout and cascades.
# Hit repeatedly (PRs #47, #53, #54), each needing an empty-commit
# re-trigger once the runner was idle.
#
# GitHub's `concurrency:` is the native fix but this Forgejo (9.0.3)
# predates Actions `concurrency` — it arrived in Forgejo v14 / Gitea
# 1.26 and is silently ignored here. A lock object (network, container,
# file) would leak: this runner (forgejo-runner v3.5.0) does NOT run a
# trailing `if: always()` step after an earlier step fails, so there is
# no reliable in-job release. Instead, key off the one thing the runner
# *does* always clean up — the job container itself (removed on pass,
# fail, and cancel alike): wait while any lower-numbered `test` job's
# container is still running. Lower task id goes first, so two jobs
# that start together can't deadlock; nothing to release.
- name: Wait for any earlier test job
run: |
my_id="$(printf '%s\n' "${JOB_CONTAINER_NAME:-}" | sed -n 's/.*TASK-\([0-9]\{1,\}\).*/\1/p')"
echo "this test job is task ${my_id:-<unknown, not serializing>}"
# ~25 min cap (75 * 20s): comfortably covers one earlier run
# (the realistic overlap is two runs); a rarer 3-way pile-up whose
# wait exceeds this just fails and gets an empty-commit re-run.
for attempt in $(seq 1 75); do
earlier="$(docker ps --format '{{.Names}}' 2>/dev/null \
| sed -n 's/.*TASK-\([0-9]\{1,\}\)_.*_JOB-test$/\1/p' \
| awk -v me="${my_id:-0}" 'NF && ($1 + 0) < (me + 0)')"
if [ -z "$earlier" ]; then
[ "$attempt" -gt 1 ] && echo "earlier test job(s) finished — proceeding"
exit 0
fi
echo "waiting for earlier test job(s): $(printf '%s ' $earlier)(attempt ${attempt})"
sleep 20
done
echo "still blocked by an earlier test job after ~25 min — giving up; a re-run should get through" >&2
exit 1
# Postgres as a plain background container instead of a `services:`
# block — see this workflow's top-level `env.DATABASE_URL` comment for
# why. Reachable as `postgres` from this job (the runner mounts the
# docker socket and runs jobs on a shared user-defined network, so a
# `--name postgres` sibling resolves by name); the network is
# discovered from the job container itself rather than hardcoded. The
# `docker rm -f postgres` below also clears a container left behind by
# the previous run (this runner has no reliable post-job cleanup), and
# the serialization step above means no other run is using it now.
- name: Start Postgres in the background
run: |
net="$(docker inspect "$(hostname)" \
--format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' \
| awk '{print $1}')"
echo "job network: ${net:?could not determine the job container network}"
docker rm -f postgres >/dev/null 2>&1 || true
# Also drop any `postgres-<suffix>` container. A since-reverted
# attempt at per-run Postgres naming on this branch left some
# running on this persistent runner (their `if: always()` teardown
# never fired — this runner skips trailing steps after a failure),
# and nothing else removes them. A second idle Postgres here starves
# the runner's RAM and slows every query ~5x → `resetDatabase()`
# blows Mocha's 10s hook timeout. Nothing creates `postgres-*` any
# more, so this only ever clears that debris.
docker ps -aq --filter 'name=^postgres-' | xargs -r docker rm -f >/dev/null 2>&1 || true
docker run -d --name postgres --network "$net" \
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \
postgres:16-alpine
for _ in $(seq 1 60); do
docker exec postgres pg_isready -U ci -d batchcooking_ci && exit 0
sleep 2
done
echo "Postgres did not become ready in time" >&2
docker logs postgres || true
exit 1
- uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
- uses: https://github.com/astral-sh/setup-uv@v5
with:
python-version: "3.12"
enable-cache: true
# A `services:` block can only pull an already-published image — it
# can't build services/tech-step-intent-service/Dockerfile from this
# checkout. Running `uvicorn` as a plain background step instead: it
# keeps running for the rest of this job (steps in one job share the
# same process tree), and `pnpm --filter api test` below needs a real
# instance to talk to per this repo's "never mock an internal service"
# test convention — same "real dependency, started by the job itself"
# posture as the Postgres step above, not a mock HTTP server.
- name: Install services/tech-step-intent-service
working-directory: services/tech-step-intent-service
run: uv sync --frozen
- name: Start services/tech-step-intent-service in the background
working-directory: services/tech-step-intent-service
run: |
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
# `/health` only returns 200 once this service has finished
# training itself from scratch (no model ever persisted to disk —
# see its own README) — measured at ~3min combined (fr+en) against
# the current ~74-technique corpus, down from ~930s once
# `spacy.TextCatBOW.v3`'s `length` stopped being left at its
# 262144 default (see `_TEXTCAT_MODEL_LENGTH` in
# `locale_pipeline.py`, and docker-compose.yml's healthcheck for
# the same reasoning). The timeout stays well above that: it's a
# ceiling for a slow/contended runner, not a target.
timeout 600 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm --filter api exec prisma migrate deploy - run: pnpm --filter api exec prisma migrate deploy
- run: pnpm --filter api test - run: pnpm --filter api test
intent-service-test:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/astral-sh/setup-uv@v5
with:
python-version: "3.12"
enable-cache: true
- name: Install services/tech-step-intent-service
working-directory: services/tech-step-intent-service
run: uv sync --frozen
- name: Run pytest
working-directory: services/tech-step-intent-service
run: uv run pytest -q
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
@ -238,22 +83,17 @@ jobs:
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Xvfb and Cypress dependencies
run: |
apt-get update
apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth dbus-x11
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
- name: Cache Cypress binary - name: Cache Cypress binary
uses: https://github.com/actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.cache/Cypress path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@ -263,99 +103,9 @@ jobs:
# download (see apps/web's cypress caveat in the README) — install it # download (see apps/web's cypress caveat in the README) — install it
# explicitly so `cypress run` finds it. # explicitly so `cypress run` finds it.
- run: pnpm --filter web exec cypress install - run: pnpm --filter web exec cypress install
- name: Run E2E tests - run: pnpm --filter web e2e
env:
HOST: "0.0.0.0"
run: pnpm --filter web e2e
# No dev server needed here — Cypress spins up its own Vite dev # No dev server needed here — Cypress spins up its own Vite dev
# server internally for component testing (see cypress.config.ts's # server internally for component testing (see cypress.config.ts's
# `component.devServer`), unlike `e2e` above which needs the real app # `component.devServer`), unlike `e2e` above which needs the real app
# running first. # running first.
- run: pnpm --filter web cy:run:component - run: pnpm --filter web cy:run:component
# Builds and packages a signed, downloadable Android APK on every push to
# `main` — issue #64. `needs:` on every other job is what actually makes
# this "build, TEST, and package" rather than just "build and package in
# parallel with tests that might still be failing": Forgejo/GitHub Actions
# has no native way for a job in a *different* workflow file to block on
# one here, so this has to live in this same file to get a real dependency
# rather than a same-push coincidence. `if:` (not a separate trigger) keeps
# every other job running on every branch as today — only this one is
# main-only.
#
# No dedicated Android test suite exists (zero native code beyond the
# Capacitor-generated shell) — "testée" is satisfied by gating on the 5
# existing jobs, not by inventing an instrumented test suite for this.
#
# Unverified on this runner as of writing: no Android build has ever run
# on this self-hosted Forgejo `act` runner (unlike lint/test/build/e2e,
# exercised for months). If SDK installation turns out too slow/heavy here,
# cache `~/.android/sdk`/`~/.gradle` (same idea as the Cypress binary cache
# in `e2e` above) rather than accepting a slow job on every push.
build-android:
runs-on: ubuntu-latest
needs: [lint, test, intent-service-test, build, e2e]
if: github.ref == 'refs/heads/main'
steps:
- uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
# Matches what Android Gradle Plugin 8.x (bundled with the
# Capacitor-generated project, see apps/web/android/build.gradle)
# expects — bump alongside AGP if that version ever moves.
java-version: "21"
- uses: https://github.com/android-actions/setup-android@v3
- run: pnpm install --frozen-lockfile
# Unlike the normal web build (served same-origin as the API), a
# packaged Capacitor build runs from the WebView's own local origin and
# needs an absolute, public HTTPS URL baked in at build time — see
# apps/web/.env.capacitor.example's own comment on this.
- name: Build le bundle web pour Capacitor
env:
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
run: pnpm --filter web build:capacitor
- run: pnpm --filter web exec cap sync android
# Le keystore de release est un secret généré une fois à la main (voir
# le README, section "Application mobile") — jamais régénéré ici, un
# nouveau keystore casserait la mise à jour de l'appli déjà installée
# (Android refuse une signature différente de celle déjà installée).
- name: Décode le keystore de release
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
- name: Build l'APK signé
working-directory: apps/web/android
env:
ANDROID_KEYSTORE_PATH: release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# chmod défensif : le dev de ce repo se fait sous Windows, où le bit
# exécutable git de gradlew ne veut rien dire et peut être reperdu
# silencieusement par un futur commit — ne pas dépendre uniquement
# du mode suivi par git (voir aussi le fix de ce mode lui-même).
run: |
chmod +x ./gradlew
./gradlew assembleRelease
# v4+ exige l'API "Results" de GitHub, que ce runner Forgejo self-hosted
# ne supporte pas ("GHESNotSupportedError") — v3 utilise encore
# l'ancienne API Artifacts (compatible), seule version utilisable ici.
- uses: https://github.com/actions/upload-artifact@v3
with:
name: batchcooking-release.apk
path: apps/web/android/app/build/outputs/apk/release/app-release.apk

View file

@ -1,243 +1,44 @@
name: Release name: Release
# On a `vX.Y.Z` tag: build the three service images and push them to this # No image registry involved: Portainer is wired to this repo's Git remote
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/ # and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag # that the image actually builds at the tagged commit, publishes a GitHub
# and `latest`), build+attach a signed Android APK, publish a Forgejo # Release, then (best-effort) pings Portainer's deploy webhook so it doesn't
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can # have to wait for its own polling interval.
# pull those prebuilt images (docker-compose.yml references them via
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
# keeps a `build:` fallback next to every `image:`.
on: on:
push: push:
tags: ["v*.*.*"] tags: ["v*.*.*"]
permissions: permissions:
contents: write contents: write
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
# (the `build-and-push-images` job below `docker login`s with it). If a
# future instance policy scopes the run token tighter, swap that login's
# password for a `write:package` PAT added as a repo secret.
packages: write
jobs: jobs:
# Build the three service images at the tagged commit and push them to the sanity-build:
# Forgejo container registry. A successful push also *is* the "does it
# still build" check the old `sanity-build` job did (which only covered
# apps/api). One sequential job, not a per-image matrix: three concurrent
# `docker build`s hammer this single persistent runner's Docker daemon
# (the contended resource per project memory `ci-forgejo-runner-constraints`)
# for no real gain on a release, which is rare and usually runs alone.
build-and-push-images:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# This runner's job containers don't come with docker preinstalled — - uses: actions/checkout@v4
# same reason ci.yml's `test` job has to do this before its own
# `docker run` calls.
- name: Install docker
run: apt-get update && apt-get install -y docker.io
- uses: https://github.com/actions/checkout@v4 # Build only, no push, no registry — just confirms Portainer will be
# able to build this same Dockerfile successfully at this tag before
# anyone points it at this ref.
- run: docker build -f apps/api/Dockerfile .
- name: Build et push des 3 images vers le registre Forgejo github-release:
env:
# The per-run token Forgejo Actions mints — has `packages: write`
# via this workflow's `permissions:` block. `docker login` accepts
# it as the password for the actor who pushed the tag.
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
# registry namespace is the owner, lower-cased (Forgejo package
# names are case-insensitive but the CLI wants a stable form).
registry="${GITHUB_SERVER_URL#http*://}"
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
tag="$GITHUB_REF_NAME"
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
# Every Dockerfile's build context is the repo root (see
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
build_push() {
image="$registry/$owner/$1"
echo "::group::$1"
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
docker push "$image:$tag"
docker push "$image:latest"
echo "::endgroup::"
}
build_push batchcooking-app apps/api/Dockerfile
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
# Builds the same signed APK as ci.yml's build-android job (issue #64),
# with one difference: the version baked into the APK is derived from the
# tag itself rather than left at build.gradle's default. Duplicated here
# rather than factored into a shared `workflow_call` reusable workflow —
# deliberate: reusable-workflow support on this self-hosted Forgejo/act
# runner is unverified, and this ~15-line recipe is proven working as-is
# in ci.yml; not worth risking a new class of runner-compatibility surprise
# (this repo has already hit several — see ci-forgejo-runner-constraints
# in project memory) to save a little duplication. Keep both copies in
# sync if the Android build steps ever change.
build-android-apk:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: sanity-build
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: softprops/action-gh-release@v2
- uses: https://github.com/actions/setup-node@v4
with: with:
node-version: 22 # Auto-generated from merged PRs since the previous tag — no
cache: pnpm # changelog tooling to maintain, consistent with there being no
# versioning tooling elsewhere in the repo yet.
- uses: https://github.com/actions/setup-java@v4 generate_release_notes: true
with:
distribution: temurin
java-version: "21"
- uses: https://github.com/android-actions/setup-android@v3
- run: pnpm install --frozen-lockfile
- name: Build le bundle web pour Capacitor
env:
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
run: pnpm --filter web build:capacitor
- run: pnpm --filter web exec cap sync android
- name: Décode le keystore de release
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
- name: Dérive versionName/versionCode depuis le tag
# GITHUB_REF_NAME = "v1.4.2" pour un tag `v1.4.2` — versionName =
# "1.4.2" (sans le `v`), versionCode = majeur*10000 + mineur*100 +
# correctif (ex. 1.4.2 -> 10402), un entier strictement croissant à
# chaque release comme Android l'exige (jamais réutilisé, jamais
# décroissant) tant que chaque partie reste sous 100 (largement
# suffisant ici). Exposé aux steps suivants via $GITHUB_ENV plutôt
# que recalculé.
run: |
tag="${GITHUB_REF_NAME#v}"
major="$(echo "$tag" | cut -d. -f1)"
minor="$(echo "$tag" | cut -d. -f2)"
patch="$(echo "$tag" | cut -d. -f3)"
# Préfixe 10# : force une lecture en base 10, sinon bash lirait
# un composant à zéro non significatif (ex. "08") comme de
# l'octal invalide et échouerait.
code=$((10#$major * 10000 + 10#$minor * 100 + 10#$patch))
echo "Version dérivée du tag $GITHUB_REF_NAME : versionName=$tag versionCode=$code"
echo "ANDROID_VERSION_NAME=$tag" >> "$GITHUB_ENV"
echo "ANDROID_VERSION_CODE=$code" >> "$GITHUB_ENV"
- name: Build l'APK signé
working-directory: apps/web/android
env:
ANDROID_KEYSTORE_PATH: release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
chmod +x ./gradlew
./gradlew assembleRelease -PandroidVersionName="$ANDROID_VERSION_NAME" -PandroidVersionCode="$ANDROID_VERSION_CODE"
# Nom de fichier stable, jamais versionné : la page d'accueil (#65)
# pointe vers /releases/latest (Forgejo n'a pas l'équivalent du
# raccourci GitHub /releases/latest/download/<fichier>, seul
# /releases/latest lui-même résout) — un nom stable évite au moins que
# l'utilisateur voie un nom de fichier différent à chaque release une
# fois sur la page.
- name: Renomme l'APK sous un nom stable
run: cp apps/web/android/app/build/outputs/apk/release/app-release.apk batchcooking.apk
# v4+ exige l'API "Results" de GitHub, non supportée par ce Forgejo
# self-hosted ("GHESNotSupportedError") — v3 (ancienne API Artifacts)
# est la seule utilisable ici, voir ci.yml's build-android pour la
# même contrainte déjà rencontrée.
- uses: https://github.com/actions/upload-artifact@v3
with:
name: batchcooking.apk
path: batchcooking.apk
forgejo-release:
runs-on: ubuntu-latest
needs: [build-and-push-images, build-android-apk]
steps:
# Ce runner ne fournit ni docker ni curl par défaut dans ses
# conteneurs de job — même constat déjà fait pour docker (voir
# build-and-push-images) et pour curl dans ci.yml's test job.
- name: Install curl
run: apt-get update && apt-get install -y curl
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/download-artifact@v3
with:
name: batchcooking.apk
# softprops/action-gh-release (l'action GitHub habituelle pour ça)
# échoue systématiquement contre ce Forgejo : sa requête de création
# de release répond 405 (vérifié en conditions réelles, tag v0.2.0 —
# pas un souci de ref bare comme les autres actions de ce fichier,
# une vraie incompatibilité d'API entre les deux plateformes qui n'a
# pas de contournement connu côté configuration de l'action). Appelle
# directement l'API native de Forgejo plutôt qu'une action tierce
# dont la compatibilité GitHub-Forgejo n'est pas garantie.
#
# Pas d'équivalent à `generate_release_notes` ici (fonctionnalité
# propre à GitHub, pas répliquée par l'API Forgejo) — le corps de la
# release reste minimal, cohérent avec l'absence d'outillage de
# changelog ailleurs dans le repo.
- name: Crée (ou récupère) la Release Forgejo
id: create-release
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases"
body="$(printf '{"tag_name":"%s","name":"%s","draft":false,"prerelease":false}' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME")"
# POST pour créer la release, code HTTP capturé à part. Si le tag est
# déjà connu de Forgejo (tag poussé à la main avant ce workflow, ou
# ré-exécution du job), l'API répond 409 : on réutilise alors la
# release existante au lieu d'échouer — idempotence, cf. l'incident
# du tag v0.2.2 poussé en avance qui a fait tomber ce job sur un 409.
code="$(curl -sS -o /tmp/release.json -w '%{http_code}' -X POST "$api" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
-d "$body")"
if [ "$code" = "409" ]; then
echo "Release $GITHUB_REF_NAME déjà existante (HTTP 409) — récupération de son id."
curl -fsS "$api/tags/$GITHUB_REF_NAME" \
-H "Authorization: token $FORGEJO_TOKEN" -o /tmp/release.json
elif [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
echo "Échec de création de la Release (HTTP $code) : $(cat /tmp/release.json)" >&2
exit 1
fi
id="$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)"
if [ -z "$id" ]; then
echo "Pas d'id de release trouvé : $(cat /tmp/release.json)" >&2
exit 1
fi
echo "Release $GITHUB_REF_NAME prête (id=$id)"
echo "release-id=$id" >> "$GITHUB_OUTPUT"
- name: Attache l'APK à la Release
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/${{ steps.create-release.outputs.release-id }}/assets"
curl -fsS -X POST "$api?name=batchcooking.apk" \
-H "Authorization: token $FORGEJO_TOKEN" \
-F "attachment=@batchcooking.apk"
notify-portainer: notify-portainer:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: forgejo-release needs: github-release
steps: steps:
- name: Trigger Portainer redeploy - name: Trigger Portainer redeploy
# Best-effort: does nothing (and doesn't fail the workflow) until # Best-effort: does nothing (and doesn't fail the workflow) until

22
.gitignore vendored
View file

@ -69,15 +69,6 @@ web_modules/
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.test.example
!.env.capacitor.example
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
# is otherwise all-Node — see that service's own .gitignore for the rest;
# duplicated here too since some tooling only honors the repo-root file).
services/tech-step-intent-service/.venv/
services/tech-step-intent-service/__pycache__/
services/tech-step-intent-service/.pytest_cache/
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache
@ -134,11 +125,6 @@ dist
# Stores VSCode versions used for testing VSCode extensions # Stores VSCode versions used for testing VSCode extensions
.vscode-test .vscode-test
# JetBrains IDEs (Android Studio opens this repo as a project once the
# Capacitor Android app exists under apps/web/android — see that folder's own
# .gitignore for the generated native project's build artefacts).
/.idea/
# pnpm # pnpm
.pnpm-store .pnpm-store
@ -164,11 +150,3 @@ tmp-mockups/
# IA # IA
.claude/ .claude/
# Cypress run artifacts — regenerated locally/in CI, never meant to be committed
apps/web/cypress/screenshots/
apps/web/cypress/videos/
apps/web/cypress/downloads/
apps/admin-web/cypress/screenshots/
apps/admin-web/cypress/videos/
apps/admin-web/cypress/downloads/

View file

@ -1,3 +0,0 @@
# Instructions du projet
Avant toute action de génération de code, lis attentivement le fichier `specs\dev-conventions.md` pour prendre connaissance de l'ensemble des normes de développement à appliquer impérativement

524
README.md
View file

@ -4,21 +4,13 @@
Monorepo pnpm workspaces : Monorepo pnpm workspaces :
- `apps/api` — backend Express/TypeScript : auth, foyer, planning (grille de la - `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha)
semaine), catalogue de recettes (favoris/perso/foyer/publique), import de - `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
recettes depuis des sources externes, préférences (thème, régime, allergies, Page de connexion/inscription en place ; le reste est encore un squelette générique.
ingrédients détestés). Tests Mocha (base Postgres réelle, isolée de la base - `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
de dev — voir plus bas). `loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
Capacitor plus tard. Espace connecté complet (planning, recettes, réglages) même règles des deux côtés, pas de risque de dérive entre front et back.
derrière une sidebar, wizard d'inscription, thème clair/sombre/système.
- `packages/shared` — code partagé entre `api` et `web` : schémas zod, types
(`RecipeView`, `PlanningView`, `HouseView`, `SafeUserProfile`...), le contrat
d'erreurs (`ErrorCode` numérique, `ApiErrorResponse`, voir
[specs/error-handling.md](specs/error-handling.md)) et les libellés anglais
du catalogue d'ingrédients (`data/catalog-labels-en.ts`, utilisés par le
matching de recettes importées — voir plus bas) — 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 - `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé 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 : d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
@ -27,16 +19,11 @@ Monorepo pnpm workspaces :
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware` (init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`, (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). pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
- `packages/date-tools` — utilitaires de date partagés (Luxon) : convention
"date-only = minuit UTC" (`parseDateOnly`/`formatDateOnly`/`toDateOnly`),
calcul de semaine lundi-first (`getWeekStart`/`addWeeks`/`buildCalendarMonth`)
— utilisés à la fois par `apps/api` (validation de date de planning) et
`apps/web` (grille/navigateur de semaine).
`packages/shared`, `packages/error-tools`, `packages/express-tools` et `packages/shared`, `packages/error-tools` et `packages/express-tools` ont un vrai
`packages/date-tools` ont un vrai build (`tsc` → `dist/`, voir leur build (`tsc` → `dist/`, voir leur `package.json`) : consommés en JS compilé, pas en
`package.json`) : consommés en JS compilé, pas en TS brut — nécessaire pour un TS brut — nécessaire pour un runtime Node pur (Docker, pas de transpilation à la
runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans volée), voir la note dans
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts). [specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
## Prérequis ## Prérequis
@ -44,8 +31,6 @@ runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
- Node.js 22 (voir `.nvmrc`) - Node.js 22 (voir `.nvmrc`)
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle) - pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
- Docker (pour Postgres en local) - Docker (pour Postgres en local)
- Python 3.12+ et [`uv`](https://docs.astral.sh/uv/) (pour
`services/tech-step-intent-service` en dev natif — requis, voir plus bas)
## Installation ## Installation
@ -64,9 +49,6 @@ sont pas définis dans `.env` — pas de valeur par défaut en dur dans les fich
Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le
tien, voir le commentaire dans `apps/api/.env.example`). tien, voir le commentaire dans `apps/api/.env.example`).
Si tu comptes lancer `pnpm --filter api test` (voir [Qualité / Tests](#qualité--tests)),
crée aussi `apps/api/.env.test` — voir la section dédiée plus bas.
### Cypress : téléchargement du binaire ### Cypress : téléchargement du binaire
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le `pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
@ -98,21 +80,6 @@ docker compose up -d postgres
# Applique le schéma (première fois / après un changement de prisma/schema.prisma) # Applique le schéma (première fois / après un changement de prisma/schema.prisma)
pnpm --filter api exec prisma migrate dev pnpm --filter api exec prisma migrate dev
# Peuple les données de référence (régimes, allergènes, ingrédients, unités,
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
pnpm --filter api prisma:seed
# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api`
# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en
# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage
# (~11 minutes pour le corpus actuel, voir son propre README) avant de
# répondre quoi que ce soit sur /health.
cd services/tech-step-intent-service
uv sync
cp .env.example .env # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env
uv run uvicorn intent_service.main:app --reload --port 8000
cd ../..
# Backend (http://localhost:3000) # Backend (http://localhost:3000)
pnpm dev:api pnpm dev:api
@ -137,46 +104,15 @@ pnpm dev:web
## Qualité / Tests ## Qualité / Tests
Conventions de code (classes vs objets littéraux, préfixe `_` sur les membres
privés, règles Biome actives, logs côté serveur, etc.) :
[specs/dev-conventions.md](specs/dev-conventions.md).
```bash ```bash
pnpm lint # Biome (lint + format check) pnpm lint # Biome (lint + format check)
pnpm lint:fix # Biome --write pnpm lint:fix # Biome --write
pnpm test # tests unitaires/intégration (Mocha, apps/api) pnpm test # tests unitaires/intégration (Mocha, apps/api)
pnpm --filter web e2e # tests e2e (Cypress + Cucumber, démarre le serveur dev automatiquement) pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress component testing) pnpm build # build de tous les workspaces
pnpm build # build de tous les workspaces
``` ```
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute cinq jobs indépendants (`lint`, `test`, `intent-service-test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. `test` démarre `services/tech-step-intent-service` en arrière-plan (voir ce fichier) puisque la suite Mocha ne mocke jamais un service interne. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`). La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
### Base de test isolée de la base de dev (`apps/api`)
`pnpm --filter api test` exécute une `TRUNCATE ... CASCADE` sur presque tout le
schéma **avant chaque test** (`test-support/reset-db.ts`). Pour ne jamais
risquer de vider une vraie base de dev locale, `NODE_ENV=test` (posé par le
script `test`) fait charger `apps/api/.env.test` au lieu de `.env` — un
fichier **à créer toi-même**, pas fourni automatiquement :
```bash
cp apps/api/.env.test.example apps/api/.env.test
# puis édite-le : mêmes identifiants Postgres que ton .env, mais une base
# différente (ex. batchcooking_test) — .env.test.example documente les
# commandes exactes pour la créer et lui appliquer le schéma.
```
Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
seule base qu'il doit rejeter est ta vraie base de dev.
`services/tech-step-intent-service` doit aussi tourner en local avant
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
par le vrai service (jamais un mock, voir
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
section [Développement](#développement) ci-dessus.
## Déploiement ## Déploiement
@ -190,35 +126,15 @@ client) via `FRONTEND_DIST_DIR` — voir `packages/express-tools/src/express-ser
en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant. le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.
Le `CMD` de l'image enchaîne trois étapes, chacune dans son propre processus `docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un
`node` : `prisma migrate deploy` (applique les migrations), puis seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à
`node dist/scripts/seed-runtime.js` (seed des données de référence **et** coordonner entre deux origines, le frontend et l'API sont désormais servis depuis
synchronisation de la table `sources` depuis le registre d'adaptateurs de code la même origine).
— nécessaire à chaque démarrage : le registre en mémoire peuplé par
`server.ts` ne survit pas au changement de processus, voir
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation)),
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
répéter à chaque redémarrage du conteneur.
Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port **Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à production est reliée directement au dépôt Git et reconstruit elle-même
coordonner entre deux origines, le frontend et l'API sont désormais servis `docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
depuis la même origine. Les deux autres services du fichier donc aucune image nulle part.
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
au host — voir leurs propres README pour leur rôle.
**Registre d'image** : la CI de release (voir plus bas) construit et pousse les
trois images de service — `batchcooking-app`, `batchcooking-intent-service`,
`batchcooking-llm-worker` — sur le **registre de conteneurs intégré à Forgejo**
(`git.dev.kyuno.fr/kyuno/…`), taguées à la fois avec le tag de release (`vX.Y.Z`)
et `latest`. `docker-compose.yml` référence ces images via `IMAGE_TAG` (variable
d'environnement, défaut `latest` — voir `.env.example`), tout en gardant un bloc
`build:` local à côté de chaque `image:` : `docker compose pull` récupère les
images pré-construites, `docker compose up`/`build` retombe sur un build local si
elles sont absentes. L'instance **Portainer** de production peut donc désormais
tirer les images pré-construites (stack en mode *pull*, `IMAGE_TAG` pinné sur la
release voulue) **ou** continuer à builder elle-même depuis le dépôt Git — les
deux modes fonctionnent avec le même `docker-compose.yml`.
### Release (`.github/workflows/release.yml`) ### Release (`.github/workflows/release.yml`)
@ -229,80 +145,14 @@ git tag vX.Y.Z
git push --tags git push --tags
``` ```
Le pipeline enchaîne quatre jobs : `build-and-push-images` (build des 3 images à Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
ce tag + push sur le registre Forgejo sous `vX.Y.Z` et `latest` — un push push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
réussi vaut aussi vérification que tout build encore à ce commit), redéployer dessus), `github-release` (crée une Release GitHub avec changelog
`build-android-apk` (APK signé, `versionName` dérivé du tag), `forgejo-release` auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
(crée la Release Forgejo `vX.Y.Z` et y attache `batchcooking.apk`), puis au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
`notify-portainer` — envoie une requête au webhook de redeploy de Portainer si est configuré (sinon Portainer se resynchronise simplement à son prochain
le secret de dépôt `PORTAINER_WEBHOOK_URL` est configuré (sinon Portainer se polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
resynchronise à son prochain polling). Pour l'activer : récupérer l'URL du stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
webhook depuis les réglages du stack Portainer, puis l'ajouter comme secret de
dépôt `PORTAINER_WEBHOOK_URL`.
## Application mobile (Android)
`apps/web` est empaqueté dans une coquille [Capacitor](https://capacitorjs.com/)
pour Android — le projet natif est versionné dans `apps/web/android/` (issue
\#39). L'authentification passe par un jeton Bearer stocké dans le stockage
sécurisé de Capacitor (le cookie de session `SameSite=Lax` ne part pas depuis
l'origine interne d'une WebView), et `main.tsx` bascule sur `HashRouter` quand
`Capacitor.isNativePlatform()` est vrai.
**Prérequis** : Android Studio (SDK Android + un JDK ; le JBR fourni avec
Android Studio suffit). Aucun serveur Postgres/Docker local n'est nécessaire —
l'appli parle à l'API HTTPS publique.
**Build** — depuis `apps/web/` :
```bash
# 1. URL publique de l'API, gravée dans le bundle empaqueté (le build web
# normal utilise une URL relative « même origine », impossible ici).
cp .env.capacitor.example .env.capacitor # puis renseigner VITE_API_URL
# 2. Build web en mode capacitor + synchronisation dans le projet natif.
pnpm build:capacitor
npx cap sync android
# 3. Ouvrir dans Android Studio (build/run/émulateur depuis l'IDE)…
npx cap open android
# …ou build/run en ligne de commande si un appareil/émulateur est prêt :
npx cap run android
```
Le bundle web copié (`apps/web/android/app/src/main/assets/public/`) est
git-ignoré : il est régénéré par `pnpm build:capacitor && npx cap sync android`.
Les icônes et le splash sont générés depuis `apps/web/assets/logo.svg` (voir
`apps/web/assets/README.md`). iOS est hors périmètre pour l'instant (nécessite
Xcode/macOS).
**CI — build automatique d'un APK signé** (`.github/workflows/ci.yml`, job
`build-android`, issue #64) : à chaque push sur `main`, une fois les 5 autres
jobs verts, un APK release signé est construit et déposé comme artefact de
run téléchargeable. Secrets du dépôt Forgejo requis (Réglages du dépôt →
Secrets) :
| Secret | Contenu |
|---|---|
| `MOBILE_API_URL` | URL HTTPS publique de l'API — même valeur que `VITE_API_URL` dans `.env.capacitor` |
| `ANDROID_KEYSTORE_BASE64` | Le keystore de release (`.jks`) encodé en base64 |
| `ANDROID_KEYSTORE_PASSWORD` | Mot de passe du keystore |
| `ANDROID_KEY_ALIAS` | Alias de la clé dans le keystore |
| `ANDROID_KEY_PASSWORD` | Mot de passe de la clé |
Le keystore doit être généré **une seule fois**, à la main, et rester stable
pour toujours : Android refuse d'installer la mise à jour d'une appli signée
par une clé différente de celle déjà installée.
```bash
keytool -genkeypair -v -keystore batchcooking-release.jks \
-alias batchcooking -keyalg RSA -keysize 2048 -validity 10000
base64 -w0 batchcooking-release.jks # → coller comme secret ANDROID_KEYSTORE_BASE64
```
Conserver le fichier `.jks` et ses mots de passe en lieu sûr en dehors du
dépôt (jamais commité — voir `apps/web/android/.gitignore`) : le perdre
empêche de publier toute future mise à jour de l'appli.
## Auth (apps/api) ## Auth (apps/api)
@ -316,14 +166,10 @@ Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpO
l'email ou le mot de passe qui soit incorrect l'email ou le mot de passe qui soit incorrect
- `POST /auth/logout` — efface le cookie (204) - `POST /auth/logout` — efface le cookie (204)
- `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon) - `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon)
- `DELETE /auth/me` — supprime définitivement le compte après re-saisie du mot
de passe (`{ password }`, 401 `INVALID_CREDENTIALS` si incorrect) ; gère le
départ/transfert d'adminship du foyer avant suppression (voir Foyer plus bas)
Mots de passe hachés avec argon2. `UserProfile.tokenVersion` existe pour Mots de passe hachés avec argon2. Le hash est indépendant du foyer : un profil crée
invalider les JWT déjà émis (ex. futur changement de mot de passe) mais rien toujours son propre foyer à l'inscription (rejoindre un foyer existant n'est pas
ne l'incrémente encore — pas de route de changement d'email/mot de passe encore implémenté).
aujourd'hui, seulement la suppression de compte.
> **argon2 : version pinnée à `0.31.2`, pas de `^`.** La version `0.45.1` (dernière au > **argon2 : version pinnée à `0.31.2`, pas de `^`.** La version `0.45.1` (dernière au
> moment de l'écriture) segfault au runtime sur au moins une configuration Windows — > moment de l'écriture) segfault au runtime sur au moins une configuration Windows —
@ -338,178 +184,171 @@ Les tests (Mocha) tournent avec un coût argon2 réduit
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
`prisma migrate deploy` avant les tests. `prisma migrate deploy` avant les tests.
## Foyer — création, invitation, admin, sources externes (apps/api) > **Les tests automatisés et `pnpm dev:api` partagent la même base Postgres locale.**
> Lancer `pnpm test` **vide `user_profiles`/`house`** (`TRUNCATE ... CASCADE`,
Un foyer (`house`) a un admin (`adminId`) et un code d'invitation à 8 > voir `test-support/reset-db.ts`) — si tu es en train de tester manuellement à la main
caractères (`inviteCode`, alphabet sans caractères ambigus `0`/`O`/`1`/`I`). > (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle
> efface tes données de test sans prévenir. Pas un bug, juste à savoir.
- `GET`/`PATCH /house/current` — foyer courant. `PATCH { name }` ouvert à tout membre.
- `POST /house` — crée un foyer (l'appelant devient admin) ; `POST /house/join
{ inviteCode }` — rejoint un foyer existant. Les deux 409 `ALREADY_HAS_HOUSE`
si le profil a déjà un foyer.
- `POST /house/leave` — quitte le foyer courant. Si le partant était l'admin,
l'adminship passe au membre restant le plus ancien ; si plus personne ne
reste, le foyer est supprimé (un foyer ne peut jamais rester sans admin).
- `DELETE /house/current` — supprime le foyer (403 `NOT_HOUSE_ADMIN` si appelé
par un non-admin). `DELETE /house/members/:id` — retire un membre (admin
seulement, pas de self-retrait par cette route, utiliser `/leave`).
- `GET`/`PATCH /house/current/sources` — quelles sources externes de recettes
(voir plus bas) le foyer voit dans son catalogue — `{ sourceIds: number[] }`,
remplace (pas de fusion), opt-in (aucune source activée par défaut).
Détail complet (génération du code, transfert d'adminship) :
[specs/backend-architecture.md](specs/backend-architecture.md#house--foyer-adminship-code-dinvitation-sources-activées).
## Planning (apps/api) ## Planning (apps/api)
- `GET /planning?date=YYYY-MM-DD` — planning de la semaine (lundi→dimanche) - `GET /planning/current` — nécessite le cookie de session (401 sinon). Renvoie le
couvrant `date`, pour le foyer de l'utilisateur connecté — `PlanningView | planning du foyer de l'utilisateur connecté qui couvre la date du jour (`Planning`
null` (`null` = pas de foyer, ou aucun planning pour cette semaine, deux cas dont `start_date <= aujourd'hui <= finish_date`), items inclus avec leur recette
normaux confondus, jamais une erreur). résolue en `{ id, name }` — ou `null` s'il n'y en a aucun (foyer sans planning en
- `POST /planning/items` — ajoute une recette à un créneau : cours, ou profil sans foyer). `null` est une réponse **valide** (200), pas une
`{ date, weekDay, meal, recipeId, portions }`. `portions` est saisi erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul
indépendamment du rendement propre de la recette (`Recipe.portions`) — un batch-cooking », voir [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md),
créneau peut mettre à l'échelle. reste à construire), donc c'est l'état attendu tant que ce module n'existe pas.
- `DELETE /planning/items/:id` — retire un item du planning. - Type de réponse partagé : `PlanningView` (`packages/shared/src/types/planning.ts`),
consommé tel quel par `apps/web`.
Le planning d'une semaine est créé à la demande (première recette ajoutée), Détail de `AsyncRequestHandler`/`wrapAsyncHandler` (`packages/express-tools`) —
jamais en avance. premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, ce qui
a mis au jour une contrainte générique trop stricte, corrigée à la source :
[specs/backend-architecture.md](specs/backend-architecture.md).
## Recettes — catalogue, favoris, import depuis une source externe (apps/api) ## Données de référence — régimes & allergènes (apps/api)
- `GET /recipes?tab=favoris|perso|foyer|publique&search=&suitableForHousehold=&ingredientIds=&dietIds=` - `GET /reference/diets` — liste des régimes alimentaires (`Diet`, 5 valeurs seedées).
— catalogue filtré par onglet + filtres optionnels. `PERSONAL`/`HOUSE`/`PUBLIC` - `GET /reference/allergies` — liste des allergènes sélectionnables, `{ id, name }`
(`Recipe.visibility`) contrôlent qui peut **lire** une recette (jamais qui (le nom vient de `Category.name` — la table `allergy` elle-même ne porte pas de
peut l'éditer, toujours réservé à l'auteur) ; les recettes issues d'une nom, voir `schema.prisma` — chaque allergène = une `Category` + une unique
source externe non activée pour le foyer du viewer sont masquées de tous les `Allergy` sous cette catégorie).
onglets.
- `GET /recipes/:id`, `POST /recipes`, `PATCH /recipes/:id`,
`DELETE /recipes/:id` (409 `RECIPE_IN_USE` si encore référencée par un
planning), `POST`/`DELETE /recipes/:id/favorite`.
- **Import depuis une source externe** (`/sources`) : `GET
/sources/:sourceKey/browse` (parcourir), `GET
/sources/:sourceKey/preview/:externalId` (prévisualiser sans sauvegarder —
ingrédients/unités/techniques déjà résolus contre les catalogues), `POST
/sources/:sourceKey/import/:externalId` (finaliser — même payload qu'une
création manuelle). Un item de source n'est sauvegardé qu'en conséquence de
son ajout au planning (import transparent si tout est résolu) ou d'une revue
manuelle (ingrédients ambigus à choisir à la main) — jamais un bouton
"importer" isolé. Une seule source concrète aujourd'hui : **TheMealDB**
(API officielle, catalogue anglais).
Détail complet (adaptateurs, algorithmes de matching ingrédients/techniques, Les deux sont **publics** (pas de `requireAuth`) : ce sont des données de référence,
synchronisation de la table `sources`) : pas des données de foyer, et le wizard d'inscription doit pouvoir les lire avant
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation). qu'un compte (donc une session) n'existe.
## Données de référence — régimes, allergènes, ingrédients, unités, techniques (apps/api) Données seedées via `apps/api/prisma/seed.ts` (`pnpm --filter api prisma:seed`, ou
automatiquement après `prisma migrate reset` — config `prisma.seed` dans
`package.json`). La logique réelle (listes + upsert idempotent) vit dans
`src/db/reference-seed-data.ts`, partagée avec `test-support/reset-db.ts` : chaque
test repart d'une base **avec** ces données de référence, pas de tables vides —
nécessaire pour tester `dietId`/`allergyIds` sur de vraies lignes.
- `GET /reference/diets`, `/allergies`, `/ingredients`, `/units`, `Diet.name` et `Category.name` sont `@unique` — ajouté à ce schéma (pas dans le doc
`/tech-steps`, `/sources` — tous **publics** (pas de `requireAuth`) : ce sont spec d'origine) précisément pour permettre cet upsert idempotent par nom.
des données de référence, pas des données de foyer, et le wizard
d'inscription doit pouvoir les lire avant qu'un compte n'existe.
Données seedées via `apps/api/src/db/reference-seed-data.ts` (`pnpm --filter Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
api prisma:seed`, ou automatiquement après `prisma migrate reset`) — jamais standard, pas inventée.
créées/éditées/supprimées via l'API applicative. `key`/`name` sont `@unique`
pour permettre un seed idempotent (`upsert`). Le catalogue d'ingrédients
(400+) est organisé en 7 rayons/sous-catégories façon supermarché français, et
chaque allergène est classé `ALLERGY` (immunitaire) ou `INTOLERANCE`
(Gluten/Sulfites). Détail complet du schéma :
[specs/batch-cooking-modele.md](specs/batch-cooking-modele.md).
## Foyer & profil — régime, allergènes, ingrédients détestés (apps/api) **Allergies vs intolérances** (retour fonctionnel, pas dans le doc spec d'origine) :
`Category.kind` (`AllergenKind` — `ALLERGY` | `INTOLERANCE`) classe chaque allergène.
Seuls `Gluten` et `Sulfites` sont en `INTOLERANCE` (réaction non-immunitaire
documentée) ; les 12 autres en `ALLERGY` (réaction immunitaire classique). Classifié
par substance, pas par utilisateur — un même foyer ne peut pas déclarer "allergie au
lait" pour un membre et "intolérance au lait" pour un autre ; a suffi pour le besoin
exprimé, à revoir si ça devient un problème réel. `GET /reference/allergies` renvoie
`kind` dans chaque `AllergyView` ; `PATCH /profile/allergies` ne change pas (une
seule liste d'IDs, `kind` ne sert qu'à grouper l'affichage côté client).
Nécessitent tous une session (`requireAuth`) — données propres à ## Foyer & profil — nom, régime, allergènes (apps/api)
l'utilisateur/au foyer, pas des données de référence.
Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de
référence ci-dessus, ce sont des données propres à l'utilisateur/au foyer.
- `GET`/`PATCH /house/current` — foyer de l'utilisateur connecté. `GET` renvoie
`null` si le profil n'a pas encore de foyer (cas théorique : le signup en crée
toujours un) ; `PATCH { name }` le renomme (`404 HOUSE_NOT_FOUND` si le profil
n'a pas de foyer).
- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ; - `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
`null` efface le régime. `null` efface le régime (étape "skippable" du parcours). `404 DIET_NOT_FOUND` si
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances (medical), liste `dietId` ne correspond à aucun régime de référence.
d'IDs, remplace (pas de fusion). - `GET`/`PATCH /profile/allergies` — allergènes/intolérances du profil connecté,
- `GET`/`PATCH /profile/disliked-ingredients` — ingrédients personnellement sous forme de liste d'IDs (`number[]`). `PATCH { allergyIds }` **remplace**
"pas aimés" (**goût, pas médical** — ne déclenche jamais un avertissement de l'ensemble (pas une fusion — le client renvoie toujours la sélection complète,
sécurité, juste un rappel discret sur la fiche recette), même contrat de cohérent avec un composant de multi-sélection). `404 ALLERGY_NOT_FOUND` si un ID
remplacement. ne correspond à aucun allergène de référence.
- `GET`/`PATCH /preferences { theme: "LIGHT"|"DARK"|"SYSTEM" }` — préférence
d'affichage, upsert (pas de ligne tant que rien n'a été choisi, défaut
`SYSTEM`).
`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash` `apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
(`toSafeProfile`). (`toSafeProfile`), auparavant dupliqué dans `auth.service.ts` et
`require-auth.ts` — `profile.service.ts` le réutilise aussi.
## Page de connexion / inscription (apps/web) ## Page de connexion / inscription (apps/web)
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée - `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) :
`apiClient`) : enveloppe `fetch` vers l'API (`credentials: "include"`, requis enveloppe `fetch` vers l'API (`credentials: "include"`, requis pour que le cookie
pour que le cookie de session httpOnly parte/revienne — l'API et le front de session httpOnly parte/revienne — l'API et le front sont sur des origines
sont sur des origines différentes). URL configurable via `VITE_API_URL` différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
(voir `.env.example`). - `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET /auth/me` au
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET chargement pour restaurer la session depuis le cookie.
/auth/me` au chargement pour restaurer la session depuis le cookie ; - `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes de route
`deleteAccount()` pour la suppression de compte. (react-router-dom) : `/` exige d'être connecté, `/login` et `/signup` redirigent vers
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes `/` si on l'est déjà.
de route (react-router-dom) : l'espace connecté exige d'être connecté, - `src/pages/{Login,Signup,Home}Page.tsx` — validation client instantanée via les
`/login` et `/signup` redirigent vers `/` si on l'est déjà.
- `src/pages/{Login,Signup}Page.tsx` — validation client instantanée via les
schémas zod partagés (`packages/shared`), erreurs API traduites via schémas zod partagés (`packages/shared`), erreurs API traduites via
`ErrorMessageService` (voir ci-dessous). `ErrorMessageService` (voir ci-dessous).
Détail de l'organisation complète (dossiers, routing, SCSS/theming) : Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
[specs/frontend-architecture.md](specs/frontend-architecture.md). [specs/frontend-architecture.md](specs/frontend-architecture.md).
## Sidebar, planning, recettes & sections (apps/web) ## Accueil, sidebar & sections (apps/web)
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar
sidebar (nav Planning/Recettes/Liste de courses, sous-menu Paramètres (nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et
repliable, menu compte en pied) et `<Outlet />` pour la route active — montée `<Outlet />` pour la route active — montée une seule fois comme route parente de tout
une seule fois comme route parente de tout l'espace authentifié (`App.tsx`). l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx`
(routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`,
voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes` et `Liste de
courses` n'ont pas encore de backend dédié et rendent pour l'instant le même
composant `ComingSoonPage` — `Foyer & profil` (`src/pages/HouseholdPage.tsx`), lui,
est une vraie page (voir section suivante). Détail complet (pourquoi une seule route
parente, pourquoi un composant stub partagé) :
[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
- **`/` — `PlanningPage`** : grille complète de la semaine (7 jours × 5 ## Parcours profil — foyer, régime, allergènes (apps/web)
repas), navigation par semaine avec mini-calendrier, ajout via
`RecipePickerDialog` (parcourir le catalogue **et** les sources externes,
prévisualiser avant de confirmer, import transparent en un clic si la
recette d'une source n'est pas encore résolue automatiquement, sinon revue
intégrée dans le même dialogue).
- **`/recettes`** (+ `/recettes/:id`, `/recettes/sources/:sourceKey/:externalId`)
— `RecipesPage`, vue maître-détail : onglets favoris/perso/foyer/publique
**plus un onglet par source externe activée pour le foyer**, tableau +
panneau de détail (surlignage des techniques détectées avec infobulle, icônes
d'ingrédients génériques, badges régime/allergènes/reproductible).
`/recettes/nouvelle` et `/recettes/:id/modifier` (`RecipeFormPage`) pour la
création/édition manuelle.
- **`/liste-de-courses`** — toujours un stub (`ComingSoonPage`), le module
« Calcul batch-cooking » reste `TODO` (voir
[specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md)).
- **`/parametres/*`** — Compte (identité + suppression), Préférences
(régime/allergies/ingrédients détestés), Foyer (création/invitation,
membres, sources activées), Préférences utilisateur (thème
clair/sombre/système), Crédits (attribution des icônes CC BY 4.0).
Détail complet (pourquoi une seule route parente, le flux d'import détaillé, - `src/features/profile/` — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
les composants UI partagés `Dialog`/`Checkbox`/`Radio`/`Tooltip`) : contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien
[specs/frontend-architecture.md](specs/frontend-architecture.md). eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une
grille de cases à cocher dans un `<fieldset>`/`<legend>` plutôt qu'un
`<select multiple>` — bien plus repérable/tapable, notamment sur mobile. Prend un
`legend` en prop (pas un libellé fixe interne) : le même composant est rendu
**deux fois** par chaque page consommatrice — une fois pour les allergies
(`AllergyView.kind === "ALLERGY"`), une fois pour les intolérances
(`"INTOLERANCE"`) — les deux listes filtrées côté client à partir d'un seul
`GET /reference/allergies`, mais la sélection (`allergyIds`) reste une seule
liste d'IDs partagée entre les deux groupes (une seule `PATCH /profile/allergies`).
- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage` →
`OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`).
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante (y
compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip
implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`.
- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes réglages, modifiables
à tout moment. **Hot saving** (retour fonctionnel) : pas de bouton "Enregistrer",
chaque section sauvegarde automatiquement peu après la dernière modification —
nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms,
pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé
immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le
handler `onChange` de chaque champ, jamais depuis un `useEffect` générique qui
observerait la valeur — un tel effect se déclencherait aussi au chargement
initial (quand le `GET` peuple le même state), sans moyen propre de distinguer
"vient d'être chargé" de "vient d'être modifié par l'utilisateur".
## Parcours d'inscription — onboarding (apps/web) **Piège trouvé en testant dans le navigateur** : `RedirectIfAuthenticated` (garde de
`/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la
vérification initiale — un `navigate()` explicite dans le gestionnaire de soumission
d'un formulaire qu'elle protège (ex. `SignupPage` après `signup()`, qui met `user` à
jour) entre alors en course avec le propre `<Navigate>` de la garde. Invisible tant
que les deux ciblaient "/", devenu un vrai bug dès que `SignupPage` a dû rediriger
ailleurs (`/onboarding/foyer`). Fix : la décision de redirection est verrouillée une
seule fois, au moment où `isLoading` passe à `false`, plus jamais réévaluée après.
Wizard de 4 écrans lancé une fois juste après l'inscription : **Autre piège, même méthode** : `HouseholdPage` initialisait le régime affiché depuis
`/onboarding/regime` → `/onboarding/foyer` → `/onboarding/sources` `useAuth().user.dietId` (un instantané jamais rafraîchi après une modification faite
(conditionnelle, sautée si aucun foyer n'a été créé/rejoint à l'étape directement via `apiClient`, qui ne touche pas `AuthContext`) — revenait à l'ancienne
précédente) → `/onboarding/allergenes`. Chaque étape a un unique bouton valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la
"Continuer" qui envoie la valeur courante (y compris "aucune" pour page fetch son propre profil frais (`apiClient.me()`) au montage, et
régime/allergènes) — pas de bouton "Passer" séparé, skip implicite. Routes `AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein écran régime pour que le reste de l'app reste cohérent aussi.
sans sidebar, même langage visuel que `/login`/`/signup`. Les mêmes réglages
restent modifiables à tout moment depuis `/parametres/*` (hot saving, pas de
bouton "Enregistrer" — chaque champ sauvegarde peu après la dernière
modification).
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts` et `*.feature` + Tests Cypress (`apps/web/cypress/e2e/*.cy.ts`) : mockent l'API via `cy.intercept`
`@badeball/cypress-cucumber-preprocessor`) : 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
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne Postgres/API, seulement le serveur de dev Vite. Le comportement réel de l'API est
pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de couvert par la suite Mocha d'`apps/api` (contre une vraie base).
l'API est couvert par la suite Mocha d'`apps/api` (contre une vraie base).
Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
[specs/frontend-architecture.md](specs/frontend-architecture.md#tests-cypress--cucumber).
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** : > **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
> Chromium/Electron headless plante au lancement du process GPU > Chromium/Electron headless plante au lancement du process GPU
@ -524,9 +363,8 @@ Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
## Gestion des erreurs (API ↔ web) ## Gestion des erreurs (API ↔ web)
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
**numérique** groupée par famille — `4000` validation, `401x` auth, `402x` **numérique** groupée par famille — `4000` validation, `401x` auth, `404x` not
conflit/état invalide, `403x` autorisation, `404x` not found, `500x` interne found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
— et `ApiErrorResponse`) : l'API renvoie toujours
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel `{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
quel), et le client traduit `code` en libellé français via **i18next** quel), et le client traduit `code` en libellé français via **i18next**
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` → (`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
@ -536,8 +374,8 @@ 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 aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
compris dans les mocks Cypress). compris dans les mocks Cypress).
Détail complet (schéma, liste des ~19 codes actuels, exemples, comment ajouter Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
un nouveau code d'erreur) : [specs/error-handling.md](specs/error-handling.md). [specs/error-handling.md](specs/error-handling.md).
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile` Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
(typé via `AuthLocals`), pas par une augmentation du namespace global Express — (typé via `AuthLocals`), pas par une augmentation du namespace global Express —
@ -553,21 +391,9 @@ voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshare
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons, **i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`), 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`) ; 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. Les en ajouter une est une question de fichier de locale, pas de code. Détail :
libellés des tables de référence (régimes, allergènes, ingrédients, unités,
techniques) vivent aussi dans ce fichier (`catalog.*`, par `key` stable de
`schema.prisma`), jamais stockés en base. Détail :
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation). [specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
## Thème clair / sombre / système (apps/web)
Préférence par utilisateur, persistée côté serveur (`GET`/`PATCH
/preferences`, pas `localStorage`). Tokens de design en custom properties CSS
(`apps/web/src/styles/_theme.scss`) redéfinies sous `[data-theme="dark"]`
(choix explicite) ou sous `prefers-color-scheme: dark` quand aucun
`data-theme` n'est posé (choix "système", le défaut). Détail :
[specs/frontend-architecture.md](specs/frontend-architecture.md#thème-clairsombresystème).
## Données de test (faker.js) ## Données de test (faker.js)
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données `apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données

View file

@ -11,36 +11,4 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — defaults shown (see src/config/env.ts) # Optional — defaults shown (see src/config/env.ts)
# JWT_EXPIRES_IN=7d # JWT_EXPIRES_IN=7d
# AUTH_COOKIE_NAME=session # AUTH_COOKIE_NAME=session
# CORS_ORIGIN=http://localhost:5173,https://localhost # CORS_ORIGIN=http://localhost:5173
# INTENT_SERVICE_BASE_URL=http://localhost:8000
# Required — services/tech-step-intent-service must be running locally (see
# that service's own README) for any recipe save/preview to detect
# techniques at all. Must match that service's own INTENT_SERVICE_SECRET.
# Generate your own the same way as JWT_SECRET above.
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Only needed if you're running services/tech-step-llm-worker locally —
# every /internal/tech-steps/* request is rejected outright while unset.
# Generate your own the same way as JWT_SECRET above; must match the
# worker's own INTERNAL_WORKER_SECRET.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Only needed to use the admin surface (apps/web's /admin/* routes) — every
# /admin/* request 401s while unset (`requireAdmin` fails closed). MUST be a
# different value than JWT_SECRET. Generate your own the same way.
# ADMIN_JWT_SECRET=changeme-a-different-random-secret-at-least-32-chars
# Read only by `src/scripts/create-admin.ts` when its --email/--password/
# --name flags are omitted — never by the running server.
# ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops
# Only needed to create a Forgejo issue from a contact message
# (apps/web's /admin/messages, issue #81). The button is hidden and the
# endpoint answers 503 while any of the three is unset. FORGEJO_TOKEN is a
# personal access token with the write:issue scope — generate it in Forgejo,
# never commit the real value.
# FORGEJO_BASE_URL=https://git.example.com
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
# FORGEJO_REPO=owner/repo

View file

@ -1,36 +0,0 @@
NODE_ENV=test
PORT=3000
# Must point at a *different* database than your `.env`'s — `pnpm test`
# (test-support/reset-db.ts's `resetDatabase()`) TRUNCATEs almost every
# table before each test. Pointing this at the same database `pnpm dev`
# uses will wipe real local data on every test run. Easiest setup: same
# Postgres server/credentials as `.env`, just a different database name —
# create it once with e.g.:
# pnpm exec prisma db execute --url "postgresql://USER:PASSWORD@localhost:PORT/postgres?schema=public" --file - <<< "CREATE DATABASE batchcooking_test;"
# DATABASE_URL="postgresql://USER:PASSWORD@localhost:PORT/batchcooking_test?schema=public" pnpm exec prisma migrate deploy
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking_test?schema=public"
# Required, no default on purpose — generate your own, e.g.:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Required — the Mocha suite exercises the real techStepClassifier, which
# now round-trips over HTTP to services/tech-step-intent-service (no mocks
# of internal services, per this repo's test conventions). Start that
# service locally first (see its own README) with a matching
# INTENT_SERVICE_SECRET, or every test touching tech-step-matcher.ts fails
# with a connection error rather than a useful assertion failure.
INTENT_SERVICE_BASE_URL=http://localhost:8000
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — only needed to exercise tech-step-worker.routes.test.ts's
# success path (a request with a matching secret); every other test runs
# fine without it. Any value at least 32 chars works locally.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — set to run admin-auth.test.ts's login success path and the
# requireAdmin-guarded routes (any value at least 32 chars). Left unset,
# those cases self-skip and only the "no secret configured -> 401" path
# runs. Same "optional in test, fail-closed at runtime" posture as
# INTERNAL_WORKER_SECRET above.
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars

View file

@ -2,6 +2,5 @@
"extension": ["ts"], "extension": ["ts"],
"spec": "test/**/*.test.ts", "spec": "test/**/*.test.ts",
"node-option": ["import=tsx"], "node-option": ["import=tsx"],
"timeout": 10000, "timeout": 10000
"require": ["test-support/mocha-root-hooks.ts"]
} }

View file

@ -60,11 +60,8 @@ COPY --from=build /repo/apps/web/dist ./apps/web/dist
WORKDIR /repo/apps/api WORKDIR /repo/apps/api
EXPOSE 3000 EXPOSE 3000
# Applies pending migrations, seeds the reference data (Diet/Category/ # Applies pending migrations, then seeds the reference data (Diet/Category/
# Allergy — see src/scripts/seed-runtime.ts), then runs any pending one-time # Allergy — see src/scripts/seed-runtime.ts) before starting. Both steps are
# data backfill (src/scripts/backfill-runtime.ts) before starting. migrate # safe to repeat on every container start: migrate deploy only applies
# deploy only applies pending migrations and the seed upserts by unique # pending migrations, and the seed upserts by unique name.
# name, so both are safe to repeat every start; the backfill step gates CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/server.js"]
# itself on a `backfill_state` row (no-op once done) and is written never to
# fail the boot — see its doc comment.
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/scripts/backfill-runtime.js && node dist/server.js"]

View file

@ -23,12 +23,10 @@
"@batch-cooking/shared": "workspace:*", "@batch-cooking/shared": "workspace:*",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"argon2": "0.31.2", "argon2": "0.31.2",
"cookie": "^2.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"socket.io": "^4.8.3",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
@ -40,7 +38,6 @@
"chai": "^5.1.2", "chai": "^5.1.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"mocha": "^10.8.2", "mocha": "^10.8.2",
"socket.io-client": "^4.8.3",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"

View file

@ -1,18 +0,0 @@
-- Adds `TechStep.key` (`key String @unique`) and `TechStepMapping.locale` —
-- same "stable English camelCase uid, French label lives only in apps/web's
-- locales/fr/translation.json" convention as Diet/Category/Unit. `TechStep`
-- was created in the initial migration with no way to identify a row other
-- than its numeric id; this closes that gap so reference-seed-data.ts can
-- upsert it by key like every other reference catalog. `locale` lets the
-- same TechStep carry one matching rule set per language. Neither table has
-- ever been seeded (no rows exist pre-launch), so plain NOT NULL columns
-- with no backfill are safe.
-- AlterTable
ALTER TABLE "tech_step" ADD COLUMN "key" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "tech_step_key_key" ON "tech_step"("key");
-- AlterTable
ALTER TABLE "tech_step_mapping" ADD COLUMN "locale" TEXT NOT NULL;

View file

@ -1,28 +0,0 @@
-- Replaces `Step.tech_step_id` (single nullable FK — at most one technique
-- per step) with `step_tech_step`, an ordered join table — a step can
-- genuinely involve more than one technique (e.g. "Dans une poêle chaude,
-- faire chauffer une noix de beurre" is both `preheat` and `melt`). Per PR
-- review feedback on the first version of this feature; `tech_step`/`step`
-- have never carried real recipe data yet (this feature isn't released),
-- so no backfill is needed.
-- DropForeignKey
ALTER TABLE "step" DROP CONSTRAINT "step_tech_step_id_fkey";
-- AlterTable
ALTER TABLE "step" DROP COLUMN "tech_step_id";
-- CreateTable
CREATE TABLE "step_tech_step" (
"step_id" INTEGER NOT NULL,
"tech_step_id" INTEGER NOT NULL,
"order" INTEGER NOT NULL,
CONSTRAINT "step_tech_step_pkey" PRIMARY KEY ("step_id", "order")
);
-- AddForeignKey
ALTER TABLE "step_tech_step" ADD CONSTRAINT "step_tech_step_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step" ADD CONSTRAINT "step_tech_step_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,23 +0,0 @@
-- Adds `Source.official` (Boolean, no default — every source must state
-- it explicitly, mirrors `RecipeSourceAdapter.official`) and `house_source`,
-- an opt-in join table: a household sees recipes from a source only once a
-- row exists for it (no row = hidden). `sources` has never been seeded
-- (no adapter registered yet), so a plain NOT NULL column with no backfill
-- is safe.
-- AlterTable
ALTER TABLE "sources" ADD COLUMN "official" BOOLEAN NOT NULL;
-- CreateTable
CREATE TABLE "house_source" (
"house_id" INTEGER NOT NULL,
"source_id" INTEGER NOT NULL,
CONSTRAINT "house_source_pkey" PRIMARY KEY ("house_id", "source_id")
);
-- AddForeignKey
ALTER TABLE "house_source" ADD CONSTRAINT "house_source_house_id_fkey" FOREIGN KEY ("house_id") REFERENCES "house"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "house_source" ADD CONSTRAINT "house_source_source_id_fkey" FOREIGN KEY ("source_id") REFERENCES "sources"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,6 +0,0 @@
-- Adds `Source.icon_url` — the source's own logo/favicon, shown next to
-- its name in the `SourceSelect` picker (apps/web). Nullable, no backfill
-- needed for existing rows (none had one to begin with).
-- AlterTable
ALTER TABLE "sources" ADD COLUMN "icon_url" TEXT;

View file

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "end" INTEGER,
ADD COLUMN "start" INTEGER;

View file

@ -1,6 +0,0 @@
-- DropForeignKey
ALTER TABLE "tech_step_mapping" DROP CONSTRAINT "tech_step_mapping_tech_step_id_fkey";
-- DropTable
DROP TABLE "tech_step_mapping";

View file

@ -1,4 +0,0 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER,
ADD COLUMN "context_start" INTEGER;

View file

@ -1,47 +0,0 @@
-- CreateTable
CREATE TABLE "step_tech_step_correction" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"corrector_id" INTEGER NOT NULL,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"previous_tech_step_id" INTEGER,
"corrected_tech_step_id" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"consumed_at" TIMESTAMP(3),
CONSTRAINT "step_tech_step_correction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tech_step_training_suggestion" (
"id" SERIAL NOT NULL,
"tech_step_id" INTEGER NOT NULL,
"locale" TEXT NOT NULL,
"suggested_synonyms" TEXT[],
"suggested_utterances" TEXT[],
"source_type" TEXT NOT NULL,
"source_correction_id" INTEGER,
"status" TEXT NOT NULL DEFAULT 'pending',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tech_step_training_suggestion_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrector_id_fkey" FOREIGN KEY ("corrector_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_previous_tech_step_id_fkey" FOREIGN KEY ("previous_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrected_tech_step_id_fkey" FOREIGN KEY ("corrected_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_source_correction_id_fkey" FOREIGN KEY ("source_correction_id") REFERENCES "step_tech_step_correction"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';

View file

@ -1,10 +0,0 @@
-- CreateTable
CREATE TABLE "utensil" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
CONSTRAINT "utensil_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "utensil_key_key" ON "utensil"("key");

View file

@ -1,42 +0,0 @@
-- CreateTable
CREATE TABLE "step_tech_step_ingredient" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"tech_step_order" INTEGER NOT NULL,
"ingredient_id" INTEGER NOT NULL,
"quantity" DECIMAL(10,2),
"unit_id" INTEGER,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"source" TEXT NOT NULL DEFAULT 'auto',
CONSTRAINT "step_tech_step_ingredient_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "step_tech_step_utensil" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"tech_step_order" INTEGER NOT NULL,
"utensil_id" INTEGER NOT NULL,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"source" TEXT NOT NULL DEFAULT 'auto',
CONSTRAINT "step_tech_step_utensil_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "unit"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_utensil_id_fkey" FOREIGN KEY ("utensil_id") REFERENCES "utensil"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,15 +0,0 @@
-- CreateTable
CREATE TABLE "admin_users" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"token_version" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_login_at" TIMESTAMP(3),
CONSTRAINT "admin_users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "admin_users_email_key" ON "admin_users"("email");

View file

@ -1,32 +0,0 @@
-- AlterTable: usage-metrics timestamps. Existing rows adopt the migration's
-- own timestamp (acceptable one-off skew for trend charts — same posture as
-- the ingredient_unit_catalog migration).
ALTER TABLE "user_profiles" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "recipe" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "planning" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "planning_item" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateTable
CREATE TABLE "analytics_events" (
"id" SERIAL NOT NULL,
"type" TEXT NOT NULL,
"actor_type" TEXT NOT NULL,
"actor_id" INTEGER,
"context" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "analytics_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "analytics_events_type_created_at_idx" ON "analytics_events"("type", "created_at");
-- CreateTable
CREATE TABLE "worker_heartbeats" (
"worker_key" TEXT NOT NULL,
"last_seen_at" TIMESTAMP(3) NOT NULL,
"last_run_at" TIMESTAMP(3),
"last_result" JSONB,
CONSTRAINT "worker_heartbeats_pkey" PRIMARY KEY ("worker_key")
);

View file

@ -1,14 +0,0 @@
-- AlterTable: off-catalog ingredient "placeholder" rows. Every existing row
-- is a real seeded catalog entry, so the flag defaults to false and the four
-- new nullable columns stay NULL for them — no backfill needed.
ALTER TABLE "ingredients" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "ingredients" ADD COLUMN "display_name" TEXT;
ALTER TABLE "ingredients" ADD COLUMN "created_by_id" INTEGER;
ALTER TABLE "ingredients" ADD COLUMN "created_at" TIMESTAMP(3);
ALTER TABLE "ingredients" ADD COLUMN "reviewed_at" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "ingredients_is_placeholder_idx" ON "ingredients"("is_placeholder");
-- AddForeignKey
ALTER TABLE "ingredients" ADD CONSTRAINT "ingredients_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1,22 +0,0 @@
-- CreateTable: temperatures mentioned in the same clause as a detected
-- technique — regex-extracted (services/tech-step-intent-service), no
-- foreign key of its own (the structured value is the data). Same
-- composite FK + ON DELETE CASCADE shape as step_tech_step_ingredient /
-- step_tech_step_utensil (see 20260826121000_step_tech_step_metadata).
CREATE TABLE "step_tech_step_temperature" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"tech_step_order" INTEGER NOT NULL,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"celsius" DECIMAL(6,2),
"gas_mark" INTEGER,
"qualitative" TEXT,
"raw" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'auto',
CONSTRAINT "step_tech_step_temperature_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "step_tech_step_temperature" ADD CONSTRAINT "step_tech_step_temperature_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,11 +0,0 @@
-- CreateTable: idempotency ledger for one-time data backfills run from the
-- production image's start command (apps/api/Dockerfile CMD ->
-- src/scripts/backfill-runtime.ts). One row per completed backfill, keyed
-- by an opaque backfill name; presence of the row is the "already done,
-- don't repeat" signal. See the `BackfillState` model doc comment.
CREATE TABLE "backfill_state" (
"key" TEXT NOT NULL,
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "backfill_state_pkey" PRIMARY KEY ("key")
);

View file

@ -1,27 +0,0 @@
-- Splits "portions to prepare" from "portions actually needed" on a
-- planning slot, and adds the two columns a leftovers-serving item needs
-- (its source item + how much of its own pool was thrown away). Purely
-- additive — no existing column renamed. See the `PlanningItem` model doc
-- comments and issue #34.
-- `discarded_portions` and `source_planning_item_id` are trivial adds.
ALTER TABLE "planning_item"
ADD COLUMN "discarded_portions" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "source_planning_item_id" INTEGER;
-- `portions_requested` is NOT NULL but the table may have rows already:
-- add it nullable, backfill, then lock it down — same discipline as the
-- `ingredient_unit_catalog` / `planning_item_portions` migrations. Every
-- pre-existing row predates this feature, so it had no leftovers:
-- requested == prepared at the time it was added.
ALTER TABLE "planning_item" ADD COLUMN "portions_requested" INTEGER;
UPDATE "planning_item" SET "portions_requested" = "portions";
ALTER TABLE "planning_item" ALTER COLUMN "portions_requested" SET NOT NULL;
-- Self-FK for a "leftovers" item pointing back at the item it draws from.
-- ON DELETE CASCADE: removing the source item removes everything served
-- from its pool too (same as the Planning -> PlanningItem cascade).
ALTER TABLE "planning_item"
ADD CONSTRAINT "planning_item_source_planning_item_id_fkey"
FOREIGN KEY ("source_planning_item_id") REFERENCES "planning_item"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,65 +0,0 @@
-- Guided-tour scenarios, their steps, the catalogue of valid highlight
-- targets, and per-user "seen" progress. All four tables are new — plain
-- CREATEs, nothing to backfill. See issues #42 (the tour engine) and #44
-- (admin CRUD + the DB-backed model this migration implements, which
-- revises #42's originally-planned in-code registry).
-- CreateTable
CREATE TABLE "tour_target" (
"key" TEXT NOT NULL,
"description" TEXT NOT NULL,
CONSTRAINT "tour_target_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "tour_scenario" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tour_scenario_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tour_scenario_step" (
"id" SERIAL NOT NULL,
"scenario_id" INTEGER NOT NULL,
"order" INTEGER NOT NULL,
"route" TEXT,
"target_key" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT "tour_scenario_step_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tour_progress" (
"user_profile_id" INTEGER NOT NULL,
"scenario_id" INTEGER NOT NULL,
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tour_progress_pkey" PRIMARY KEY ("user_profile_id","scenario_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tour_scenario_key_key" ON "tour_scenario"("key");
-- CreateIndex
CREATE UNIQUE INDEX "tour_scenario_step_scenario_id_order_key" ON "tour_scenario_step"("scenario_id", "order");
-- AddForeignKey
ALTER TABLE "tour_scenario_step" ADD CONSTRAINT "tour_scenario_step_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "tour_scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tour_scenario_step" ADD CONSTRAINT "tour_scenario_step_target_key_fkey" FOREIGN KEY ("target_key") REFERENCES "tour_target"("key") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tour_progress" ADD CONSTRAINT "tour_progress_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tour_progress" ADD CONSTRAINT "tour_progress_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "tour_scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,23 +0,0 @@
-- Contact-form messages (issue #80). New table only — nothing to backfill.
-- `author_id` is nullable with ON DELETE SET NULL so a report survives its
-- sender's account deletion (same as ingredients.created_by_id).
-- CreateTable
CREATE TABLE "contact_message" (
"id" SERIAL NOT NULL,
"author_id" INTEGER,
"type" TEXT NOT NULL,
"message" TEXT NOT NULL,
"page_url" TEXT,
"user_agent" TEXT,
"app_version" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "contact_message_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "contact_message_created_at_idx" ON "contact_message"("created_at");
-- AddForeignKey
ALTER TABLE "contact_message" ADD CONSTRAINT "contact_message_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1,8 +0,0 @@
-- Link a contact message to the Forgejo issue an admin created from it
-- (issue #81). Two nullable columns, no backfill — existing rows have no
-- linked issue.
-- AlterTable
ALTER TABLE "contact_message"
ADD COLUMN "forgejo_issue_number" INTEGER,
ADD COLUMN "forgejo_issue_url" TEXT;

View file

@ -1,8 +0,0 @@
-- Ingrédients d'assaisonnement « selon le goût » (issue #128, puce 1 de #3) :
-- une ligne de recette peut désormais n'avoir ni quantité ni unité. Purement
-- additif — toutes les lignes existantes gardent leurs valeurs, aucune n'est
-- réécrite.
-- AlterTable
ALTER TABLE "recipe_ingredient" ALTER COLUMN "quantity" DROP NOT NULL;
ALTER TABLE "recipe_ingredient" ALTER COLUMN "unit_id" DROP NOT NULL;

View file

@ -1,7 +0,0 @@
-- Régimes d'une recette calculés depuis ses ingrédients (issue #131, puce 4
-- de #3) — `RecipeDiet` (tag manuel) est supprimé : `RecipeSummaryView.diets`
-- est désormais dérivé de `IngredientDiet` + de l'allergène `gluten` côté
-- lecture (voir `computeRecipeDietViews`). Aucune donnée à conserver.
-- DropTable
DROP TABLE "recipe_diet";

View file

@ -1,16 +0,0 @@
-- AlterTable: off-catalog technique "placeholder" rows (issue #130, puce 3
-- de #3) — a user names a technique the catalog is missing while correcting a
-- step. Every existing row is a real seeded catalog entry, so the flag
-- defaults to false and the four new nullable columns stay NULL for them —
-- no backfill needed. Mirrors the `ingredients` placeholder migration.
ALTER TABLE "tech_step" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "tech_step" ADD COLUMN "display_name" TEXT;
ALTER TABLE "tech_step" ADD COLUMN "created_by_id" INTEGER;
ALTER TABLE "tech_step" ADD COLUMN "created_at" TIMESTAMP(3);
ALTER TABLE "tech_step" ADD COLUMN "reviewed_at" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "tech_step_is_placeholder_idx" ON "tech_step"("is_placeholder");
-- AddForeignKey
ALTER TABLE "tech_step" ADD CONSTRAINT "tech_step_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -30,8 +30,6 @@ model House {
/// Recipes whose author belonged to this household when they created /// Recipes whose author belonged to this household when they created
/// them — see `Recipe.authorHouseId`. /// them — see `Recipe.authorHouseId`.
authoredRecipes Recipe[] authoredRecipes Recipe[]
/// Which recipe sources this household sees in its recipe tabs — see `HouseSource`.
enabledSources HouseSource[]
@@map("house") @@map("house")
} }
@ -49,6 +47,7 @@ model Diet {
key String @unique key String @unique
users UserProfile[] users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[] ingredients IngredientDiet[]
@@map("diet") @@map("diet")
@ -99,13 +98,9 @@ model UserProfile {
passwordHash String @map("password_hash") passwordHash String @map("password_hash")
/// Bumped to invalidate previously-issued JWTs (e.g. on password change). /// Bumped to invalidate previously-issued JWTs (e.g. on password change).
/// Not in the original spec doc — required for stateless JWT auth. /// Not in the original spec doc — required for stateless JWT auth.
tokenVersion Int @default(0) @map("token_version") tokenVersion Int @default(0) @map("token_version")
houseId Int? @map("house_id") houseId Int? @map("house_id")
dietId Int? @map("diet_id") dietId Int? @map("diet_id")
/// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
/// the `admin_metrics` migration for the dashboard's signup curve. No
/// application code reads it (auth doesn't need it).
createdAt DateTime @default(now()) @map("created_at")
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
@ -124,21 +119,6 @@ model UserProfile {
/// list regardless of that real-world cardinality. /// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin") administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference? preferences UserPreference?
/// Guided-tour scenarios this profile has seen (completed or skipped) —
/// see {@link TourProgress} and issues #42 / #44.
tourProgress TourProgress[]
/// Tech-step corrections this profile has submitted (any profile that can
/// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`).
techStepCorrections StepTechStepCorrection[]
/// Placeholder `Ingredient` rows this profile created by typing a free-text
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
/// Placeholder `TechStep` rows this profile proposed while correcting a step
/// — see `TechStep.isPlaceholder`.
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
contactMessages ContactMessage[]
@@map("user_profiles") @@map("user_profiles")
} }
@ -206,12 +186,6 @@ model Planning {
startDate DateTime @map("start_date") @db.Date startDate DateTime @map("start_date") @db.Date
finishDate DateTime @map("finish_date") @db.Date finishDate DateTime @map("finish_date") @db.Date
houseId Int @map("house_id") houseId Int @map("house_id")
/// When this planning row was first created. Added by the `admin_metrics`
/// migration purely for the admin dashboard's activity curves — no
/// application code reads it. Rows that predate the migration all get the
/// migration's own timestamp (same acceptable one-off skew as the
/// `ingredient_unit_catalog` migration), which is fine for a trend chart.
createdAt DateTime @default(now()) @map("created_at")
house House @relation(fields: [houseId], references: [id], onDelete: Cascade) house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
items PlanningItem[] items PlanningItem[]
@ -225,36 +199,10 @@ model PlanningItem {
weekDay String @map("week_day") weekDay String @map("week_day")
meal String meal String
recipeId Int @map("recipe_id") recipeId Int @map("recipe_id")
/// Portions actually *prepared* for this slot — always a whole multiple
/// of `recipe.portions` (a dish is cooked in whole batches). Derived from
/// `portionsRequested` + the recipe's yield via `computeBatchedPortions`
/// (`@batch-cooking/shared`) in `addPlanningItem`, never sent raw by the
/// client. On a "leftovers" item (`sourcePlanningItemId` set) it always
/// equals `portionsRequested`.
portions Int portions Int
/// How many portions this slot must actually feed. For a fresh item
/// (`sourcePlanningItemId` null), `<= portions` — the gap is this item's
/// leftover pool. For a "leftovers" item, always `== portions` (serving
/// leftovers prepares nothing new, so it can't generate more — no
/// leftovers-on-leftovers).
portionsRequested Int @map("portions_requested")
/// Non-null only on a "leftovers" item: the item whose leftover pool it
/// draws from. `onDelete: Cascade` — deleting the source item also
/// deletes everything served from its leftovers (same reasoning as the
/// existing `Planning` -> `PlanningItem` cascade).
sourcePlanningItemId Int? @map("source_planning_item_id")
/// Portions of *this* item's leftover pool explicitly thrown away (the
/// basket's "jeter" button). Always 0 on a "leftovers" item (its own
/// pool is null by construction).
discardedPortions Int @default(0) @map("discarded_portions")
/// See `Planning.createdAt` — same admin-metrics-only timestamp.
createdAt DateTime @default(now()) @map("created_at")
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id]) recipe Recipe @relation(fields: [recipeId], references: [id])
sourcePlanningItem PlanningItem? @relation("PlanningItemLeftoverSource", fields: [sourcePlanningItemId], references: [id], onDelete: Cascade)
/// The "leftovers" items served from this item's own pool — inverse of `sourcePlanningItem`.
leftoverUses PlanningItem[] @relation("PlanningItemLeftoverSource")
@@map("planning_item") @@map("planning_item")
} }
@ -275,49 +223,16 @@ model PlanningItem {
/// Empty until a concrete adapter is registered (none exists yet, see /// Empty until a concrete adapter is registered (none exists yet, see
/// recipe-source-adapter.ts). /// recipe-source-adapter.ts).
model Source { model Source {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique key String @unique
name String name String
url String? url String?
/// Whether this is an official API (the site/publisher provides
/// structured recipe data itself) or unofficial web scraping (we parse
/// HTML the site never committed to a stable shape for) — mirrors
/// `RecipeSourceAdapter.official` (recipe-source-adapter.ts), synced the
/// same way as `key`/`name`. Surfaced to households picking which
/// sources to enable (see `HouseSource`) so scraped content is never
/// mistaken for an official feed.
official Boolean
/// The source's own logo/favicon URL, shown next to its name in
/// `SourceSelect` (apps/web) — mirrors `RecipeSourceAdapter.iconUrl`,
/// synced the same way as `name`/`official`. `null` if the source has
/// none worth showing.
iconUrl String? @map("icon_url")
recipes Recipe[] recipes Recipe[]
enabledHouses HouseSource[]
@@map("sources") @@map("sources")
} }
/// Which sources a household has chosen to see recipes from — opt-in: no
/// row means disabled. A newly created household starts with nothing
/// enabled (see the household-creation step in the signup wizard, and the
/// household settings page for changing this later); every recipe catalog
/// tab (`recipe.service.ts`'s `listRecipes`) filters out recipes whose
/// `sourceId` isn't in this list for the viewer's household — a
/// manually-authored recipe (`sourceId` `null`) is never affected, this
/// only ever hides recipes that came from an external source.
model HouseSource {
houseId Int @map("house_id")
sourceId Int @map("source_id")
house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade)
@@id([houseId, sourceId])
@@map("house_source")
}
/// Not in the original spec doc — who can *read* a recipe. Controls only /// Not in the original spec doc — who can *read* a recipe. Controls only
/// visibility, never editing: a recipe can only ever be edited/deleted by /// visibility, never editing: a recipe can only ever be edited/deleted by
/// its `author`, whatever this is set to (see `recipe.service.ts`). /// its `author`, whatever this is set to (see `recipe.service.ts`).
@ -365,10 +280,6 @@ model Recipe {
/// the author had no household yet. /// the author had no household yet.
authorHouseId Int? @map("author_house_id") authorHouseId Int? @map("author_house_id")
visibility RecipeVisibility @default(PERSONAL) visibility RecipeVisibility @default(PERSONAL)
/// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
/// the `admin_metrics` migration for the dashboard's "recipes created"
/// curve. No application code reads it.
createdAt DateTime @default(now()) @map("created_at")
author UserProfile @relation(fields: [authorId], references: [id]) author UserProfile @relation(fields: [authorId], references: [id])
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull) authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
@ -377,6 +288,7 @@ model Recipe {
steps Step[] steps Step[]
planningItems PlanningItem[] planningItems PlanningItem[]
favoritedBy RecipeFavorite[] favoritedBy RecipeFavorite[]
diets RecipeDiet[]
@@unique([sourceId, externalId]) @@unique([sourceId, externalId])
@@map("recipe") @@map("recipe")
@ -397,6 +309,21 @@ model RecipeFavorite {
@@map("recipe_favorite") @@map("recipe_favorite")
} }
/// Explicit join table for the recipe <-> diet "associated regime" tags
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
/// manual reminder set by whoever creates/edits the recipe, not computed
/// from its ingredients.
model RecipeDiet {
recipeId Int @map("recipe_id")
dietId Int @map("diet_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
@@id([recipeId, dietId])
@@map("recipe_diet")
}
/// `key` is `@unique` — not in the original spec doc, added so the seed /// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (reference-seed-data.ts) can `upsert` by key and stay /// script (reference-seed-data.ts) can `upsert` by key and stay
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A /// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
@ -548,52 +475,14 @@ model Ingredient {
/// ingredient↔recipe linking in the database, the UI only pre-fills the /// ingredient↔recipe linking in the database, the UI only pre-fills the
/// catalog's own search with this ingredient's name). /// catalog's own search with this ingredient's name).
reproducible Boolean @default(false) reproducible Boolean @default(false)
/// `true` = a "placeholder" row: a free-text ingredient a user typed on a
/// recipe line because the seeded catalog had nothing matching (see
/// specs/batch-cooking-modele.md's "ingrédients hors-catalogue"). Such a
/// row has `displayName` non-null, a generated `key` (`placeholder:<uuid>`,
/// never an i18n label), and the default metadata (`JAR`/`dryGoods`/`other`,
/// no allergen/diet links). It is referenced by `RecipeIngredient` like any
/// other `Ingredient`, but `GET /reference/ingredients` and
/// `ingredient-matcher.ts`'s `loadIngredientCatalog` both exclude it — it is
/// never a browsable/matchable target, only a per-line stand-in a
/// maintainer later promotes into a real catalog entry by hand. The
/// `/admin/catalog/placeholders` view groups these by normalized name so
/// the maintainer sees which ingredients the catalog is missing.
isPlaceholder Boolean @default(false) @map("is_placeholder")
/// Display name of a placeholder ingredient — the exact text the user
/// typed. `null` for a real catalog row (whose label lives in i18n under
/// `catalog.ingredients.<key>`). Invariant "non-null iff `isPlaceholder`"
/// is enforced service-side, not by the schema (same posture as other
/// cross-field invariants here).
displayName String? @map("display_name")
/// Profile that first created this placeholder — context for the admin
/// catalog-gap review. `onDelete: SetNull` so deleting an account never
/// blocks on, or cascades into, the recipes that still use its placeholder.
/// `null` for a real catalog row.
createdById Int? @map("created_by_id")
/// When this placeholder was created. `null` for a real catalog row (the
/// seed carries no timestamp).
createdAt DateTime? @map("created_at")
/// Stamped when an admin has triaged this catalog gap
/// (`PATCH /admin/catalog/placeholders/mark-reviewed`) — the group then
/// drops out of the default "à traiter" list. `null` while pending / for a
/// real catalog row.
reviewedAt DateTime? @map("reviewed_at")
recipes RecipeIngredient[] recipes RecipeIngredient[]
allergies IngredientAllergy[] allergies IngredientAllergy[]
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}. /// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
dislikedBy UserProfileDislikedIngredient[] dislikedBy UserProfileDislikedIngredient[]
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}. /// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
diets IngredientDiet[] diets IngredientDiet[]
/// Mentions of this ingredient detected in a step's free text alongside a
/// technique — see `StepTechStepIngredient`.
stepTechSteps StepTechStepIngredient[]
/// The profile that created this row when it is a placeholder — see `createdById`.
createdBy UserProfile? @relation("PlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([isPlaceholder])
@@map("ingredients") @@map("ingredients")
} }
@ -666,15 +555,9 @@ model Unit {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique key String @unique
type UnitType type UnitType
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4) toBaseFactor Decimal @default(1) @db.Decimal(12, 4) @map("to_base_factor")
recipeIngredients RecipeIngredient[] recipeIngredients RecipeIngredient[]
/// Ingredient mentions detected alongside a technique in a step's free
/// text (e.g. "50g" resolved against this `Unit`) — see
/// `StepTechStepIngredient`. Distinct from `recipeIngredients` above
/// (the recipe's structured ingredient list): a step can mention a
/// quantity+unit that was never itself an ingredient list line.
stepTechStepIngredients StepTechStepIngredient[]
@@map("unit") @@map("unit")
} }
@ -684,104 +567,39 @@ model Unit {
/// quantity per recipe, so this join table carries quantity + unit /// quantity per recipe, so this join table carries quantity + unit
/// (project decision, not in the original spec doc). /// (project decision, not in the original spec doc).
model RecipeIngredient { model RecipeIngredient {
recipeId Int @map("recipe_id") recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id") ingredientId Int @map("ingredient_id")
/// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line — quantity Decimal @db.Decimal(10, 2)
/// salt, pepper… added to taste, with nothing to scale by portions or to unitId Int @map("unit_id")
/// aggregate on a shopping list. The "both null or neither" invariant is
/// enforced service-side (and by `recipeIngredientInputSchema`), not by
/// the schema — same posture as this file's other cross-field invariants.
quantity Decimal? @db.Decimal(10, 2)
unitId Int? @map("unit_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit? @relation(fields: [unitId], references: [id]) unit Unit @relation(fields: [unitId], references: [id])
@@id([recipeId, ingredientId]) @@id([recipeId, ingredientId])
@@map("recipe_ingredient") @@map("recipe_ingredient")
} }
/// `key` is `@unique` — same convention as `Diet`/`Unit`: a stable English
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
/// label lives in `apps/web`'s `locales/fr/translation.json` under
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
///
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
/// `TechStepClassifierService`) used to go through a DB-backed
/// `TechStepMapping` table of per-locale regex expressions — replaced with
/// a spaCy-based model (`services/tech-step-intent-service`) trained from
/// in-code data (`tech-step-training-data.ts`) once regexes turned out
/// unable to generalize past their own literal vocabulary. Nothing
/// queries/edits that matching data at runtime anymore (it only ever feeds
/// that service's one-time training pass), so it no longer needs a table
/// of its own — this row now only exists to be a stable id/key other
/// tables (`StepTechStep`) reference.
model TechStep { model TechStep {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique
/// `true` = a "placeholder" technique: a free-text technique a user typed steps Step[]
/// while correcting a step, because nothing in the seeded catalog matched mappings TechStepMapping[]
/// (issue #131 for the ingredient precedent this mirrors — see
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
/// training data of its own, and is excluded from
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
/// like any other technique (the correction that created it applies
/// immediately), but a maintainer reviews it at
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
isPlaceholder Boolean @default(false) @map("is_placeholder")
/// The exact text the user typed — `null` for a real catalog row (whose
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
/// `isPlaceholder`, enforced service-side.
displayName String? @map("display_name")
/// Profile that first proposed this technique — context for the admin
/// review. `onDelete: SetNull` so deleting an account never blocks on the
/// recipes still using its placeholder. `null` for a real catalog row.
createdById Int? @map("created_by_id")
/// When this placeholder was proposed. `null` for a real catalog row.
createdAt DateTime? @map("created_at")
/// Stamped once a maintainer has triaged this proposal
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
/// `null` while pending / for a real catalog row.
reviewedAt DateTime? @map("reviewed_at")
steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`.
correctionsAsPrevious StepTechStepCorrection[] @relation("PreviousTechStep")
/// Corrections where this technique was the *corrected* (user-asserted)
/// match — see `StepTechStepCorrection.correctedTechStepId`.
correctionsAsCorrected StepTechStepCorrection[] @relation("CorrectedTechStep")
/// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`.
trainingSuggestions TechStepTrainingSuggestion[]
/// The profile that proposed this row when it is a placeholder — see `createdById`.
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([isPlaceholder])
@@map("tech_step") @@map("tech_step")
} }
/// `key` is `@unique`, same bare id+key shape as `TechStep` — no /// Used by the recipe-import pipeline to auto-detect which technique a raw
/// categorization taxonomy like `Ingredient` needed yet, and no matching /// instruction step corresponds to (expression = text pattern, weight = match score).
/// data of its own here either: unlike `TechStep` (whose matching synonyms model TechStepMapping {
/// used to live in TS and were moved into id Int @id @default(autoincrement())
/// `services/tech-step-intent-service`'s `training_data.py`), this catalog techStepId Int @map("tech_step_id")
/// was *born* owned by that service (`utensil_vocabulary.py`) since nothing expression String
/// pre-existing needed it — this row only exists to be a stable id/key weight Int
/// `StepTechStepUtensil` references, and to carry a French label
/// (`apps/web`'s `catalog.utensils.<key>`, see `reference-seed-data.ts`'s
/// `UTENSILS`).
model Utensil {
id Int @id @default(autoincrement())
key String @unique
steps StepTechStepUtensil[] techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
@@map("utensil") @@map("tech_step_mapping")
} }
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the /// Modeled as one-to-many (a step belongs to exactly one recipe), not the
@ -794,465 +612,10 @@ model Step {
description String description String
picture String? picture String?
order Int order Int
techStepId Int? @map("tech_step_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
techSteps StepTechStep[] techStep TechStep? @relation(fields: [techStepId], references: [id], onDelete: SetNull)
/// User-submitted corrections to this step's detected techniques — see
/// `StepTechStepCorrection`.
corrections StepTechStepCorrection[]
@@map("step") @@map("step")
} }
/// A single step's *ordered sequence* of detected techniques — one
/// instruction can genuinely involve more than one (e.g. "Dans une poêle
/// chaude, faire chauffer une noix de beurre" is both `preheat` and
/// `melt`), which is why this replaced the original single nullable
/// `Step.techStepId` FK (per PR review feedback on the first version of
/// this feature). `order` is the position within *this step* (0-based, in
/// the order `matchTechStepSpans` — `tech-step-matcher.ts` — detected the
/// techniques in the description), not a global ordering across different
/// steps of the recipe (that's `Step.order`).
///
/// `start`/`end` are the tight matched *keyword* span within
/// `Step.description` (see `TechStepMatch`, `tech-step-matcher.ts`) — what
/// the recipe detail view highlights strongly, with a tooltip.
/// `contextStart`/`contextEnd` are the wider *clause* the keyword was found
/// in (e.g. "Dans une poêle chaude" around a `preheat` keyword of "poêle
/// chaude") — always contains `start`/`end` — what the detail view
/// highlights more subtly around it, so both "the exact trigger word(s)"
/// and "how much of the sentence is about this technique" are visible.
/// Nullable, **not backfilled**: adding them `NOT NULL` without a default
/// would fail outright against any pre-existing row, the same mistake the
/// `ingredient_unit_catalog` migration made against real prod data. A row
/// from before a column existed just has no span for it (no highlight)
/// until its recipe is next saved, which recomputes every step's
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
/// graceful degradation, not a permanent gap.
///
/// `source` distinguishes a `"manual"` row — written immediately when a
/// user submits a `StepTechStepCorrection` that asserts a technique
/// (`recipe-tech-step-correction.service.ts`'s `applyManualCorrection`),
/// not just recorded as a pending suggestion — from an `"auto"` row the
/// classifier itself produced (`tech-step-matcher.ts`). Both kinds coexist
/// in the same ordered sequence; the detail view (`apps/web`) renders them
/// with a different highlight color so a viewer can tell which is which.
/// `backfillTechSteps` (`scripts/backfill-tech-steps.ts`) only ever
/// deletes/recreates `"auto"` rows — a `"manual"` row survives a
/// classifier/corpus change until a user (or a future moderation feature)
/// explicitly changes it again.
model StepTechStep {
stepId Int @map("step_id")
techStepId Int @map("tech_step_id")
order Int
start Int?
end Int?
contextStart Int? @map("context_start")
contextEnd Int? @map("context_end")
source String @default("auto")
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
/// Ingredients mentioned in the same clause as this technique occurrence
/// — see `StepTechStepIngredient`.
ingredients StepTechStepIngredient[]
/// Utensils mentioned in the same clause as this technique occurrence —
/// see `StepTechStepUtensil`.
utensils StepTechStepUtensil[]
/// Temperatures mentioned in the same clause as this technique occurrence
/// — see `StepTechStepTemperature`.
temperatures StepTechStepTemperature[]
@@id([stepId, order])
@@map("step_tech_step")
}
/// An ingredient mention found in the same *clause* as one `StepTechStep`
/// occurrence (`tech-step-matcher.ts`'s `matchTechStepSpans` — clauses are
/// already the unit a technique is judged on, see that file's doc comment,
/// so "same clause" is the association rule, no dependency-parsing needed).
/// `quantity`/`unitId` are best-effort, populated only when a leading
/// numeric expression immediately preceding the ingredient mention resolved
/// against the `Unit` catalog (`ingredient-matcher.ts`'s
/// `findIngredientMentions`) — both `null` when the clause names the
/// ingredient with no quantity ("ajouter le sel"). `start`/`end` are the
/// ingredient mention's own span in `Step.description`, same `[start, end)`
/// convention as `StepTechStep.start`/`end`. `source` mirrors
/// `StepTechStep.source` (`"auto"` today, room for a future user
/// correction without a shape change).
model StepTechStepIngredient {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
ingredientId Int @map("ingredient_id")
quantity Decimal? @db.Decimal(10, 2)
unitId Int? @map("unit_id")
start Int
end Int
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit? @relation(fields: [unitId], references: [id])
@@map("step_tech_step_ingredient")
}
/// A utensil mention found in the same clause as one `StepTechStep`
/// occurrence — same association rule as `StepTechStepIngredient` (see its
/// doc comment). Detected by
/// `services/tech-step-intent-service`'s own utensil `PhraseMatcher`
/// (`intent_service/utensil_vocabulary.py`), returned alongside technique
/// entities in `POST /v1/process` and filtered to this clause's span by
/// `tech-step-matcher.ts`.
model StepTechStepUtensil {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
utensilId Int @map("utensil_id")
start Int
end Int
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
utensil Utensil @relation(fields: [utensilId], references: [id], onDelete: Cascade)
@@map("step_tech_step_utensil")
}
/// A temperature mention found in the same *clause* as one `StepTechStep`
/// occurrence — same association rule as `StepTechStepIngredient` /
/// `StepTechStepUtensil` (see their doc comments). Detected by
/// `services/tech-step-intent-service`'s regex pass (`temperature_extraction.py`),
/// not a `PhraseMatcher` — a temperature is numeric/pattern, not a word from
/// a finite vocabulary. Carries no foreign key: the structured value *is*
/// the data. At least one of `celsius` / `gasMark` / `qualitative` is
/// non-null:
/// - `celsius` — an oven/pan temperature in °C (a °F mention is
/// converted + rounded upstream).
/// - `gasMark` — a gas-mark / thermostat number ("th. 6").
/// - `qualitative` — "low" | "medium" | "high" ("feu doux/moyen/vif").
/// `raw` is the exact recognized fragment, `start`/`end` its span in
/// `Step.description` ([start, end), same as `StepTechStep.start`/`end`).
/// `source` mirrors the others (`"auto"` only today — no correction UI).
model StepTechStepTemperature {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
start Int
end Int
celsius Decimal? @db.Decimal(6, 2)
gasMark Int? @map("gas_mark")
qualitative String?
raw String
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
@@map("step_tech_step_temperature")
}
/// One user-submitted correction to a `Step`'s detected techniques —
/// captures ADD (a missing technique the classifier didn't find),
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
/// `previousTechStepId` is the (possibly absent) match being corrected,
/// `correctedTechStepId` is what the user asserts instead (absent means
/// "no technique belongs here"). Both `null` at once is invalid (nothing
/// would have changed) — enforced service-side, not by the schema, same
/// posture as other cross-field invariants in this codebase (e.g.
/// `RecipeIngredientView`'s no-duplicate-ingredient check).
///
/// `start`/`end` are the user's selected `[start, end)` span within
/// `Step.description` (`String.prototype.slice` convention, same as
/// `StepTechStep`) — what they highlighted before assigning a technique to
/// it, not necessarily identical to any existing `StepTechStep` span.
///
/// Never edited/deleted once created (an audit trail of what was actually
/// submitted) — only `consumedAt` changes, stamped once
/// `services/tech-step-llm-worker` has turned this correction into a
/// `TechStepTrainingSuggestion` for a maintainer to review, so the same
/// correction isn't proposed twice on the next scheduled run.
model StepTechStepCorrection {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
/// Any profile that could *view* the recipe when they submitted this, not
/// necessarily its author — see `assertRecipeVisible`,
/// `recipe.service.ts`.
correctorId Int @map("corrector_id")
start Int
end Int
previousTechStepId Int? @map("previous_tech_step_id")
correctedTechStepId Int? @map("corrected_tech_step_id")
createdAt DateTime @default(now()) @map("created_at")
consumedAt DateTime? @map("consumed_at")
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
corrector UserProfile @relation(fields: [correctorId], references: [id], onDelete: Cascade)
previousTechStep TechStep? @relation("PreviousTechStep", fields: [previousTechStepId], references: [id], onDelete: SetNull)
correctedTechStep TechStep? @relation("CorrectedTechStep", fields: [correctedTechStepId], references: [id], onDelete: SetNull)
trainingSuggestions TechStepTrainingSuggestion[]
@@map("step_tech_step_correction")
}
/// A candidate addition to `TECH_STEP_TRAINING_DATA`
/// (`tech-step-training-data.ts`), proposed by `services/tech-step-llm-worker`
/// from one of two sources (`sourceType`):
///
/// - `"correction"` — a user's `StepTechStepCorrection`, turned into
/// suggested synonyms/utterances by the worker's LLM
/// (`transform-corrections` job).
/// - `"llm_audit"` — a low-confidence NLP clause on an *existing* recipe the
/// worker periodically samples and re-judges with its LLM
/// (`audit-low-confidence` job); no `sourceCorrectionId` in this case.
///
/// Deliberately never auto-applied to `tech-step-training-data.ts` — a
/// maintainer reviews `status: "pending"` rows (see
/// `list-pending-training-suggestions.ts`) and edits that file by hand,
/// same "generated suggestion, human-reviewed source of truth" split as a
/// linter's autofix vs. a human-authored diff. `retrain-tech-steps.ts` then
/// flips `status` to `"applied"`/`"rejected"` once a maintainer has acted on
/// a batch, so the same suggestion isn't reviewed twice.
///
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
/// (`String[]`), not a join table — unlike this schema's other list-shaped
/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text
/// proposed once for a human to read, not ids referencing another catalog
/// table, so there's nothing for a join table to normalize against.
model TechStepTrainingSuggestion {
id Int @id @default(autoincrement())
techStepId Int @map("tech_step_id")
locale String
suggestedSynonyms String[] @map("suggested_synonyms")
suggestedUtterances String[] @map("suggested_utterances")
sourceType String @map("source_type")
sourceCorrectionId Int? @map("source_correction_id")
status String @default("pending")
createdAt DateTime @default(now()) @map("created_at")
techStep TechStep @relation(fields: [techStepId], references: [id])
sourceCorrection StepTechStepCorrection? @relation(fields: [sourceCorrectionId], references: [id], onDelete: SetNull)
@@map("tech_step_training_suggestion")
}
// -----------------------------------------------------------------------------
// Admin application
// A separate operations app (usage metrics, microservice monitoring, tech-step
// correction triage) — see specs/backend-architecture.md's "admin" section.
// -----------------------------------------------------------------------------
/// An operator of the admin application (`apps/admin-web` / the `/admin/*`
/// API surface). Deliberately its **own** table with **no relation** to
/// `UserProfile`: admin access is a completely separate concern from being
/// an end user of the recipe app — a person can be one, both or neither,
/// and the two auth mechanisms (`requireAdmin` vs `requireAuth`, distinct
/// cookies, distinct JWT secrets) never overlap. No self-service signup —
/// the first row is created out-of-band by `src/scripts/create-admin.ts`,
/// and (for now) there's no in-app admin-management UI.
model AdminUser {
id Int @id @default(autoincrement())
email String @unique
/// argon2 hash of the password — same hashing as `UserProfile.passwordHash`
/// (`auth.service.ts`'s `hashOptions`, cheaper cost under NODE_ENV=test).
passwordHash String @map("password_hash")
/// Display name shown in the admin UI's account menu.
name String
/// Bumped to invalidate previously-issued admin JWTs — same mechanism as
/// `UserProfile.tokenVersion`, checked on every request by `requireAdmin`.
tokenVersion Int @default(0) @map("token_version")
createdAt DateTime @default(now()) @map("created_at")
/// Stamped on every successful login — a cheap "is this account still in
/// use" signal for the operator managing admins by hand.
lastLoginAt DateTime? @map("last_login_at")
@@map("admin_users")
}
/// One recorded product event, for the admin dashboard's usage metrics.
/// Written fire-and-forget by `lib/analytics.service.ts`'s `recordEvent`
/// from a handful of key service methods (signup, recipe import/create,
/// planning add, cooking-session open, tech-step correction, shopping-list
/// view) — never on the request's critical path, so a failed insert is
/// logged and swallowed, never surfaced to the user.
///
/// `type` is a free `String` (`"user.signup"`, `"recipe.imported"`…), not
/// an enum: adding a new event to instrument is a one-line call site
/// change with **no migration**. `actorId` is a `UserProfile.id` when
/// `actorType == "user"` but carries **no FK** — an event is an immutable
/// historical fact that must outlive the account it describes (a deleted
/// user's signup still counts on the curve). `context` is a small free
/// JSON blob (`{ sourceKey, recipeId, … }`) for slicing later; nothing
/// queries into it today.
model AnalyticsEvent {
id Int @id @default(autoincrement())
type String
actorType String @map("actor_type")
actorId Int? @map("actor_id")
context Json?
createdAt DateTime @default(now()) @map("created_at")
@@index([type, createdAt])
@@map("analytics_events")
}
/// Liveness/last-run record for a background worker that has no inbound
/// HTTP surface of its own — one row per worker (`workerKey`, today only
/// `"tech-step-llm-worker"`). The worker POSTs `/internal/tech-steps/heartbeat`
/// (`requireInternalWorker`) on boot, on every scheduler tick, and after
/// each job; the admin monitoring board reads this to show the worker as
/// up / stale / down and to surface its last job result. Upserted, never
/// accumulated — only the latest state matters.
model WorkerHeartbeat {
workerKey String @id @map("worker_key")
lastSeenAt DateTime @map("last_seen_at")
/// Set only by a `"job"` heartbeat — the last time the worker actually ran a job (vs. just a tick proving it's alive).
lastRunAt DateTime? @map("last_run_at")
/// Small JSON summary of that last job (`{ job, ok, counts }`).
lastResult Json? @map("last_result")
@@map("worker_heartbeats")
}
/// Idempotency ledger for one-time data backfills run from the production
/// image's start command (`src/scripts/backfill-runtime.ts`, invoked in
/// apps/api/Dockerfile's CMD after `prisma migrate deploy` + `seed-runtime`).
///
/// The CMD re-runs on every container start, but a backfill here *rewrites*
/// existing rows (unlike `seed-runtime`, which only upserts and is safe to
/// repeat) — e.g. `step_tech_step_metadata_20260904` recomputes every
/// `StepTechStep` sequence against the intent service. So each backfill
/// gates itself on a row in this table: `key` present → already done, skip;
/// absent → run it, then insert the row. A schema migration only ever
/// creates the table structure; it can't run TypeScript against the intent
/// service, which is why this is a start-command step and not a migration.
model BackfillState {
key String @id
completedAt DateTime @default(now()) @map("completed_at")
@@map("backfill_state")
}
/// Catalogue des cibles de surlignage valides pour un didacticiel guidé —
/// même esprit que `TechStep`/`Unit`/`Diet` : une valeur de référence
/// maintenue par le code, seedée (`reference-seed-data.ts`). Chaque `key`
/// correspond à un attribut `data-tour-<key>` réellement posé quelque part
/// dans le frontend ; un admin choisit parmi ces cibles en construisant un
/// scénario, il ne peut pas en inventer une (poser un nouveau `data-tour-*`
/// reste un changement de code + une ligne seedée ici). Voir issues #42/#44.
model TourTarget {
key String @id
/// Libellé pour aider l'admin à choisir dans le formulaire, ex.
/// "Bouton + d'un créneau du planning".
description String
steps TourScenarioStep[]
@@map("tour_target")
}
/// Un scénario de didacticiel — une séquence d'étapes surlignées, rejouable
/// et éditable depuis l'admin (#44). `key` est une clé stable (comme
/// `Diet.key`/`Source.key`) : `"general"` / `"techStepMetadata"` pour les
/// deux scénarios seedés, libre pour un scénario créé par un admin.
model TourScenario {
id Int @id @default(autoincrement())
key String @unique
/// Libellé admin, ex. "Tour d'orientation".
name String
/// Désactiver sans supprimer — `GET /reference/tours` ne renvoie que les
/// scénarios activés.
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
steps TourScenarioStep[]
progress TourProgress[]
@@map("tour_scenario")
}
/// Une étape d'un scénario : (si `route` est défini) navigue vers cette
/// route, puis surligne l'élément portant `data-tour-<targetKey>` avec un
/// popover `title`/`content`. `title`/`content` sont du **texte libre**
/// saisi par l'admin, pas des clés i18n (#44 : le chrome de l'UI vient
/// d'i18next, le contenu administré dynamiquement est du texte, comme le
/// nom d'une recette).
model TourScenarioStep {
id Int @id @default(autoincrement())
scenarioId Int @map("scenario_id")
order Int
/// Route React Router à ouvrir avant cette étape ; `null` = rester sur la
/// route de l'étape précédente.
route String?
targetKey String @map("target_key")
title String
content String
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
target TourTarget @relation(fields: [targetKey], references: [key])
@@unique([scenarioId, order])
@@map("tour_scenario_step")
}
/// Présence d'une ligne = ce profil a *vu* ce scénario. Le terminer **ou**
/// le passer volontairement crée la ligne (pas de re-nag après un skip
/// délibéré). Révise la version de #42 (`tourKey: String` libre) en une
/// vraie FK vers `TourScenario` (#44).
model TourProgress {
userProfileId Int @map("user_profile_id")
scenarioId Int @map("scenario_id")
completedAt DateTime @default(now()) @map("completed_at")
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
@@id([userProfileId, scenarioId])
@@map("tour_progress")
}
// -----------------------------------------------------------------------------
// Contact
// In-app "report a bug / suggestion" form — user-submitted, read-only for
// admins at /admin/messages. See issues #80 (this) and #81 (turn a message
// into a Forgejo issue).
// -----------------------------------------------------------------------------
/// A message sent via the app's contact form (bug, suggestion, other).
/// Written once by a signed-in user, never edited afterwards — consulted
/// read-only from the admin app. `authorId` is nullable + `onDelete: SetNull`
/// (same choice as `Ingredient.createdById`): a bug report keeps its value
/// even if the account that sent it is deleted in the meantime.
model ContactMessage {
id Int @id @default(autoincrement())
authorId Int? @map("author_id")
/// "bug" | "suggestion" | "other" — a plain `String` backed by the shared
/// `CONTACT_MESSAGE_TYPES` const (same approach as
/// `TechStepTrainingSuggestion.status` / `AnalyticsEvent.type`): adding a
/// value is one line, no Postgres enum migration.
type String
message String
/// Technical context captured automatically at submit time, for triage —
/// all nullable (an older client, or a direct API call, can omit them).
pageUrl String? @map("page_url")
userAgent String? @map("user_agent")
appVersion String? @map("app_version")
createdAt DateTime @default(now()) @map("created_at")
/// Number of the Forgejo issue an admin created from this message via
/// `/admin/messages` (issue #81). `null` = no ticket yet. Non-null ⇒ the
/// "Créer un ticket" button disappears (one message links to at most one
/// issue).
forgejoIssueNumber Int? @map("forgejo_issue_number")
/// The issue's html URL (`<base>/<owner>/<repo>/issues/<n>`) — stored
/// as-is rather than rebuilt from `FORGEJO_*` config, so the link stays
/// correct even if that config changes later.
forgejoIssueUrl String? @map("forgejo_issue_url")
author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@map("contact_message")
}

View file

@ -1,7 +1,6 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { seedReferenceData } from "../src/db/reference-seed-data.js"; import { seedReferenceData } from "../src/db/reference-seed-data.js";
import { registerAllRecipeSources } from "../src/sources/index.js";
// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in // Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in
// the rest of the app's config/env plumbing this doesn't need), run via // the rest of the app's config/env plumbing this doesn't need), run via
@ -10,7 +9,6 @@ import { registerAllRecipeSources } from "../src/sources/index.js";
// `prisma migrate reset`. The actual data/logic lives in // `prisma migrate reset`. The actual data/logic lives in
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`. // `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
const prisma = new PrismaClient(); const prisma = new PrismaClient();
registerAllRecipeSources();
seedReferenceData(prisma) seedReferenceData(prisma)
.then(() => syncRecipeSources(prisma)) .then(() => syncRecipeSources(prisma))

View file

@ -1,24 +1,15 @@
import { errorHandlerService } from "@batch-cooking/error-tools"; import { errorHandlerService } from "@batch-cooking/error-tools";
import { createErrorMiddleware, ExpressServer } from "@batch-cooking/express-tools"; import { ExpressServer, createErrorMiddleware } from "@batch-cooking/express-tools";
import { ErrorCode } from "@batch-cooking/shared"; import { ErrorCode } from "@batch-cooking/shared";
import type { Express, Request, Response } from "express"; import type { Express, Request, Response } from "express";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js";
import { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js";
import { contactRouter } from "./modules/contact/contact.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js"; import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { mobileRouter } from "./modules/mobile/mobile.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js";
import { recipeRouter } from "./modules/recipe/recipe.routes.js"; import { recipeRouter } from "./modules/recipe/recipe.routes.js";
import { referenceRouter } from "./modules/reference/reference.routes.js"; import { referenceRouter } from "./modules/reference/reference.routes.js";
import { shoppingListRouter } from "./modules/shopping-list/shopping-list.routes.js";
import { sourcesRouter } from "./modules/sources/sources.routes.js";
/** /**
* Builds the API's `ExpressServer`: standard middleware, routes, and the * Builds the API's `ExpressServer`: standard middleware, routes, and the
@ -30,17 +21,6 @@ import { sourcesRouter } from "./modules/sources/sources.routes.js";
*/ */
export function createServer(): ExpressServer { export function createServer(): ExpressServer {
const server = new ExpressServer(); const server = new ExpressServer();
// First middleware registered, before even setupCore's own (CORS/JSON
// body parsing/cookies) — it only reads `req`/`res`, so it doesn't need
// to run after them, and mounting it first means it wraps the *whole*
// pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger);
// Allowed origin(s) come from `CORS_ORIGIN` (see its own doc comment in
// config/env.ts for why the packaged Android app's origin must always be
// one of them). The admin surface (`/admin/*`) is served by this same API
// and consumed by `apps/web`'s own `/admin/*` routes — same origin as the
// rest of the app, so no extra CORS entry.
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); server.setupCore({ corsOrigin: env.CORS_ORIGIN });
server.addRoute("get", "/health", (_req: Request, res: Response) => { server.addRoute("get", "/health", (_req: Request, res: Response) => {
@ -48,41 +28,12 @@ export function createServer(): ExpressServer {
}); });
server.mountRouter("/auth", authRouter); server.mountRouter("/auth", authRouter);
// Admin application surface — its own auth (`requireAdmin`, distinct
// cookie/secret), never the end-user session. Mounted at `/admin/api`,
// *not* bare `/admin`: apps/web's own admin pages live at `/admin/*` too
// (`/admin/monitoring`, `/admin/login`...), and this API is served from
// the same origin/process as that frontend (see `serveStaticFrontend`
// below). A bare `/admin` mount put `GET /admin/monitoring` (API) and the
// page route `/admin/monitoring` in direct conflict — Express matches by
// path alone, so a hard navigation/refresh on that page was answered by
// this router's own handler (raw JSON) instead of falling through to the
// SPA's `index.html`. The `/api` segment guarantees no current or future
// admin page path can ever collide with an admin API path again.
server.mountRouter("/admin/api", adminRouter);
server.mountRouter("/house", houseRouter); server.mountRouter("/house", houseRouter);
// In-app "signaler un bug / suggestion" form (issue #80) — session
// required, one POST. Admins read these rows via `/admin/api/contact`,
// never this router.
server.mountRouter("/contact", contactRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers
// rather than nested under one of them since it isn't scoped to a single
// recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
// Public proxy for Forgejo's Releases API (issues #65/#66) — see
// mobile.service.ts's doc comment for why this can't just be called
// directly from the browser.
server.mountRouter("/mobile", mobileRouter);
server.mountRouter("/planning", planningRouter); server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter); server.mountRouter("/profile", profileRouter);
server.mountRouter("/recipes", recipeRouter); server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter); server.mountRouter("/reference", referenceRouter);
server.mountRouter("/shopping-list", shoppingListRouter);
server.mountRouter("/cooking-session", cookingSessionRouter);
server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see // Serves the built frontend (production Docker image only — see
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after // FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after
@ -99,12 +50,10 @@ export function createServer(): ExpressServer {
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" }); res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
}); });
// Two error-handling middlewares in a row (Express runs them in // Final error-handling middleware: every thrown/`next(err)`-ed error in
// registration order, same as regular middleware) — errorLogger logs the // the app ends up here. All the "what status/body does this error map
// error, then hands it on (`next(err)`) to the real one: all the "what // to" logic lives in ErrorHandlerService, from @batch-cooking/error-tools
// status/body does this error map to" logic lives in ErrorHandlerService, // — this stays a thin adapter.
// from @batch-cooking/error-tools — this stays a thin adapter.
server.setErrorHandler(errorLogger);
server.setErrorHandler(createErrorMiddleware(errorHandlerService)); server.setErrorHandler(createErrorMiddleware(errorHandlerService));
return server; return server;

View file

@ -1,38 +1,12 @@
import dotenv from "dotenv"; import "dotenv/config";
import { z } from "zod"; import { z } from "zod";
// Loads `.env.test` instead of `.env` when running the test suite
// (NODE_ENV=test, set by `cross-env` in package.json's `test` script —
// already present in `process.env` by the time this module runs, since
// `cross-env` sets it before invoking node/tsx at all). Keeps
// `resetDatabase()` (test-support/reset-db.ts, which TRUNCATEs almost
// every table before each test) pointed at a dedicated test database,
// never whatever `pnpm dev` actually uses — running the test suite once
// already wiped a real local dev database this way (`.env`/`.env.test`
// sharing one `DATABASE_URL`), see `.env.test.example` for how to set the
// separate test database this now requires.
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
/**
* Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes
* every optional var through as `${FOO:-}`, which resolves to `""` (not an
* absent key) when the stack's `.env` leaves it empty — and a `.env` file
* is `.dockerignore`d, so that passthrough is the container's only source
* of these. Fields that must *fail closed* on a blank value rather than run
* their own format check against `""` (and crash `parse()` at boot) wrap
* their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE`
* folds the same `"" -> undefined` step into its own `.transform`.
*/
const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value);
/** /**
* Schema for every environment variable the API reads. Parsing (below) * Schema for every environment variable the API reads. Parsing (below)
* fails fast at startup if something required is missing/invalid, instead * fails fast at startup if something required is missing/invalid, instead
* of surfacing as a confusing runtime error later. Exported so the parsing * of surfacing as a confusing runtime error later.
* rules (e.g. `blankAsUnset` on the `FORGEJO_*` trio) can be unit-tested
* without the process-wide `process.env` the singleton below parses.
*/ */
export const envSchema = z.object({ const envSchema = z.object({
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */ /** 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"), NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
/** Port the HTTP server listens on. */ /** Port the HTTP server listens on. */
@ -48,42 +22,8 @@ export const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default("7d"), JWT_EXPIRES_IN: z.string().default("7d"),
/** Name of the httpOnly cookie carrying the session JWT. */ /** Name of the httpOnly cookie carrying the session JWT. */
AUTH_COOKIE_NAME: z.string().default("session"), AUTH_COOKIE_NAME: z.string().default("session"),
/** /** Origin allowed by CORS — must match wherever apps/web is served from. */
* Origin(s) allowed by CORS, comma-separated — parsed below into a CORS_ORIGIN: z.string().default("http://localhost:5173"),
* `string[]` for `ExpressServerCoreOptions.corsOrigin`
* (`app.ts`/`express-server.ts`, which passes it straight to the `cors`
* package) and reused as-is for the Socket.IO handshake's own CORS config
* (`server.ts`, which needs its own since the WS upgrade never runs
* through `setupCore`'s middleware).
*
* Must always include `https://localhost`: the packaged Android app's
* WebView is served from exactly that fixed origin (Capacitor's default
* for `androidScheme`/hostname — see `apps/web/capacitor.config.ts`'s own
* doc comment), and every request it makes to this API — `/auth/login`
* and `/auth/signup` included — is genuinely cross-origin, unlike
* apps/web's *browser* build, which is served from the very same origin
* as this API in production (see docker-compose.yml's `app` service) and
* so never needs an entry here at all. Missing this origin doesn't fail
* loudly: the `cors` package still answers a preflight `OPTIONS` with 204
* for an origin it's about to reject (it only *omits*
* `Access-Control-Allow-Origin`), so the request the app's WebView logs
* looks identical to a normal one right up until the browser silently
* drops the real `POST` — surfacing client-side as a generic "unexpected
* error" with no 401/403 anywhere to point at the actual cause. Default
* covers both the Vite dev server and the packaged app out of the box; a
* real deployment's own web origin (e.g. a production domain) never needs
* adding here for the reason above, but the env var still allows
* extending/overriding this list (comma-separated) if that ever changes.
*/
CORS_ORIGIN: z
.string()
.default("http://localhost:5173,https://localhost")
.transform((value) =>
value
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0),
),
/** /**
* Absolute path to the built frontend (`apps/web/dist`), to serve * Absolute path to the built frontend (`apps/web/dist`), to serve
* alongside the API. Optional, no default — only set inside the * alongside the API. Optional, no default — only set inside the
@ -108,75 +48,6 @@ export const envSchema = z.object({
.string() .string()
.optional() .optional()
.transform((value) => (value === undefined || value === "" ? undefined : value === "true")), .transform((value) => (value === undefined || value === "" ? undefined : value === "true")),
/**
* Shared secret `services/tech-step-llm-worker` sends as an
* `X-Internal-Worker-Secret` header on every call to `/internal/tech-steps/*`
* (`requireInternalWorker`, `middlewares/require-internal-worker.ts`).
* Optional with no default in the schema itself (unlike `JWT_SECRET`) so
* an environment that doesn't run the worker at all (e.g. this repo's
* existing test suite) never needs to set it — but `requireInternalWorker`
* itself rejects every request outright when it's unset, so the surface
* fails closed rather than open if a real deployment forgets to set it.
*/
INTERNAL_WORKER_SECRET: z.string().min(32).optional(),
/**
* Base URL of `services/tech-step-intent-service` (the spaCy-based
* microservice `TechStepClassifierService` delegates NER + intent
* classification to, see `lib/recipe-matching/intent-service-client.ts`).
* Has a default (unlike `DATABASE_URL`/secrets below) since it isn't
* secret and dev natively runs it on a fixed local port — Docker Compose
* overrides it to the compose network's service name.
*/
INTENT_SERVICE_BASE_URL: z.string().url().default("http://localhost:8000"),
/**
* Shared secret sent as an `X-Intent-Service-Secret` header on every call
* to `services/tech-step-intent-service`. Unlike `INTERNAL_WORKER_SECRET`
* above, **required, no `.optional()`** — that service is a core
* dependency (recipe save/preview can no longer detect any technique
* without it), not an optional background job; an environment that
* forgets to set this must fail loudly at startup, not silently run with
* every technique detection request failing one at a time.
*/
INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"),
/**
* Secret used to sign/verify the **admin** session JWT (`lib/admin-jwt.ts`)
* — entirely separate from `JWT_SECRET`, so an end-user session token can
* never be replayed against `/admin/*` and vice versa. `.optional()`
* (unlike `JWT_SECRET`): an instance that doesn't run the admin app at
* all never needs it — but `requireAdmin` (`middlewares/require-admin.ts`)
* rejects every request outright when it's unset, so the surface fails
* closed, same posture as `INTERNAL_WORKER_SECRET`.
*/
ADMIN_JWT_SECRET: z
.string()
.min(32, "ADMIN_JWT_SECRET must be at least 32 characters")
.optional(),
/** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */
ADMIN_COOKIE_NAME: z.string().default("admin_session"),
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
ADMIN_INITIAL_EMAIL: z.string().optional(),
ADMIN_INITIAL_PASSWORD: z.string().optional(),
ADMIN_INITIAL_NAME: z.string().optional(),
/**
* Forgejo instance + credentials for "create an issue from a contact
* message" (`/admin/messages`, issue #81). All three `.optional()` and
* checked together by `ForgejoService.isConfigured()` — the feature
* **fails closed** when any is missing: the endpoint answers `503` and
* the admin UI hides the button (same posture as `ADMIN_JWT_SECRET` /
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
* rest of the app never talks to Forgejo.
*/
FORGEJO_BASE_URL: z.preprocess(blankAsUnset, z.string().url().optional()),
/** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */
FORGEJO_TOKEN: z.preprocess(blankAsUnset, z.string().optional()),
/** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */
FORGEJO_REPO: z.preprocess(
blankAsUnset,
z
.string()
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
.optional(),
),
}); });
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

@ -1,22 +1,3 @@
// Imported for its side effect only (loading `.env`/`.env.test` via
// dotenv) — must run *before* `new PrismaClient()` below. The generated
// Prisma Client bakes in its own fallback `.env` path (always
// `apps/api/.env`, the dev one — resolved once at `prisma generate` time)
// and loads it internally the first time a `PrismaClient` is constructed,
// unless `DATABASE_URL` is already set in `process.env` by then — dotenv
// never overrides an already-set variable, so whichever of these two env
// loads runs first "wins" for the rest of the process. Without this
// import, that race depended entirely on which test file some *other*
// module happened to import first, which normally worked out only by
// coincidence (whatever file mocha's `test/**/*.test.ts` glob happens to
// resolve first) — running a single test file in isolation (e.g. `mocha
// test/some-file.test.ts` directly, bypassing that glob) could silently
// resolve `DATABASE_URL` to the real dev database instead of
// `.env.test`'s. `resetDatabase()`'s own `assertRunningAgainstTestDatabase`
// guard (test-support/reset-db.ts) is what actually caught this in
// practice — it throws rather than truncating the wrong database — but
// the fix belongs here, at the source, not just at that one call site.
import "../config/env.js";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
/** /**

View file

@ -1,5 +1,5 @@
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.js"; import { listRecipeSources } from "../lib/recipe-source-registry.js";
/** /**
* Upserts one `Source` row (schema.prisma) per adapter currently in * Upserts one `Source` row (schema.prisma) per adapter currently in
@ -18,73 +18,44 @@ import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.
* out from under it (see `onDelete: SetNull` on `Recipe.source` in * out from under it (see `onDelete: SetNull` on `Recipe.source` in
* schema.prisma, which is what *would* happen on an actual delete). * schema.prisma, which is what *would* happen on an actual delete).
* *
* Safe to call with an empty registry (leaves the `sources` table * Safe to call with an empty registry — currently always the case, since
* untouched) — the case whenever nothing has called `registerRecipeSource` * no concrete adapter exists yet (see recipe-source-adapter.ts) — leaves
* yet, e.g. most test files (see `apps/api/src/sources/index.ts` for where * the `sources` table untouched.
* the app's own concrete adapters — currently just TheMealDB — register
* themselves at startup).
*/ */
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> { export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
try { for (const adapter of listRecipeSources()) {
for (const adapter of listRecipeSources()) { await prisma.source.upsert({
await prisma.source.upsert({ where: { key: adapter.key },
where: { key: adapter.key }, update: { name: adapter.name },
update: { create: { key: adapter.key, name: adapter.name },
name: adapter.name, });
official: adapter.official,
iconUrl: adapter.iconUrl,
},
create: {
key: adapter.key,
name: adapter.name,
official: adapter.official,
iconUrl: adapter.iconUrl,
},
});
}
} catch (err) {
// Rethrown as-is — callers (app startup, `sources.service.ts` via test
// setup) already handle/log failures centrally; this function just
// isn't allowed a bare `await` per the repo's async/try-catch convention.
throw err;
} }
} }
/** /**
* Which of `externalIds` already have a `Recipe` imported from the source * Which of `externalIds` already have a `Recipe` imported from the source
* registered under `sourceKey`, mapped to that `Recipe`'s id — the * registered under `sourceKey` — the DB-touching counterpart to
* DB-touching counterpart to `markAlreadyImported` (recipe-source-adapter.ts), * `markAlreadyImported` (recipe-source-adapter.ts), which stays pure and
* which stays pure and takes a plain `ReadonlySet<string>` (this map's * takes this set as a plain argument rather than querying itself. Returns
* `.keys()`) rather than querying itself. The id (not just membership) is * an empty set (not an error) for a `sourceKey` with no matching `Source`
* what `sources.service.ts`'s browse endpoint needs to link an * row — nothing can have been imported from a source we don't even have a
* already-imported item straight to its real `Recipe`, instead of a * catalog entry for.
* caller having to look it up again. Returns an empty map (not an error)
* for a `sourceKey` with no matching `Source` row — nothing can have been
* imported from a source we don't even have a catalog entry for.
*/ */
export async function findImportedRecipeIds( export async function findImportedExternalIds(
prisma: PrismaClient, prisma: PrismaClient,
sourceKey: string, sourceKey: string,
externalIds: string[], externalIds: string[],
): Promise<Map<string, number>> { ): Promise<Set<string>> {
try { if (externalIds.length === 0) return new Set();
if (externalIds.length === 0) return new Map();
const source = await prisma.source.findUnique({ const source = await prisma.source.findUnique({ where: { key: sourceKey } });
where: { key: sourceKey }, if (!source) return new Set();
});
if (!source) return new Map();
const imported = await prisma.recipe.findMany({ const imported = await prisma.recipe.findMany({
where: { sourceId: source.id, externalId: { in: externalIds } }, where: { sourceId: source.id, externalId: { in: externalIds } },
select: { id: true, externalId: true }, select: { externalId: true },
}); });
return new Map( return new Set(
imported.flatMap((recipe) => imported.flatMap((recipe) => (recipe.externalId !== null ? [recipe.externalId] : [])),
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [], );
),
);
} catch (err) {
throw err; // see syncRecipeSources()'s catch comment above
}
} }

View file

@ -41,262 +41,6 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
{ uid: "bunch", type: "COUNT", toBaseFactor: 1 }, { uid: "bunch", type: "COUNT", toBaseFactor: 1 },
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 }, { uid: "sachet", type: "COUNT", toBaseFactor: 1 },
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 }, { uid: "sprig", type: "COUNT", toBaseFactor: 1 },
// US customary units — added for English-language sources (TheMealDB and
// similar routinely state quantities in cups/oz/lb, not metric), so
// `ingredient-matcher.ts` has something to resolve them to instead of
// always falling back to `unitId: null`. Convertible against their
// metric siblings like any other MASS/VOLUME unit.
{ uid: "cup", type: "VOLUME", toBaseFactor: 236.5882 },
{ uid: "ounce", type: "MASS", toBaseFactor: 28.3495 },
{ uid: "pound", type: "MASS", toBaseFactor: 453.5924 },
];
// Cooking-technique catalog (French recipe-step normalization) — the
// stable `key`s `tech-step-matcher.ts` auto-detects in a free-text
// `Step.description` (a step can mention several, e.g. "faire chauffer une
// poêle puis y faire fondre le beurre" is both `preheat` and `melt` — see
// `Step.techSteps`/`StepTechStep` in schema.prisma). Same "English
// camelCase uid, no French label" authoring as DIETS/UNITS — the label
// lives in apps/web's locales/fr/translation.json under
// `catalog.techSteps.<key>`.
//
// Just a flat list of stable ids here — the actual matching data (per-
// locale synonym lists + example phrasings the classifier trains on) lives
// in `services/tech-step-intent-service/intent_service/training_data.py`'s
// `TECH_STEP_TRAINING_DATA`, not here: it's owned and trained entirely by
// that separate Python service (see its own README), not read by this
// seed script at all, so it doesn't belong alongside the rest of this
// file's DB-seeded reference data. Every entry here must have a matching
// entry there.
export const TECH_STEPS: string[] = [
"cook",
"fry",
"melt",
"deglaze",
"simmer",
"boil",
"roast",
"grill",
"panFry",
"blanch",
"marinate",
"chop",
"peel",
"mince",
"mix",
"whisk",
"foldIn",
"setAside",
"season",
"drain",
"brown",
"rest",
"preheat",
"bake",
"plate",
"coat",
// Lexique de techniques ajouté par la suite — voir
// `services/tech-step-intent-service/intent_service/training_data.py`
// pour les synonymes/phrases d'exemple de chacune.
"baste",
"appertize",
"whiskPale",
"goldenBrown",
"braise",
"truss",
"caramelize",
"score",
"lineMold",
"clarify",
"compote",
"concasse",
"confit",
"julienne",
"brunoise",
"mirepoix",
"paysanne",
"blindBake",
"bainMarie",
"smother",
"decant",
"dilute",
"punchDown",
"disgorge",
"loosen",
"shellEgg",
"scald",
"pod",
"emulsify",
"hollowOut",
"shock",
"setGel",
"glaze",
"thicken",
"filet",
"proof",
"peelBlanch",
"whipUp",
"moisten",
"pasteurize",
"poach",
"reduce",
"rubIn",
"dustWithFlour",
"sweat",
"sift",
"toast",
"zest",
];
// Same authoring convention as `TECH_STEPS` right above (stable English
// camelCase uid, French label in `apps/web`'s `locales/fr/translation.json`
// under `catalog.utensils.<key>`) — but unlike `TECH_STEPS`, the matching
// data (per-locale synonym lists a `PhraseMatcher` matches against) lives
// in `services/tech-step-intent-service/intent_service/utensil_vocabulary.py`'s
// `UTENSIL_VOCABULARY`, not `training_data.py`: no textcat/training
// involved, a utensil mention doesn't need to be classified, only matched.
// Every entry here must have a matching entry there. See
// `StepTechStepUtensil` in schema.prisma for how a mention gets attached to
// a detected technique.
export const UTENSILS: string[] = [
"pan",
"saucepan",
"pot",
"knife",
"whisk",
"bowl",
"bakingSheet",
"mold",
"colander",
"cuttingBoard",
"oven",
"blender",
"mixer",
"spatula",
"ladle",
"grater",
"rollingPin",
"lid",
"tongs",
"peeler",
"sieve",
"foodProcessor",
"steamerBasket",
"skewer",
"pastryBrush",
"ramekin",
"dish",
"wok",
"thermometer",
"mandoline",
];
// Guided-tour highlight targets (issues #42/#44). Each `key` is the suffix
// of a `data-tour-<key>` attribute the frontend puts on the element the
// tour points at; `description` is the hint an admin sees when picking a
// target in the scenario editor. Reference data maintained here, like
// `TECH_STEPS`/`UTENSILS` — adding a real new target needs a frontend
// `data-tour-*` *and* a line here.
export const TOUR_TARGETS: Array<{ key: string; description: string }> = [
{ key: "nav-planning", description: "Lien « Planning » de la barre latérale" },
{ key: "nav-recipes", description: "Lien « Recettes » de la barre latérale" },
{ key: "nav-shopping-list", description: "Lien « Liste de courses » de la barre latérale" },
{ key: "planning-grid", description: "La grille du planning de la semaine" },
{ key: "planning-add-recipe", description: "Bouton « + » d'un créneau du planning" },
{ key: "planning-cook-button", description: "Bouton « Cuisiner » de l'en-tête du planning" },
{ key: "shopping-list-summary", description: "La liste de courses générée pour la semaine" },
{
key: "techstep-description",
description: "Le texte d'une étape de recette, avec les techniques surlignées",
},
{
key: "techstep-technique-picker",
description: "Le sélecteur de technique du popover de correction",
},
];
// The two tours filed in issue #42, as the DB-backed scenarios #44 defines.
// Only *created* if absent (an admin owns name/enabled/steps afterwards —
// see `seedTourScenarios`); `content`/`title` are plain French text, not
// i18n keys (#44). The second tour is deliberately short for now — issue
// #36 (correction popover closes mid-selection) is still open, so v1 only
// covers the gestures that already work (select text, pick a technique).
export const TOUR_SCENARIOS: Array<{
key: string;
name: string;
steps: Array<{ route: string | null; targetKey: string; title: string; content: string }>;
}> = [
{
key: "general",
name: "Tour d'orientation",
steps: [
{
route: "/",
targetKey: "nav-planning",
title: "Votre semaine, ici",
content: "Le planning est votre point de départ : ce que vous mangez, jour par jour.",
},
{
route: null,
targetKey: "nav-recipes",
title: "Vos recettes",
content:
"Retrouvez vos recettes, importez-en depuis d'autres sites, et filtrez selon vos préférences.",
},
{
route: null,
targetKey: "nav-shopping-list",
title: "La liste de courses",
content:
"Générée automatiquement à partir de tout ce que vous avez planifié pour la semaine.",
},
{
route: null,
targetKey: "planning-grid",
title: "Sept jours, cinq repas",
content: "Chaque case est un créneau. Ajoutez-y des recettes, ajustez les portions.",
},
{
route: null,
targetKey: "planning-add-recipe",
title: "Ajouter une recette",
content: "Ce bouton ouvre le sélecteur de recettes pour remplir un créneau.",
},
{
route: null,
targetKey: "planning-cook-button",
title: "Passer en cuisine",
content:
"Une fois la semaine planifiée, ce bouton réorganise tout en une séance de cuisine optimisée.",
},
{
route: "/liste-de-courses",
targetKey: "shopping-list-summary",
title: "Tout ce qu'il vous faut",
content: "Les ingrédients de toutes vos recettes de la semaine, regroupés et additionnés.",
},
],
},
{
key: "techStepMetadata",
name: "Corriger les techniques d'une recette",
steps: [
{
route: null,
targetKey: "techstep-description",
title: "Les techniques repérées",
content:
"Les mots surlignés sont des techniques de cuisson détectées automatiquement. Sélectionnez un passage pour le corriger.",
},
{
route: null,
targetKey: "techstep-technique-picker",
title: "Choisir la bonne technique",
content:
"Indiquez la technique correcte pour le passage sélectionné — la correction est immédiate.",
},
],
},
]; ];
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
@ -558,13 +302,6 @@ export const INGREDIENT_GROUPS: Array<{
{ uid: "vealCutlet", allergenUids: [] }, { uid: "vealCutlet", allergenUids: [] },
{ uid: "porkTenderloin", allergenUids: [] }, { uid: "porkTenderloin", allergenUids: [] },
{ uid: "porkChop", allergenUids: [] }, { uid: "porkChop", allergenUids: [] },
// Ground/minced meats, requested alongside `groundBeef` (already
// seeded) — one per red-meat type the catalog otherwise only offers
// as a whole cut. Ground poultry (turkey/chicken) lives in the
// `poultry` subcategory below, next to their whole-cut counterparts.
{ uid: "groundVeal", allergenUids: [] },
{ uid: "groundPork", allergenUids: [] },
{ uid: "groundLamb", allergenUids: [] },
{ uid: "lamb", allergenUids: [] }, { uid: "lamb", allergenUids: [] },
{ uid: "legOfLamb", allergenUids: [] }, { uid: "legOfLamb", allergenUids: [] },
{ uid: "baconLardons", allergenUids: [] }, { uid: "baconLardons", allergenUids: [] },
@ -615,9 +352,7 @@ export const INGREDIENT_GROUPS: Array<{
defaultIcon: "POULTRY", defaultIcon: "POULTRY",
items: [ items: [
{ uid: "chicken", allergenUids: [] }, { uid: "chicken", allergenUids: [] },
{ uid: "groundChicken", allergenUids: [] },
{ uid: "turkey", allergenUids: [] }, { uid: "turkey", allergenUids: [] },
{ uid: "groundTurkey", allergenUids: [] },
{ uid: "duck", allergenUids: [] }, { uid: "duck", allergenUids: [] },
{ uid: "duckBreast", allergenUids: [] }, { uid: "duckBreast", allergenUids: [] },
// Ciqual 2025 additions — see the VIANDES group above. // Ciqual 2025 additions — see the VIANDES group above.
@ -953,11 +688,7 @@ export const INGREDIENT_GROUPS: Array<{
subcategory: "eggs", subcategory: "eggs",
defaultDiets: ["vegetarian", "pescatarian"], defaultDiets: ["vegetarian", "pescatarian"],
defaultIcon: "EGG", defaultIcon: "EGG",
items: [ items: [{ uid: "egg", allergenUids: ["eggs"] }],
{ uid: "egg", allergenUids: ["eggs"] },
{ uid: "eggYolk", allergenUids: ["eggs"] },
{ uid: "eggWhite", allergenUids: ["eggs"] },
],
}, },
{ {
category: "dairyAndCheese", category: "dairyAndCheese",
@ -1017,7 +748,6 @@ export const INGREDIENT_GROUPS: Array<{
{ uid: "fiveSpice", allergenUids: [] }, { uid: "fiveSpice", allergenUids: [] },
{ uid: "garamMasala", allergenUids: [] }, { uid: "garamMasala", allergenUids: [] },
{ uid: "corianderSeeds", allergenUids: [] }, { uid: "corianderSeeds", allergenUids: [] },
{ uid: "groundCoriander", allergenUids: [] },
{ uid: "cardamom", allergenUids: [] }, { uid: "cardamom", allergenUids: [] },
{ uid: "fenugreek", allergenUids: [] }, { uid: "fenugreek", allergenUids: [] },
{ uid: "jalapeno", allergenUids: [] }, { uid: "jalapeno", allergenUids: [] },
@ -1395,19 +1125,6 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
}); });
} }
// TechStep: upsert by key (same idempotent-seed reasoning as everything
// above) — just the stable id/key rows themselves now, no matching data
// to replace alongside them (see `TECH_STEPS`' own comment for why).
for (const key of TECH_STEPS) {
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
}
// Utensil: same idempotent bare id/key upsert as TechStep right above —
// no matching data alongside it either (see `UTENSILS`' own comment).
for (const key of UTENSILS) {
await prisma.utensil.upsert({ where: { key }, update: {}, create: { key } });
}
// `Allergy` itself carries no `key` — it's the selectable instance of a // `Allergy` itself carries no `key` — it's the selectable instance of a
// keyed `Category` (see schema.prisma) — so seeding an allergen means one // keyed `Category` (see schema.prisma) — so seeding an allergen means one
// Category (upserted by key) plus exactly one Allergy row under it, // Category (upserted by key) plus exactly one Allergy row under it,
@ -1524,47 +1241,4 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
if (dietLinks.length > 0) { if (dietLinks.length > 0) {
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true }); await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
} }
await seedTourScenarios(prisma);
}
/**
* Seeds the guided-tour reference data (issues #42/#44):
*
* - `TourTarget` rows — upserted by key with `update: { description }`, same
* as `Unit` above: a reseed must correct an edited hint, not just skip.
* - `TourScenario` + its steps — **create-if-absent only**. Once a scenario
* exists, an admin owns its `name`/`enabled`/steps (that's the whole
* point of #44's CRUD), so a later deploy's reseed must never clobber
* those. In the test suite every `resetDatabase()` truncates first, so
* the defaults are always recreated fresh there.
*/
async function seedTourScenarios(prisma: PrismaClient): Promise<void> {
for (const { key, description } of TOUR_TARGETS) {
await prisma.tourTarget.upsert({
where: { key },
update: { description },
create: { key, description },
});
}
for (const { key, name, steps } of TOUR_SCENARIOS) {
const existing = await prisma.tourScenario.findUnique({ where: { key } });
if (existing) continue;
await prisma.tourScenario.create({
data: {
key,
name,
steps: {
create: steps.map((step, index) => ({
order: index,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
},
},
});
}
} }

View file

@ -1,59 +0,0 @@
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
/**
* Decoded contents of an **admin** session JWT, once verified. Deliberately
* a separate token type from `lib/jwt.ts`'s `AuthTokenPayload`: the admin
* app authenticates against its own `AdminUser` table with its own secret
* (`ADMIN_JWT_SECRET`), so an end-user session token and an admin session
* token are never interchangeable.
*/
export interface AdminTokenPayload {
/** `AdminUser.id` this token authenticates. */
adminUserId: number;
/** Snapshot of `AdminUser.tokenVersion` at sign time — re-checked against the DB on every request (see `requireAdmin`) to allow server-side invalidation. */
tokenVersion: number;
}
/**
* The admin JWT secret, or a thrown error if the instance never configured
* one. A misconfigured admin deployment surfaces as a loud 500 on login
* rather than a silently-unsigned token; a deployment that doesn't run the
* admin app at all never reaches here (nothing calls sign/verify), and
* `requireAdmin` independently fails closed on the same unset value.
*/
function adminSecret(): string {
if (env.ADMIN_JWT_SECRET === undefined) {
throw new Error("ADMIN_JWT_SECRET is not configured — cannot issue or verify admin sessions");
}
return env.ADMIN_JWT_SECRET;
}
/** Signs a new admin session JWT, expiring per `JWT_EXPIRES_IN` (shared with the end-user token — same "how long a session lasts" policy). */
export function signAdminToken(payload: AdminTokenPayload): string {
return jwt.sign(
{ sub: String(payload.adminUserId), tokenVersion: payload.tokenVersion },
adminSecret(),
{ expiresIn: env.JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] },
);
}
/**
* Verifies an admin session JWT's signature/expiry and decodes it back
* into an {@link AdminTokenPayload}.
*
* @throws {Error} if the token is invalid/expired (from `jwt.verify`) or
* structurally malformed (missing/wrong-typed claims).
*/
export function verifyAdminToken(token: string): AdminTokenPayload {
const decoded = jwt.verify(token, adminSecret());
const adminUserId = typeof decoded === "object" ? Number(decoded.sub) : Number.NaN;
if (
typeof decoded !== "object" ||
Number.isNaN(adminUserId) ||
typeof decoded.tokenVersion !== "number"
) {
throw new Error("Malformed admin token payload");
}
return { adminUserId, tokenVersion: decoded.tokenVersion };
}

View file

@ -1,65 +0,0 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import { logger } from "./logger.service.js";
/** Who caused an {@link AnalyticsEvent}. `"user"` pairs with an `actorId` (`UserProfile.id`); `"system"` is a background job; `"anon"` is an unauthenticated request. */
export type AnalyticsActorType = "user" | "system" | "anon";
/** Optional context for {@link AnalyticsService.recordEvent}. */
export interface RecordEventOptions {
/** `UserProfile.id` — set together with `actorType: "user"` (the default when this is present). */
actorId?: number;
/** Overrides the inferred actor type (`"user"` when `actorId` is set, else `"anon"`). */
actorType?: AnalyticsActorType;
/** Small free-form blob for later slicing (`{ sourceKey, recipeId, … }`) — nothing queries into it today. */
context?: Prisma.InputJsonValue;
}
/**
* Records product usage events for the admin dashboard's metrics (see the
* `AnalyticsEvent` model doc comment). A class rather than a bare function
* — same convention as `LoggerService`/`ErrorHandlerService`: `public`
* `recordEvent` is the API, `_insert` is the internal it fans out to.
*
* **Fire-and-forget by contract**: `recordEvent` returns `void`, not a
* promise. The insert runs detached, and a failure is logged at `warn` and
* swallowed — analytics must never add latency to, or fail, the request
* that triggered it. Call sites therefore never `await` it.
*/
export class AnalyticsService {
public recordEvent(type: string, options: RecordEventOptions = {}): void {
const actorType: AnalyticsActorType =
options.actorType ?? (options.actorId !== undefined ? "user" : "anon");
void this._insert(type, actorType, options).catch((err: unknown) => {
logger.warn("Analytics event insert failed", {
eventType: type,
error: err instanceof Error ? err.message : String(err),
});
});
}
private async _insert(
type: string,
actorType: AnalyticsActorType,
options: RecordEventOptions,
): Promise<void> {
try {
await prisma.analyticsEvent.create({
data: {
type,
actorType,
actorId: options.actorId ?? null,
context: options.context,
},
});
} catch (err) {
// Rethrown so `recordEvent`'s `.catch` above logs it — this layer
// just isn't allowed a bare `await` per the repo's convention.
throw err;
}
}
}
/** Single shared instance — stateless, same reasoning as `logger`. */
export const analytics = new AnalyticsService();

View file

@ -1,102 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode } from "@batch-cooking/shared";
import { env } from "../../config/env.js";
import { logger } from "../logger.service.js";
/** The slice of Forgejo's `POST .../issues` response this integration reads. */
interface ForgejoIssueResponse {
number: number;
html_url: string;
}
/**
* The one place `apps/api` talks to Forgejo — creating an issue from a
* contact message (`/admin/messages`, issue #81). A class + singleton
* (`forgejo` below), same convention as `logger` / `analytics`.
*
* **Fail-closed**: every method is a no-op / typed error unless all three
* `FORGEJO_*` env vars are set (see `config/env.ts`). Callers check
* {@link isConfigured} first and answer `503` themselves; {@link createIssue}
* re-checks as a backstop.
*/
export class ForgejoService {
/** True once `FORGEJO_BASE_URL`, `FORGEJO_TOKEN` and `FORGEJO_REPO` are all set. */
public isConfigured(): boolean {
return (
env.FORGEJO_BASE_URL !== undefined &&
env.FORGEJO_TOKEN !== undefined &&
env.FORGEJO_REPO !== undefined
);
}
/**
* Creates an issue in `FORGEJO_REPO` with the given title/body (no labels
* — a deliberate #81 decision, the admin adds them in Forgejo). Returns
* the new issue's number and html URL.
*
* @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` if the env isn't set,
* `502 FORGEJO_REQUEST_FAILED` on a network error or any non-2xx / non-JSON
* response — the caller leaves the database untouched in that case.
*/
public async createIssue(input: {
title: string;
body: string;
}): Promise<{ number: number; htmlUrl: string }> {
if (!this.isConfigured()) {
throw new HttpError(
503,
ErrorCode.FORGEJO_NOT_CONFIGURED,
"Forgejo integration is not configured",
);
}
const url = `${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`;
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Forgejo/Gitea's PAT scheme — `token <pat>`, not `Bearer`.
Authorization: `token ${env.FORGEJO_TOKEN}`,
},
body: JSON.stringify({ title: input.title, body: input.body }),
});
} catch (err) {
logger.warn("Forgejo issue creation failed (network)", {
error: err instanceof Error ? err.message : String(err),
});
throw new HttpError(502, ErrorCode.FORGEJO_REQUEST_FAILED, "Forgejo request failed");
}
if (!response.ok) {
const detail = await response.text().catch(() => "");
logger.warn("Forgejo issue creation failed", {
status: response.status,
detail: detail.slice(0, 500),
});
throw new HttpError(
502,
ErrorCode.FORGEJO_REQUEST_FAILED,
`Forgejo issues API responded ${response.status}`,
);
}
try {
const data = (await response.json()) as ForgejoIssueResponse;
return { number: data.number, htmlUrl: data.html_url };
} catch (err) {
logger.warn("Forgejo issue creation failed (bad response)", {
error: err instanceof Error ? err.message : String(err),
});
throw new HttpError(
502,
ErrorCode.FORGEJO_REQUEST_FAILED,
"Forgejo response was not valid JSON",
);
}
}
}
/** Single shared instance — stateless, same reasoning as `logger` / `analytics`. */
export const forgejo = new ForgejoService();

View file

@ -1,102 +0,0 @@
import { env } from "../config/env.js";
/**
* Server-side operational logging — the one place in this codebase allowed
* to call `console.*` directly (see `biome.json`'s `noConsole`, which bans
* bare `console.log` everywhere else: every log line here goes through a
* named level instead, `logger.info(...)`/`logger.error(...)`, never an
* unlabeled dump of text). Everything else (the request logger, `server.ts`'s
* startup line, the error middleware) goes through this instead of touching
* `console` itself, so there's exactly one place that decides the log
* *shape* (structured JSON lines — one object per line, trivially
* grep/parse-able by `docker logs`/Portainer or any log aggregator, unlike
* free-form `console.log` text).
*
* A class, not a plain object literal — same convention as
* `ErrorHandlerService` (`packages/error-tools`): `public` methods are the
* actual API, `private` ones are internals a caller never touches directly.
* `export const logger = new LoggerService()` below is the single shared
* instance every caller imports — there's exactly one log stream for the
* whole process, nothing to parameterize per call site, so nobody
* constructs their own.
*/
/** Ascending severity — mirrors the standard `debug < info < warn < error` convention. */
export type LogLevel = "debug" | "info" | "warn" | "error";
/** Arbitrary structured context attached to a log line (a request id, a duration, an error's own fields, …) — merged into the emitted JSON object, never interpolated into the message string itself. */
export type LogMeta = Record<string, unknown>;
const LEVEL_SEVERITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
/**
* Lines below this level are dropped rather than emitted — keeps `debug`
* out of production (verbose, meant for local troubleshooting only) and
* out of the test suite's own output (Mocha's reporter is noisy enough
* already), while `pnpm dev:api` sees everything. A free function, not a
* method — exported (unlike the rest of this module's internals) purely so
* `logger.service.test.ts` can exercise the three `NODE_ENV` cases
* directly, without needing a `LoggerService` instance at all.
*/
export function minLevelFor(nodeEnv: typeof env.NODE_ENV): LogLevel {
if (nodeEnv === "production") return "info";
if (nodeEnv === "test") return "warn";
return "debug";
}
/** Console method each level writes through — `error`/`warn` go to stderr (their own native behavior), `debug`/`info` to stdout, the usual split log aggregators expect. */
const CONSOLE_METHOD: Record<LogLevel, "debug" | "info" | "warn" | "error"> = {
debug: "debug",
info: "info",
warn: "warn",
error: "error",
};
/** Server-side operational logger — see the module doc comment for why this exists and what it's for. */
export class LoggerService {
/** Computed once at construction from `env.NODE_ENV` — see {@link minLevelFor}. */
private readonly _minSeverity: number;
public constructor(nodeEnv: typeof env.NODE_ENV = env.NODE_ENV) {
this._minSeverity = LEVEL_SEVERITY[minLevelFor(nodeEnv)];
}
public debug(message: string, meta?: LogMeta): void {
this._emit("debug", message, meta);
}
public info(message: string, meta?: LogMeta): void {
this._emit("info", message, meta);
}
public warn(message: string, meta?: LogMeta): void {
this._emit("warn", message, meta);
}
public error(message: string, meta?: LogMeta): void {
this._emit("error", message, meta);
}
private _emit(level: LogLevel, message: string, meta?: LogMeta): void {
if (LEVEL_SEVERITY[level] < this._minSeverity) return;
// `meta` spread first so a caller accidentally passing e.g. `{ message:
// ... }` in it can never shadow the line's own core fields.
const line = {
...meta,
timestamp: new Date().toISOString(),
level,
message,
};
// biome-ignore lint/suspicious/noConsole: this is the one place allowed to — see the class doc comment above.
console[CONSOLE_METHOD[level]](JSON.stringify(line));
}
}
/** Single shared instance — this service has no per-call-site state to isolate, same reasoning as `errorHandlerService`. */
export const logger = new LoggerService();

View file

@ -1,95 +0,0 @@
import type { MonitoringView } from "@batch-cooking/shared";
import type { Server as SocketIoServer } from "socket.io";
import { requireAdminSocket } from "../middlewares/require-admin-socket.js";
import { getMonitoring } from "../modules/admin/admin-monitoring.service.js";
import { logger } from "./logger.service.js";
/** How often the server re-probes every dependency, in ms — see `MonitoringBroadcastService`. */
const PROBE_INTERVAL_MS = 60_000;
/** Every authenticated admin socket joins this one room — a single probe loop serves every open tab, instead of each polling independently. */
const MONITORING_ROOM = "monitoring";
/** Event name every `monitoring:update` push carries — the sole message this service ever emits. */
const UPDATE_EVENT = "monitoring:update";
/**
* Pushes the admin monitoring status board to every connected admin over
* WebSocket, replacing the old `GET /admin/api/monitoring` polling
* endpoint entirely (removed — see `admin.routes.ts`'s doc comment): one
* server-side probe loop, shared by every viewer, instead of each browser
* tab re-probing Postgres/the API/the intent-service/the LLM worker on its
* own timer.
*
* Owns the *whole* `socket.io` server passed to it (`io.use`, top-level
* `"connection"` handler) — fine while this is the server's only
* real-time feature; a second one added later should likely get its own
* namespace rather than share this room's connection handler.
*/
export class MonitoringBroadcastService {
private readonly _io: SocketIoServer;
/**
* Most recent probe result — replayed to a newly-joined admin
* immediately (see the `"connection"` handler below), so a freshly
* opened tab isn't left blank for up to a minute waiting for the next
* scheduled tick. `null` until the very first probe (kicked off by
* {@link start}) completes.
*/
private _lastSnapshot: MonitoringView | null = null;
/** Handle for the running probe loop, or `null` before {@link start}/after {@link stop}. */
private _intervalHandle: NodeJS.Timeout | null = null;
public constructor(io: SocketIoServer) {
this._io = io;
this._io.use(requireAdminSocket);
this._io.on("connection", (socket) => {
void socket.join(MONITORING_ROOM);
if (this._lastSnapshot) {
socket.emit(UPDATE_EVENT, this._lastSnapshot);
}
});
}
/**
* Starts the probe loop: one immediate probe (so the very first admin
* to connect after a server restart doesn't wait a full minute), then
* every {@link PROBE_INTERVAL_MS}. Call once, at server boot
* (`server.ts`) — meant to run for the lifetime of the process, same
* posture as the tech-step worker's own cron (`server.ts` never calls
* {@link stop} today; the process exiting takes the interval with it).
*/
public start(): void {
void this._tick();
this._intervalHandle = setInterval(() => void this._tick(), PROBE_INTERVAL_MS);
}
/**
* Stops the probe loop. Nothing in production calls this today — it
* exists so a graceful-shutdown hook could use it later, and so tests
* can tear an instance down cleanly instead of leaking a live
* `setInterval` past their own lifetime (which would otherwise hold the
* whole Mocha process open).
*/
public stop(): void {
if (this._intervalHandle) {
clearInterval(this._intervalHandle);
this._intervalHandle = null;
}
}
/** Probes every dependency, caches the result, and broadcasts it to {@link MONITORING_ROOM}. */
private async _tick(): Promise<void> {
try {
this._lastSnapshot = await getMonitoring();
this._io.to(MONITORING_ROOM).emit(UPDATE_EVENT, this._lastSnapshot);
} catch (err) {
// getMonitoring() itself never rejects — every individual probe is
// already bounded/caught (see its own doc comment) — so this is
// purely defensive, per the repo's await-in-try/catch rule; not
// expected to actually fire.
logger.error("Monitoring probe tick failed unexpectedly", {
error: err instanceof Error ? err.message : String(err),
});
}
}
}

View file

@ -1,734 +0,0 @@
import type {
CookingBackgroundTaskView,
CookingPhaseKind,
CookingPhaseNoteView,
CookingPhaseView,
CookingSessionRecipeRef,
CookingTaskIngredientView,
CookingTaskView,
CookingTemperatureView,
TechStepView,
UtensilView,
} from "@batch-cooking/shared";
/**
* The pure core of the "Calcul batch-cooking" module (`specs/batch-cooking-architecture.md`):
* takes the week's planned recipes — already resolved to reference views by
* `cooking-session.service.ts` — and reorganizes their steps into an ordered
* sequence of {@link CookingPhaseView}s that pools shared preparation and
* interleaves the recipes so passive cooks (simmer, braise, bake…) run in
* the background while the cook does active work from another recipe.
*
* Pure and synchronous, no database access — same `matchXxx()` pure /
* `loadXxx()` DB-backed split as `ingredient-matcher.ts` /
* `tech-step-matcher.ts` / `shopping-list.service.ts`'s
* `aggregateShoppingList`, so the whole optimization is unit-testable
* without a Postgres round-trip.
*
* Two families of work are *merged* across recipes:
* - **Preparation** — a `chop`/`peel`/… technique applied to the same
* ingredient by two or more recipes, in a step that does nothing but
* prep, collapses into one {@link CookingTaskView} of `kind:
* "merged-prep"`.
* - **Setup** — the same `preheat`/`boil`/`bainMarie` action shared across
* recipes collapses into one `kind: "merged-setup"` task, but *only when
* the action metadata is compatible*: two recipes that both preheat the
* oven merge only when they preheat it to the **same temperature**.
* Two preheats at different temperatures stay separate (you cannot heat
* one oven to two temperatures at once), are ordered coolest-first, and
* the mise-en-place phase carries a {@link CookingPhaseNoteView} telling
* the cook to run them in sequence.
*
* Cooking steps themselves are still never merged (no shared-oven bake
* batching yet); they're only *reordered* for parallelism.
*/
/**
* Technique keys (`TechStep.key`, see `reference-seed-data.ts`'s
* `TECH_STEPS`) that are pure knife/prep work on an ingredient — the only
* techniques v1 pools across recipes. A *step* counts as prep only when
* **every** technique it mentions is in here (see {@link isPurePrepStep}):
* "émincer les oignons" merges, "faire revenir les oignons émincés" does
* not (its `panFry` keeps it a cooking step).
*/
const PREP_TECHNIQUES: ReadonlySet<string> = new Set([
"chop",
"peel",
"mince",
"julienne",
"brunoise",
"concasse",
"paysanne",
"mirepoix",
"zest",
"score",
"pod",
"shellEgg",
"hollowOut",
"filet",
"disgorge",
"sift",
"dustWithFlour",
"peelBlanch",
]);
/**
* How much of the cook's attention a technique needs once it's under way —
* the axis that makes parallelism possible.
*
* - `"SETUP"` — a short active trigger, then it looks after itself: preheat
* the oven, bring a pot of water to the boil. Pooled into the first
* ("mise en place") phase so it's running before it's needed, and merged
* across recipes when compatible (see {@link extractMergedSetup}).
* - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate,
* rest…). Scheduled, then floated into every following phase's
* `background` until the step that consumes it comes up.
* - anything not listed here, or a step with no detected technique at all,
* is treated as `"ACTIVE"` — hands-on, occupies the cook.
*/
const SETUP_TECHNIQUES: ReadonlySet<string> = new Set(["preheat", "boil", "bainMarie"]);
/** See {@link SETUP_TECHNIQUES}. */
const PASSIVE_TECHNIQUES: ReadonlySet<string> = new Set([
"simmer",
"bake",
"roast",
"braise",
"marinate",
"rest",
"proof",
"confit",
"reduce",
"blindBake",
"compote",
"smother",
"setGel",
"pasteurize",
"appertize",
"poach",
"sweat",
"glaze",
]);
/** Attention class of a single step — see {@link SETUP_TECHNIQUES}. */
type Attention = "SETUP" | "PASSIVE" | "ACTIVE";
/** One technique occurrence within a step, already scaled to the planned portions. */
interface OptimizerTechStepInput {
techStep: TechStepView;
order: number;
ingredients: CookingTaskIngredientView[];
utensils: UtensilView[];
/**
* Target temperature(s) named in this same clause (`preheat` → "à 180 °C").
* The structured subset only — see {@link CookingTemperatureView}. Not
* scaled (a temperature doesn't grow with the portion count). `[]` when
* the clause named no temperature.
*/
temperatures: CookingTemperatureView[];
}
/** One recipe step, as handed to {@link optimizeCookingPlan}. */
interface OptimizerStepInput {
stepId: number;
order: number;
description: string;
techSteps: OptimizerTechStepInput[];
}
/**
* One planned recipe, as handed to {@link optimizeCookingPlan}. `portions`
* is the planning slot's own count and `recipePortions` the recipe's
* as-written yield — quantities are scaled by `portions / recipePortions`
* (see {@link scaleOf}). The same recipe planned twice at different portion
* counts arrives as two entries with the same `recipeId`; that's
* intentional (two real cooking jobs), and merged-prep still pools their
* knife work back together.
*/
interface OptimizerRecipeInput {
recipeId: number;
name: string;
portions: number;
recipePortions: number;
steps: OptimizerStepInput[];
}
/** {@link optimizeCookingPlan}'s result — the date-range/legend wrapper is added by the service. */
interface OptimizeCookingPlanResult {
recipes: CookingSessionRecipeRef[];
phases: CookingPhaseView[];
}
export type {
OptimizeCookingPlanResult,
OptimizerRecipeInput,
OptimizerStepInput,
OptimizerTechStepInput,
};
/** Portion scale factor for a recipe — guards a missing/zero as-written yield (bad data) by falling back to 1× rather than dividing by zero. */
function scaleOf(recipe: OptimizerRecipeInput): number {
if (!recipe.recipePortions || recipe.recipePortions <= 0) return 1;
return recipe.portions / recipe.recipePortions;
}
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils/temperatures unioned across its technique clauses. */
interface NormalizedStep {
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
taskId: string;
recipeIndex: number;
recipe: CookingSessionRecipeRef;
stepId: number;
order: number;
description: string;
techSteps: OptimizerTechStepInput[];
attention: Attention;
isPurePrep: boolean;
/** Every technique in the step is a {@link SETUP_TECHNIQUES} one — eligible to pool via {@link extractMergedSetup}. */
isPureSetup: boolean;
dominantTechnique: TechStepView | null;
ingredients: CookingTaskIngredientView[];
utensils: UtensilView[];
/** Distinct target temperatures across the step's clauses (see {@link unionTemperatures}) — drives setup pooling and the coolest-first ordering. */
temperatures: CookingTemperatureView[];
/** Set once merged-prep/merged-setup extraction absorbs this step wholesale — it then produces no standalone task. */
absorbed: boolean;
}
/** Sums two ingredient lines only when it's unambiguous — same unit id and both quantities known; otherwise the pooled line carries no number (see `ShoppingListItemView`'s "don't guess a conversion" rule). */
function poolIngredient(lines: CookingTaskIngredientView[]): {
quantity: number | null;
unit: CookingTaskIngredientView["unit"];
} {
const first = lines[0];
if (!first) return { quantity: null, unit: null };
const unitId = first.unit?.id ?? null;
let total = 0;
for (const line of lines) {
if (line.quantity === null || (line.unit?.id ?? null) !== unitId) {
return { quantity: null, unit: null };
}
total += line.quantity;
}
return { quantity: total, unit: first.unit };
}
/** Unions ingredient lines by `(ingredientId, unitId)`, summing quantities within a group the same careful way as {@link poolIngredient}. */
function unionIngredients(lines: CookingTaskIngredientView[]): CookingTaskIngredientView[] {
const groups = new Map<string, CookingTaskIngredientView[]>();
for (const line of lines) {
const key = `${line.ingredient.id}:${line.unit?.id ?? "x"}`;
const group = groups.get(key);
if (group) group.push(line);
else groups.set(key, [line]);
}
const out: CookingTaskIngredientView[] = [];
for (const group of groups.values()) {
const head = group[0];
if (!head) continue;
const pooled = poolIngredient(group);
out.push({ ingredient: head.ingredient, quantity: pooled.quantity, unit: pooled.unit });
}
return out.sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
}
/** Unions utensils by id, keeping a stable order by key. */
function unionUtensils(utensils: UtensilView[]): UtensilView[] {
const byId = new Map<number, UtensilView>();
for (const utensil of utensils) byId.set(utensil.id, utensil);
return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
}
/**
* A stable canonical string for one temperature — `c<°C>` / `g<gasMark>` /
* `q<level>`, or `""` when every field is null (shouldn't happen — the
* upstream extractor guarantees one non-null — but stays total). Two
* temperatures are "the same" iff their keys are equal: **no conversion**
* between °C, gas marks and qualitative levels is guessed for a merge
* decision (same "don't invent a conversion" stance as `ShoppingListItemView`
* on units). °C is rounded to the nearest degree so `180` and `180.00`
* (a `Decimal` artefact) collapse.
*/
function temperatureKey(temperature: CookingTemperatureView): string {
if (temperature.celsius !== null) return `c${Math.round(temperature.celsius)}`;
if (temperature.gasMark !== null) return `g${temperature.gasMark}`;
if (temperature.qualitative !== null) return `q${temperature.qualitative}`;
return "";
}
/** Unions temperatures by {@link temperatureKey}, keeping a stable order by that key. */
function unionTemperatures(temperatures: CookingTemperatureView[]): CookingTemperatureView[] {
const byKey = new Map<string, CookingTemperatureView>();
for (const temperature of temperatures) byKey.set(temperatureKey(temperature), temperature);
return [...byKey.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
}
/**
* A single number to *order* temperatures by (coolest first) — **never** used
* to decide a merge (that's {@link temperatureKey}, exact-match only). Gas
* marks map via the French `th × 30 ≈ °C` convention and qualitative levels
* via rough midpoints, purely so a mixed list still sorts sensibly; a
* temperature-less task sorts first.
*/
function comparableTemp(temperature: CookingTemperatureView): number {
if (temperature.celsius !== null) return temperature.celsius;
if (temperature.gasMark !== null) return temperature.gasMark * 30;
if (temperature.qualitative !== null) {
return { low: 120, medium: 180, high: 240 }[temperature.qualitative];
}
return Number.NEGATIVE_INFINITY;
}
/** A setup task's sort position (coolest first) — its lowest target, or "before everything" when it has no detected temperature. */
function setupTaskTemp(task: CookingTaskView): number {
if (task.temperatures.length === 0) return Number.NEGATIVE_INFINITY;
return Math.min(...task.temperatures.map(comparableTemp));
}
/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */
function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
}
/** A step is pure setup only if it has techniques and every one is in {@link SETUP_TECHNIQUES} — mirror of {@link isPurePrepStep}. */
function isPureSetupStep(techSteps: OptimizerTechStepInput[]): boolean {
return techSteps.length > 0 && techSteps.every((ts) => SETUP_TECHNIQUES.has(ts.techStep.key));
}
/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
const last = techSteps[techSteps.length - 1];
if (last && PASSIVE_TECHNIQUES.has(last.techStep.key)) return "PASSIVE";
return "ACTIVE";
}
/** Turns one recipe's raw steps into {@link NormalizedStep}s — scales quantities, resolves attention, unions per-clause ingredients/utensils up to the step. */
function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): NormalizedStep[] {
const scale = scaleOf(recipe);
const recipeRef: CookingSessionRecipeRef = {
recipeId: recipe.recipeId,
name: recipe.name,
portions: recipe.portions,
};
return [...recipe.steps]
.sort((a, b) => a.order - b.order)
.map((step) => {
const techSteps: OptimizerTechStepInput[] = [...step.techSteps]
.sort((a, b) => a.order - b.order)
.map((ts) => ({
techStep: ts.techStep,
order: ts.order,
ingredients: ts.ingredients.map((line) => ({
ingredient: line.ingredient,
quantity: line.quantity === null ? null : line.quantity * scale,
unit: line.unit,
})),
utensils: ts.utensils,
// Temperatures are not scaled — an oven set to 180 °C for 4
// portions is still 180 °C for 8.
temperatures: ts.temperatures,
}));
const lastTech = techSteps[techSteps.length - 1];
return {
taskId: `step:${recipeIndex}:${step.stepId}`,
recipeIndex,
recipe: recipeRef,
stepId: step.stepId,
order: step.order,
description: step.description,
techSteps,
attention: attentionOf(techSteps),
isPurePrep: isPurePrepStep(techSteps),
isPureSetup: isPureSetupStep(techSteps),
dominantTechnique: lastTech ? lastTech.techStep : null,
ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
temperatures: unionTemperatures(techSteps.flatMap((ts) => ts.temperatures)),
absorbed: false,
} satisfies NormalizedStep;
});
}
/** The prep signature of a pure-prep step — sorted `<techniqueKey>:<ingredientId>` pairs; two steps with the same signature do identical knife work and can be pooled. */
function prepSignature(step: NormalizedStep): string {
const pairs: string[] = [];
for (const ts of step.techSteps) {
for (const line of ts.ingredients) {
pairs.push(`${ts.techStep.key}:${line.ingredient.id}`);
}
}
return [...new Set(pairs)].sort().join("+");
}
/**
* The setup signature of a pure-setup step — `<sorted technique keys>|<sorted
* temperature keys>`. Two steps with the same signature start the *same*
* appliance in the *same* state and can be pooled. The temperature part is
* `""` when no temperature was detected, which is a valid signature: two
* bare "préchauffer le four" steps then share it and merge (the optimistic
* default — an unknown temperature is assumed compatible). The appliance
* itself is deliberately not in the key (a `preheat` is the oven either
* way); its utensils are still unioned onto the pooled task.
*/
function setupSignature(step: NormalizedStep): string {
const techKeys = [...new Set(step.techSteps.map((ts) => ts.techStep.key))].sort().join("+");
const tempKeys = step.temperatures
.map(temperatureKey)
.filter((key) => key !== "")
.sort()
.join("+");
return `${techKeys}|${tempKeys}`;
}
/** Builds one {@link CookingTaskView} from a normalized step run as written. */
function stepToTask(step: NormalizedStep): CookingTaskView {
return {
id: step.taskId,
kind: "step",
technique: step.dominantTechnique,
description: step.description,
ingredients: step.ingredients,
utensils: step.utensils,
// Only a setup step (preheat/boil/…) carries a target temperature the
// plan should reason about; every other step leaves it `[]` even if its
// text happens to mention degrees.
temperatures: step.attention === "SETUP" ? step.temperatures : [],
sourceRecipes: [step.recipe],
originalSteps: [
{
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
},
],
};
}
/** Builds the running-in-the-background status line for a passive step already scheduled in an earlier phase. */
function stepToBackground(step: NormalizedStep): CookingBackgroundTaskView {
return {
id: `bg:${step.taskId}`,
technique: step.dominantTechnique,
description: step.description,
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
};
}
/**
* Pools pure-prep steps that do the *exact same* knife work (same
* {@link prepSignature}) in two or more distinct recipes into one
* `merged-prep` {@link CookingTaskView}, and marks every contributing step
* `absorbed` so it produces no standalone task. A pure-prep step whose
* signature is unique (only one recipe needs it) is left untouched — it
* still lands in the mise-en-place phase, just as its own step task.
*
* Returns the merged tasks in a stable order (by id).
*/
function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
const bySignature = new Map<string, NormalizedStep[]>();
for (const step of steps) {
if (!step.isPurePrep) continue;
const signature = prepSignature(step);
if (signature === "") continue;
const group = bySignature.get(signature);
if (group) group.push(step);
else bySignature.set(signature, [step]);
}
const merged: CookingTaskView[] = [];
for (const [signature, group] of bySignature) {
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
if (recipeIndexes.size < 2) continue;
for (const step of group) step.absorbed = true;
// Every contributing clause's ingredient lines, pooled per ingredient.
const allLines = group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients));
const ingredients = unionIngredients(allLines);
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
// Dominant technique of the pool = the first pair's technique (v1
// signatures are almost always a single `<technique>:<ingredient>`
// pair; a multi-pair signature just takes the earliest).
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
const firstIngredientKey = ingredients[0]?.ingredient.key ?? signature;
// Distinct source recipes / original step texts, in input order.
const sourceRecipes: CookingSessionRecipeRef[] = [];
const seenRecipe = new Set<number>();
const originalSteps: CookingTaskView["originalSteps"] = [];
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
if (!seenRecipe.has(step.recipeIndex)) {
seenRecipe.add(step.recipeIndex);
sourceRecipes.push(step.recipe);
}
originalSteps.push({
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
});
}
merged.push({
id: `prep:${firstTech ? firstTech.key : "prep"}:${firstIngredientKey}`,
kind: "merged-prep",
technique: firstTech,
description: null,
ingredients,
utensils,
temperatures: [],
sourceRecipes,
originalSteps,
});
}
return merged.sort((a, b) => a.id.localeCompare(b.id));
}
/**
* Pools pure-setup steps with the same {@link setupSignature} (same action,
* same detected temperature) across two or more distinct recipes into one
* `merged-setup` {@link CookingTaskView}, marking each contributing step
* `absorbed`. Two `preheat` steps at *different* temperatures have different
* signatures and are therefore never pooled — {@link detectApplianceTemperatureConflicts}
* turns that into a phase note. A signature only one recipe needs is left
* untouched (still lands in mise-en-place as its own step task).
*
* Returns the merged tasks sorted by id.
*/
function extractMergedSetup(steps: NormalizedStep[]): CookingTaskView[] {
const bySignature = new Map<string, NormalizedStep[]>();
for (const step of steps) {
if (!step.isPureSetup) continue;
const signature = setupSignature(step);
const group = bySignature.get(signature);
if (group) group.push(step);
else bySignature.set(signature, [step]);
}
const merged: CookingTaskView[] = [];
for (const group of bySignature.values()) {
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
if (recipeIndexes.size < 2) continue;
for (const step of group) step.absorbed = true;
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
const ingredients = unionIngredients(
group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients)),
);
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
const temperatures = unionTemperatures(group.flatMap((s) => s.temperatures)).sort(
(a, b) => comparableTemp(a) - comparableTemp(b),
);
// Distinct source recipes / original step texts, in input order.
const sourceRecipes: CookingSessionRecipeRef[] = [];
const seenRecipe = new Set<number>();
const originalSteps: CookingTaskView["originalSteps"] = [];
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
if (!seenRecipe.has(step.recipeIndex)) {
seenRecipe.add(step.recipeIndex);
sourceRecipes.push(step.recipe);
}
originalSteps.push({
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
});
}
const tempKey = temperatures.map(temperatureKey).join("+");
merged.push({
id: `setup:${firstTech ? firstTech.key : "setup"}:${tempKey || "any"}`,
kind: "merged-setup",
technique: firstTech,
description: null,
ingredients,
utensils,
temperatures,
sourceRecipes,
originalSteps,
});
}
return merged.sort((a, b) => a.id.localeCompare(b.id));
}
/**
* The kitchen has one oven: if the mise-en-place ends up with two or more
* `preheat` tasks at *distinct known* temperatures, they cannot run at once.
* Emits a single {@link CookingPhaseNoteView} listing those temperatures
* coolest-first so the web layer can say "enchaîner 180 °C puis 210 °C".
* A `preheat` with no detected temperature contributes nothing to the note
* (it merged optimistically with the other unknowns and we've no number to
* show).
*/
function detectApplianceTemperatureConflicts(
setupTasks: CookingTaskView[],
): CookingPhaseNoteView[] {
const preheats = setupTasks.filter((task) => task.technique?.key === "preheat");
const distinct = unionTemperatures(preheats.flatMap((task) => task.temperatures))
.filter((temperature) => temperatureKey(temperature) !== "")
.sort((a, b) => comparableTemp(a) - comparableTemp(b));
const technique = preheats.find((task) => task.technique !== null)?.technique;
if (distinct.length < 2 || !technique) return [];
return [{ kind: "appliance-temperature-conflict", technique, temperatures: distinct }];
}
/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
const seen = new Set<string>();
const out: CookingSessionRecipeRef[] = [];
for (const recipe of recipes) {
const key = `${recipe.recipeId}:${recipe.portions}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ recipeId: recipe.recipeId, name: recipe.name, portions: recipe.portions });
}
return out;
}
/** A phase is `"finishing"` when everything left in it is plating; otherwise it's a normal `"cooking"` phase. */
function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind {
return tasks.every((task) => task.technique?.key === "plate") ? "finishing" : "cooking";
}
/**
* See the file header. Given the week's planned recipes (already resolved
* to reference views), returns the display legend plus the ordered phases:
*
* 1. **Mise en place** (`"mise-en-place"`) — all prep first (`merged-prep`
* tasks, then leftover pure-prep steps), then all setup (`merged-setup`
* tasks and leftover `SETUP` steps, ordered coolest-first). Carries an
* `appliance-temperature-conflict` note when the week needs the oven at
* two or more temperatures. Omitted entirely if it would be empty.
* 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved:
* each phase pops the next remaining step of every recipe that still has
* one (passive-cook steps first, so long cooks start early). A passive
* step scheduled in one phase is echoed in every later phase's
* `background` until that recipe's next step is popped.
*/
export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCookingPlanResult {
const legend = toRecipeLegend(recipes);
const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
const allSteps = normalized.flat();
// Merged-prep first, then merged-setup — each marks its contributing
// steps `absorbed`, so the leftover loop below only sees the solo ones.
const mergedPrep = extractMergedPrep(allSteps);
const mergedSetup = extractMergedSetup(allSteps);
const phases: CookingPhaseView[] = [];
// Phase 0 — mise en place: all prep, then all setup. Keeping the two
// groups apart (rather than input order) lets the setup group be ordered
// coolest-first without disturbing the prep order.
const prepTasks: CookingTaskView[] = [...mergedPrep];
const setupTasks: CookingTaskView[] = [...mergedSetup];
for (const step of allSteps) {
if (step.absorbed) continue;
if (step.isPurePrep) {
prepTasks.push(stepToTask(step));
step.absorbed = true; // consumed here, not again in the cooking loop
} else if (step.attention === "SETUP") {
setupTasks.push(stepToTask(step));
step.absorbed = true;
}
}
// Coolest oven first, so a temperature conflict reads "180 °C puis 210 °C";
// `id` as a stable tiebreaker keeps the plan deterministic.
setupTasks.sort((a, b) => setupTaskTemp(a) - setupTaskTemp(b) || a.id.localeCompare(b.id));
const miseTasks = [...prepTasks, ...setupTasks];
if (miseTasks.length > 0) {
phases.push({
index: 0,
kind: "mise-en-place",
tasks: miseTasks,
background: [],
notes: detectApplianceTemperatureConflicts(setupTasks),
});
}
// Cooking phases — one "next step of each recipe" per phase. `hold` keeps
// a recipe out of the *next* phase right after it starts a passive cook,
// so another recipe's active work fills that phase and the passive cook
// shows up as `background` there instead of being immediately followed by
// its own next step.
const queues = normalized.map((steps) => ({
remaining: steps.filter((s) => !s.absorbed),
cursor: 0,
hold: 0,
}));
/** Passive steps started in an earlier phase, keyed by recipe index, still "cooking". */
const runningPassive = new Map<number, NormalizedStep>();
while (queues.some((queue) => queue.cursor < queue.remaining.length)) {
const phaseSteps: NormalizedStep[] = [];
queues.forEach((queue, recipeIndex) => {
const next = queue.remaining[queue.cursor];
if (!next) return;
if (queue.hold > 0) {
// Still tending its passive cook this phase — leave it in
// `runningPassive` so it renders as background, don't advance.
queue.hold--;
return;
}
// This recipe is advancing — whatever passive cook it had going is
// now being tended to, so it stops showing as background.
runningPassive.delete(recipeIndex);
phaseSteps.push(next);
queue.cursor++;
});
// Every recipe with steps left is holding on a passive cook — break the
// stall by releasing all holds and letting the next iteration advance.
if (phaseSteps.length === 0) {
for (const queue of queues) queue.hold = 0;
continue;
}
// Background = passive cooks from earlier phases not yet resolved above.
const background = [...runningPassive.values()].map(stepToBackground);
// Start the long cooks first within the phase.
phaseSteps.sort((a, b) => {
const rank = (s: NormalizedStep) => (s.attention === "PASSIVE" ? 0 : 1);
return rank(a) - rank(b) || a.recipeIndex - b.recipeIndex;
});
const tasks = phaseSteps.map(stepToTask);
phases.push({
index: phases.length,
kind: cookingPhaseKind(tasks),
tasks,
background,
// Temperature conflicts only matter in mise-en-place, where all the
// preheats live; a cooking phase never carries a note today.
notes: [],
});
for (const step of phaseSteps) {
if (step.attention === "PASSIVE") {
runningPassive.set(step.recipeIndex, step);
const queue = queues[step.recipeIndex];
if (queue) queue.hold = 1;
}
}
}
// `index` was set from `phases.length` as we went; re-stamp so it always
// matches the final array position even if phase 0 was skipped.
phases.forEach((phase, index) => {
phase.index = index;
});
return { recipes: legend, phases };
}

View file

@ -1,471 +0,0 @@
import {
INGREDIENT_LABEL_SYNONYMS_EN,
INGREDIENT_LABEL_SYNONYMS_FR,
INGREDIENT_LABELS_EN,
INGREDIENT_LABELS_FR,
UNIT_LABELS_EN,
UNIT_LABELS_FR,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { normalizeText } from "./tech-step-matcher.js";
/**
* Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter`
* lifts from a recipe source (`ParsedRecipeIngredient`) against our own
* `Ingredient`/`Unit` reference catalogs — the ingredient-side counterpart
* to `tech-step-matcher.ts`'s technique detection, built for the same
* reason: a source's raw text has no idea our catalogs even exist.
*
* Unlike tech steps (regex mappings hand-authored per technique),
* ingredient/unit labels are plain hand-written text, one table per
* supported `locale` (`INGREDIENT_LABELS_EN`/`INGREDIENT_LABELS_FR`/
* `UNIT_LABELS_EN`/`UNIT_LABELS_FR`, `packages/shared`) — matching them
* against arbitrary free text (extra adjectives, plurals, "large diced
* yellow onion" for a catalog entry that's just "Onion") needs its own,
* lighter algorithm: word-tokenize both sides, naively stem for plurals,
* then look for the catalog phrase's tokens as a contiguous run inside the
* ingredient text's tokens. The longest (most specific) matching catalog
* phrase wins, same "specificity resolves overlaps" idea as
* `matchTechSteps`, just without weights (there's no need to rank two
* *unrelated* ingredients — only a phrase against its own substrings, e.g.
* "chicken breast" beating bare "chicken").
*
* `matchIngredientName`/`matchUnit`/`extractQuantity` are pure, so they're
* unit-testable without a database (see `test/ingredient-matcher.test.ts`);
* `loadIngredientCatalog`/`loadUnitCatalog` are the only DB-touching pieces,
* meant to be fetched once per request and reused across every ingredient
* line, the same "don't requery per item" convention
* `tech-step-matcher.ts`'s `TechStepClassifierService` follows for its own
* one-time training pass.
*/
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its matching label in whichever locale it was loaded for. */
export interface IngredientMatchEntry {
ingredientId: number;
/** Matching label — English (`INGREDIENT_LABELS_EN`) or French (`INGREDIENT_LABELS_FR`) depending on which locale {@link loadIngredientCatalog} was called with, e.g. `"Chicken breast"`/`"Blanc de poulet"` — matched against free text, never displayed. */
label: string;
}
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted spellings in whichever locale it was loaded for. */
export interface UnitMatchEntry {
unitId: number;
/** Accepted spellings — English (`UNIT_LABELS_EN`) or French (`UNIT_LABELS_FR`), e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]` or `["cuillère à soupe", "cas", ...]`. Unlike English, a French entry can be genuinely multi-word — see `matchUnit`'s own doc comment. */
synonyms: string[];
}
/**
* Naive English stemmer — strips a plural suffix so "tomato"/"tomatoes",
* "onion"/"onions", "cherry"/"cherries" compare equal after stemming.
* Deliberately not a real linguistic stemmer: it only has to be
* *consistent* (the same word always stems the same way) since both sides
* of every comparison go through it, not linguistically correct on its
* own — see the module doc comment.
*/
function stemWordEn(word: string): string {
if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
if (word.endsWith("es") && word.length > 3) return word.slice(0, -2);
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
return word;
}
/**
* Naive French stemmer — French regular plurals are overwhelmingly just
* "+s" on the singular (`"carotte"`/`"carottes"`, `"pomme"`/`"pommes"`),
* unlike English's several suffix patterns, so this only strips a single
* trailing "s". Deliberately *not* {@link stemWordEn}'s `"es"` rule reused
* here: applying it to French would silently corrupt any word whose
* singular itself ends in "e" plus a consonant before the final "s" — e.g.
* `"carottes"` would wrongly stem to `"carott"` (dropping the "e" that's
* actually part of the singular `"carotte"`) instead of `"carotte"`,
* exactly the class of near-miss that made ingredient matching
* French-locale silently broken before this stemmer existed (almost every
* regular French plural ends in "es" this way — it's not an edge case).
* Irregular plurals (`"cheval"`/`"chevaux"`, `"chou"`/`"choux"`) aren't
* handled — same "consistent, not linguistically perfect" trade-off as
* {@link stemWordEn}.
*/
function stemWordFr(word: string): string {
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
return word;
}
/** Dispatches to {@link stemWordEn}/{@link stemWordFr} by `locale` — any locale other than `"fr"` uses the English rules (the long-standing default, unchanged for every existing caller that doesn't pass a locale at all). */
function stemWord(word: string, locale: string): string {
return locale === "fr" ? stemWordFr(word) : stemWordEn(word);
}
/** Lowercases, strips diacritics (via `normalizeText`) and splits `text` into stemmed word tokens — empty/non-letter runs are dropped, not kept as empty tokens. `locale` picks the stemming rules (see {@link stemWord}); defaults to `"en"`, the original behavior every pre-existing caller still gets without passing one. */
function tokenize(text: string, locale = "en"): string[] {
return normalizeText(text)
.split(/[^a-z]+/)
.filter((word) => word.length > 0)
.map((word) => stemWord(word, locale));
}
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
function containsSubsequence(haystack: string[], needle: string[]): boolean {
if (needle.length === 0 || needle.length > haystack.length) return false;
for (let start = 0; start <= haystack.length - needle.length; start++) {
if (needle.every((word, i) => haystack[start + i] === word)) return true;
}
return false;
}
/** One stemmed word from {@link tokenizeWithOffsets}, alongside its `[start, end)` span in the *original* (un-normalized) text it came from. */
interface OffsetToken {
word: string;
start: number;
end: number;
}
/** Matches a run of letters (any script, diacritics included) — the same "word" unit {@link tokenize} splits `normalizeText`'d text on (`/[^a-z]+/`), applied here directly to the *original* text instead so each token keeps its real character offsets. Digits/punctuation are never part of a run, same separator role they play for `tokenize` (a leading quantity is `extractQuantity`'s job, not this module's word-tokenizer's). */
const LETTER_RUN_PATTERN = /\p{L}+/gu;
/**
* {@link tokenize}'s positional twin: same stemmed/normalized words, but
* each one keeps the `[start, end)` span it occupies in `text` — needed by
* {@link findIngredientMentions} to report *where* a mention is, not just
* that the catalog has a matching label somewhere. Splitting the original
* text into letter-runs first (rather than normalizing the whole string up
* front, the way `tokenize` does, then losing track of offsets) works
* safely here because `normalizeText` only ever rewrites a character's own
* form (case/diacritics) — see `_DiacriticsNormalizer`'s doc comment on the
* Python side, ported from the same guarantee — never merges or splits
* words, so normalizing one already-isolated run in place can't shift its
* boundaries relative to the un-normalized text.
*/
function tokenizeWithOffsets(text: string, locale = "en"): OffsetToken[] {
const tokens: OffsetToken[] = [];
for (const match of text.matchAll(LETTER_RUN_PATTERN)) {
const raw = match[0];
const start = match.index ?? 0;
const word = stemWord(normalizeText(raw), locale);
if (word.length === 0) continue;
tokens.push({ word, start, end: start + raw.length });
}
return tokens;
}
/**
* Matches a quantity (integer/decimal/fraction/mixed number, same shapes as
* {@link extractQuantity}) immediately followed by an optional unit
* word/phrase (up to three words, e.g. "cuillères à soupe") and an optional
* connector ("de"/"d'"/"of"/"a"/"an"), anchored at the *end* of whatever
* string it's tested against (`$`) rather than the start. Anchoring at the
* end — not the start — is what lets {@link findQuantityBeforeIngredient}
* test the *whole* text preceding a mention without first having to guess
* where an unrelated preamble ("ajouter", "puis", an earlier sentence) ends
* and the quantity phrase begins: whatever doesn't fit the pattern
* immediately before the ingredient simply isn't part of the match, no
* separate boundary-finding step needed.
*/
const QUANTITY_BEFORE_INGREDIENT_PATTERN =
/(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:[.,]\d+)?)\s*((?:\p{L}+\s+){0,2}\p{L}*)\s*(?:de\s|d['’]|of\s|a\s|an\s)?$/u;
/**
* Best-effort quantity+unit lookup for an ingredient mention {@link findIngredientMentions}
* just found at `mentionStart` in `text` — looks *only* at what immediately
* precedes the mention (see {@link QUANTITY_BEFORE_INGREDIENT_PATTERN}), the
* dominant French/English recipe phrasing ("200g de beurre", "2 cuillères à
* soupe d'huile", "3 œufs"). Both `null` when nothing recognizable precedes
* it (no leading digit at all) — same "no match, not an error" posture as
* {@link extractQuantity}. Doesn't detect a quantity that *follows* its
* ingredient ("du beurre, 50g") — an accepted gap, same trade-off
* {@link extractQuantity} already documents for the leading-only case it
* was built for.
*/
function findQuantityBeforeIngredient(
text: string,
mentionStart: number,
unitCatalog: UnitMatchEntry[],
locale: string,
): { quantity: number | null; unitId: number | null } {
const match = QUANTITY_BEFORE_INGREDIENT_PATTERN.exec(text.slice(0, mentionStart));
if (!match) return { quantity: null, unitId: null };
const { quantity } = extractQuantity(match[1] ?? "");
const unitId = matchUnit(match[2] ?? "", unitCatalog, locale);
return { quantity, unitId };
}
/** One ingredient mention {@link findIngredientMentions} found in a free-text clause, alongside its `[start, end)` span (same convention as `TechStepMatch`, `tech-step-matcher.ts`) and any quantity+unit resolved immediately before it (see {@link findQuantityBeforeIngredient}) — both `null` when the clause names the ingredient with no quantity ("ajouter le sel"). */
export interface IngredientMention {
ingredientId: number;
start: number;
end: number;
quantity: number | null;
unitId: number | null;
}
/**
* Scans `text` (typically one technique's clause, see `tech-step-matcher.ts`'s
* `splitIntoClauses`) for every mention of a catalog ingredient, left to
* right, non-overlapping — the free-text-*scanning* counterpart to
* {@link matchIngredientName} (which resolves one *already-isolated*
* ingredient-line string to a single winner, not several mentions spread
* across a longer text). Same "longest catalog label wins" rule as
* {@link matchIngredientName}, applied at every token position in turn: once
* a mention is found, scanning resumes right after it rather than
* considering a shorter label starting inside an already-matched longer one.
*
* `locale` must match whatever `ingredientCatalog`/`unitCatalog` were loaded
* in (see {@link loadIngredientCatalog}/{@link loadUnitCatalog}) — defaults
* to `"en"`, same as every other function in this module.
*/
export function findIngredientMentions(
text: string,
ingredientCatalog: IngredientMatchEntry[],
unitCatalog: UnitMatchEntry[],
locale = "en",
): IngredientMention[] {
const tokens = tokenizeWithOffsets(text, locale);
if (tokens.length === 0) return [];
const candidates = ingredientCatalog
.map((entry) => ({
ingredientId: entry.ingredientId,
labelTokens: tokenize(entry.label, locale),
}))
.filter((entry) => entry.labelTokens.length > 0);
const mentions: IngredientMention[] = [];
let i = 0;
while (i < tokens.length) {
let best: { ingredientId: number; tokenCount: number } | null = null;
for (const candidate of candidates) {
const { labelTokens } = candidate;
if (i + labelTokens.length > tokens.length) continue;
const matches = labelTokens.every((word, offset) => tokens[i + offset]?.word === word);
if (!matches) continue;
if (
best === null ||
labelTokens.length > best.tokenCount ||
(labelTokens.length === best.tokenCount && candidate.ingredientId < best.ingredientId)
) {
best = { ingredientId: candidate.ingredientId, tokenCount: labelTokens.length };
}
}
if (best === null) {
i += 1;
continue;
}
const startToken = tokens[i];
const endToken = tokens[i + best.tokenCount - 1];
if (startToken === undefined || endToken === undefined) {
// Unreachable — `best` was only ever set above after confirming
// `i + labelTokens.length <= tokens.length`, so both tokens exist.
// Satisfies `noUncheckedIndexedAccess`.
i += 1;
continue;
}
const { quantity, unitId } = findQuantityBeforeIngredient(
text,
startToken.start,
unitCatalog,
locale,
);
mentions.push({
ingredientId: best.ingredientId,
start: startToken.start,
end: endToken.end,
quantity,
unitId,
});
i += best.tokenCount;
}
return mentions;
}
/**
* Resolves free-text `name` (a `ParsedRecipeIngredient.name`, e.g. `"large
* diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or
* `null` if nothing matches. Among every catalog entry whose label's words
* all appear as a contiguous run in `name`, **in the same order**, the one
* with the most words wins (most specific — "chicken breast" over bare
* "chicken"); ties break on the lowest `ingredientId`, for a deterministic
* result independent of catalog order. `locale` must match whatever
* `catalog`'s labels were loaded in (see {@link loadIngredientCatalog}) —
* defaults to `"en"`.
*/
export function matchIngredientName(
name: string,
catalog: IngredientMatchEntry[],
locale = "en",
): number | null {
const nameTokens = tokenize(name, locale);
if (nameTokens.length === 0) return null;
let best: { ingredientId: number; tokenCount: number } | null = null;
for (const entry of catalog) {
const labelTokens = tokenize(entry.label, locale);
if (!containsSubsequence(nameTokens, labelTokens)) continue;
if (
best === null ||
labelTokens.length > best.tokenCount ||
(labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId)
) {
best = {
ingredientId: entry.ingredientId,
tokenCount: labelTokens.length,
};
}
}
return best?.ingredientId ?? null;
}
/**
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`, `"cuillères à
* soupe de farine"`) to the best-matching `Unit` in `catalog`, or `null` if
* nothing matches. Same ordered-contiguous-run search as
* {@link matchIngredientName}, longest match wins — **not** the
* single-first-word equality check this function used before French
* support existed: every English unit synonym happens to be one word, so
* comparing only `unitText`'s first token against each *whole* synonym
* string used to be enough, but a French unit can be genuinely multi-word
* (`"cuillère à soupe"`, see `UNIT_LABELS_FR`) — a whole multi-word phrase
* (spaces and all) can never equal a single extracted token, so that
* approach would have silently matched nothing for any French unit
* requiring more than one word. `locale` must match whatever `catalog`'s
* synonyms were loaded in (see {@link loadUnitCatalog}) — defaults to
* `"en"`.
*/
export function matchUnit(
unitText: string,
catalog: UnitMatchEntry[],
locale = "en",
): number | null {
const textTokens = tokenize(unitText, locale);
if (textTokens.length === 0) return null;
let best: { unitId: number; tokenCount: number } | null = null;
for (const entry of catalog) {
for (const synonym of entry.synonyms) {
const synonymTokens = tokenize(synonym, locale);
if (!containsSubsequence(textTokens, synonymTokens)) continue;
if (
best === null ||
synonymTokens.length > best.tokenCount ||
(synonymTokens.length === best.tokenCount && entry.unitId < best.unitId)
) {
best = { unitId: entry.unitId, tokenCount: synonymTokens.length };
}
}
}
return best?.unitId ?? null;
}
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
export interface ExtractedQuantity {
quantity: number | null;
/** `rawText` with the leading quantity (and any separating whitespace) removed — `rawText` unchanged if none was found. */
remainder: string;
}
// Leading "1 1/2", "1/2", "1.5", "1,5" or "2" (optionally followed by a
// hyphenated range like "2-3", in which case only the first number counts —
// good enough for a best-effort quantity, not meant to model ranges. The
// `[.,]` decimal separator already covers French recipe text ("1,5") as-is,
// same pattern used for English ("1.5") — no locale-specific handling
// needed here, unlike tokenize/stemWord above.
const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/;
/**
* Pulls a leading quantity off `rawText` (e.g. `"1 1/2 cups flour"` ->
* `{ quantity: 1.5, remainder: "cups flour" }`), supporting a plain
* integer/decimal, a simple fraction (`"1/2"`), or a mixed number
* (`"1 1/2"`). `quantity: null` (remainder === rawText, trimmed) when
* `rawText` doesn't start with a recognizable number — e.g. `"salt to
* taste"`, which has none.
*/
export function extractQuantity(rawText: string): ExtractedQuantity {
const trimmed = rawText.trim();
const match = LEADING_QUANTITY_PATTERN.exec(trimmed);
if (!match) return { quantity: null, remainder: trimmed };
let quantity: number;
if (match[1] !== undefined) {
// Mixed number: "1 1/2".
quantity = Number(match[1]) + Number(match[2]) / Number(match[3]);
} else if (match[4] !== undefined) {
// Simple fraction: "1/2".
quantity = Number(match[4]) / Number(match[5]);
} else {
// Plain integer/decimal: "2" or "1.5"/"1,5" — group 6 is guaranteed
// defined here (the only remaining alternative in the pattern), `?? ""`
// is just satisfying the indexed-access type, not a real fallback.
quantity = Number((match[6] ?? "").replace(",", "."));
}
return { quantity, remainder: trimmed.slice(match[0].length).trim() };
}
/**
* Per-locale matching label tables {@link loadIngredientCatalog}/
* {@link loadUnitCatalog} pick from — the only two locales with any
* matching data authored yet (see `packages/shared/src/data/`). A locale
* with no entry here (anything but `"en"`/`"fr"`) falls back to empty
* tables in both loaders below — the same "no matching-language data,
* degrade to doing nothing rather than guess" behavior `tech-step-matcher.ts`
* already has for a locale with no trained mappings, not a thrown error.
*/
const INGREDIENT_LABELS_BY_LOCALE: Record<string, Record<string, string>> = {
en: INGREDIENT_LABELS_EN,
fr: INGREDIENT_LABELS_FR,
};
const INGREDIENT_LABEL_SYNONYMS_BY_LOCALE: Record<string, Record<string, string[]>> = {
en: INGREDIENT_LABEL_SYNONYMS_EN,
fr: INGREDIENT_LABEL_SYNONYMS_FR,
};
const UNIT_LABELS_BY_LOCALE: Record<string, Record<string, string[]>> = {
en: UNIT_LABELS_EN,
fr: UNIT_LABELS_FR,
};
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s for `locale` (default `"en"`) — one entry per key with an authored label in that locale, plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`/`_FR`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no label in `locale` yet is silently skipped, never a matching target — same for every ingredient when `locale` itself has no label table at all (see {@link INGREDIENT_LABELS_BY_LOCALE}). Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMatchEntry[]> {
try {
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
const ingredients = await prisma.ingredient.findMany({
// Placeholder rows (user free-text, `placeholder:<uuid>` key) have no
// authored label so they'd be skipped by the `label === undefined`
// check below anyway — filtered here too so an import never even
// considers resolving one raw line to another line's placeholder.
where: { isPlaceholder: false },
select: { id: true, key: true },
});
const catalog: IngredientMatchEntry[] = [];
for (const ingredient of ingredients) {
const label = labels[ingredient.key];
if (label === undefined) continue;
catalog.push({ ingredientId: ingredient.id, label });
for (const synonym of synonyms[ingredient.key] ?? []) {
catalog.push({ ingredientId: ingredient.id, label: synonym });
}
}
return catalog;
} catch (err) {
// Rethrown as-is — the caller (`sources.service.ts`/`recipe-translation.ts`)
// already handles/logs failures centrally; this function just isn't
// allowed a bare `await` per the repo's async/try-catch convention.
throw err;
}
}
/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s for `locale` (default `"en"`) — one entry per key with authored synonyms in that locale (see {@link UNIT_LABELS_BY_LOCALE}); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */
export async function loadUnitCatalog(locale = "en"): Promise<UnitMatchEntry[]> {
try {
const labels = UNIT_LABELS_BY_LOCALE[locale] ?? {};
const units = await prisma.unit.findMany({
select: { id: true, key: true },
});
const catalog: UnitMatchEntry[] = [];
for (const unit of units) {
const synonyms = labels[unit.key];
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
}
return catalog;
} catch (err) {
throw err; // see loadIngredientCatalog()'s catch comment above
}
}

View file

@ -1,135 +0,0 @@
import { env } from "../../config/env.js";
/**
* Thin fetch wrapper around `services/tech-step-intent-service`'s HTTP
* contract (`POST /v1/process`) — the microservice
* {@link TechStepClassifierService} (`tech-step-matcher.ts`) delegates NER +
* intent classification to, in place of the `node-nlp` `NlpManager` it used
* to own directly. See that service's own README for the full contract and
* why it never touches Postgres itself — it also owns its own training
* corpus now (`training_data.py`), trained once at its own startup, so
* `apps/api` never pushes anything to it; `process()` below is this
* client's only method.
*
* Authenticated with `INTENT_SERVICE_SECRET` — the inverse direction of
* `requireInternalWorker`'s `INTERNAL_WORKER_SECRET` (this time `apps/api`
* is the caller, not the callee), but the same "one flat shared secret"
* shape.
*/
/** One candidate mention one of the service's two `PhraseMatcher`s found — offsets `[start, end)`, same convention as `String.prototype.slice`. Mirrors `EntityPayload` (Python `schemas.py`). `kind` distinguishes a technique mention (`self._matcher`, the corpus-trained one) from a utensil mention (`self._utensil_matcher`, static — see `utensil_vocabulary.py`) — `tech-step-matcher.ts` resolves each against a different catalog (`TechStep`/`Utensil`). */
export interface IntentServiceEntity {
uid: string;
start: number;
end: number;
kind: "technique" | "utensil";
}
/**
* A temperature the service recognized in the text — mirrors
* `TemperaturePayload` (Python `schemas.py`), `gas_mark` camelCased to
* `gasMark`. A separate list from {@link IntentServiceEntity} (not another
* `kind`) because it carries structured fields that don't fit an entity's
* `uid`. At least one of `celsius` / `gasMark` / `qualitative` is non-null.
*/
export interface IntentServiceTemperature {
start: number;
end: number;
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
raw: string;
}
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). `temperatures` is regex-extracted and independent of the model/training (empty when none, not omitted). */
export interface IntentServiceProcessResult {
entities: IntentServiceEntity[];
intent: string | null;
score: number;
temperatures: IntentServiceTemperature[];
}
/**
* Client for `services/tech-step-intent-service` — a real class (not a
* plain object of functions) per this repo's service-style-logic
* convention, even though it holds no state of its own: it's used as the
* one shared {@link intentServiceClient} singleton below, same reasoning as
* `TechStepClassifierService` itself.
*/
export class IntentServiceClient {
/**
* Performs a JSON request against the intent service and returns the
* parsed body.
*
* @throws {Error} if the response status is not in the 2xx range, or the
* request itself fails (network error, service down) — left as a plain
* `Error` rather than a typed `HttpError`: this is an internal
* service-to-service call, not a request `apps/api`'s own HTTP layer
* needs to map to a client-facing status code (see
* `TechStepClassifierService.warmUp`'s retry in `server.ts` for how a
* failure here is actually handled).
*/
private async _request<TResponseBody>(
path: string,
init: RequestInit = {},
): Promise<TResponseBody> {
try {
const response = await fetch(`${env.INTENT_SERVICE_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
"X-Intent-Service-Secret": env.INTENT_SERVICE_SECRET,
...init.headers,
},
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`${init.method ?? "GET"} ${path} failed: ${response.status} ${body}`);
}
return (await response.json()) as TResponseBody;
} catch (err) {
// Rethrown as-is — every caller (`TechStepClassifierService`) already
// wraps its own `await`s per the repo's try/catch convention; this is
// just where the `await` itself has to sit inside one.
throw err;
}
}
/**
* Equivalent to the old `NlpManager.process(locale, text)` — returns every
* candidate technique mention (NER) plus the intent classifier's verdict
* for `text` as a whole, whether `text` is a full step description or a
* single clause `TechStepClassifierService` already cut out of one (this
* service doesn't know or care which, exactly like `NlpManager` before
* it).
*/
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
try {
// The wire shape uses `gas_mark` (Python field name); everything else
// is already camelCase / single-word. Map that one key at the
// boundary so the rest of `apps/api` only ever sees `gasMark`.
const wire = await this._request<
Omit<IntentServiceProcessResult, "temperatures"> & {
temperatures: (Omit<IntentServiceTemperature, "gasMark"> & { gas_mark: number | null })[];
}
>("/v1/process", {
method: "POST",
body: JSON.stringify({ locale, text }),
});
return {
entities: wire.entities,
intent: wire.intent,
score: wire.score,
temperatures: (wire.temperatures ?? []).map(({ gas_mark, ...rest }) => ({
...rest,
gasMark: gas_mark,
})),
};
} catch (err) {
throw err;
}
}
}
/** Single shared instance — stateless, no reason for more than one (same reasoning as `techStepClassifier`/`prisma`). */
export const intentServiceClient = new IntentServiceClient();

View file

@ -1,331 +0,0 @@
import type { UnitType } from "@batch-cooking/shared";
import type {
ParsedRecipe,
ParsedRecipeIngredient,
ParsedRecipeStep,
} from "../recipe-sources/recipe-source-adapter.js";
import {
extractQuantity,
type IngredientMatchEntry,
loadIngredientCatalog,
loadUnitCatalog,
matchIngredientName,
matchUnit,
type UnitMatchEntry,
} from "./ingredient-matcher.js";
import { techStepClassifier } from "./tech-step-matcher.js";
/**
* The "Traduction en étapes" stage of the import pipeline described in
* specs/batch-cooking-architecture.md (Import depuis source → **Traduction
* en étapes** → Sauvegarde) — takes a source-agnostic {@link ParsedRecipe}
* (recipe-source-adapter.ts's `parse()` output) and declares each step's
* technique sequence, the same `techStepIds: number[]` shape
* `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
*
* Also resolves ingredients — matching each free-text `ParsedRecipeIngredient`
* line against our `Ingredient`/`Unit` catalogs (`ingredient-matcher.ts`),
* the same "free source text -> our catalog id" idea as tech-step
* detection, just for ingredients/units/quantities instead of technique
* verbs. Like tech-step matching, this doesn't turn the result into a
* saveable `Recipe` either (no `dietIds`/`visibility`/author, a source
* can't know those) — this is one step of the pipeline, not the whole
* thing.
*
* `translateRecipeIngredients` stays pure (takes its matching data as plain
* arguments, same convention `matchIngredientName` itself has) so it's
* unit-testable without a database. `translateRecipeSteps` no longer is —
* technique detection now goes through `techStepClassifier`'s trained
* model (`tech-step-matcher.ts`), which needs an async call — but is still
* exported separately from `translateRecipe` for callers/tests that only
* care about step translation, not ingredients too.
*/
/** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */
export interface TranslatedRecipeStep extends ParsedRecipeStep {
/** Ordered sequence of detected `TechStep` ids (see `TechStepClassifierService.matchTechSteps`) — empty if this step doesn't mention any known technique. */
techStepIds: number[];
}
/** A {@link ParsedRecipeIngredient}, after ingredient/unit matching — `quantity` is filled in from `rawText` when the source itself left it `null` (see `extractQuantity`); `ingredientId`/`unitId` are `null` when nothing in the catalog matched. */
export interface TranslatedRecipeIngredient extends ParsedRecipeIngredient {
ingredientId: number | null;
unitId: number | null;
}
/** A {@link ParsedRecipe} whose `steps`/`ingredients` have been translated — everything else (name, portions, …) passes through unchanged. */
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps" | "ingredients"> {
steps: TranslatedRecipeStep[];
ingredients: TranslatedRecipeIngredient[];
}
/**
* Declares each of `recipe`'s steps' technique sequence for `locale`,
* leaving everything else about the recipe untouched — including
* ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
* separate the same way tech-step and ingredient matching are two
* independent concerns everywhere else in this module. Async — technique
* detection now runs against `techStepClassifier`'s trained model rather
* than a caller-supplied mapping list (see `tech-step-matcher.ts`), so this
* can no longer stay a plain synchronous function the way it used to.
*/
export async function translateRecipeSteps(
recipe: ParsedRecipe,
locale: string,
): Promise<TranslatedRecipe> {
try {
const steps = await Promise.all(
recipe.steps.map(async (step) => ({
...step,
techStepIds: await techStepClassifier.matchTechSteps(step.description, locale),
})),
);
return {
...recipe,
ingredients: recipe.ingredients.map((ingredient) => ({
...ingredient,
ingredientId: null,
unitId: null,
})),
steps,
};
} catch (err) {
// Rethrown as-is — the caller (`sources.service.ts`) already
// handles/logs failures centrally; this function just isn't allowed a
// bare `await` per the repo's async/try-catch convention.
throw err;
}
}
/**
* Locale-specific label {@link matchUnit} is fed when a quantity was found
* but no unit word was — see the `unitId` fallback below. Each is the
* catalog's generic "counted, no further unit" entry (`Unit.key` `"piece"`)
* in that locale's own label table (`UNIT_LABELS_EN`/`UNIT_LABELS_FR`,
* `packages/shared`). A locale with neither entry (anything but
* `"en"`/`"fr"`) falls back to the English spelling — harmless, since
* `unitCatalog` itself is already empty for an unsupported locale (see
* `loadUnitCatalog`), so this fallback lookup finds nothing either way.
*/
const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
en: "piece",
fr: "unité",
};
/**
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
* Pure — testable with hand-built catalogs, no database involved (see
* `translateRecipe` for the DB-backed loader). `quantity` falls back to
* `extractQuantity(rawText)` only when the source itself left it `null`;
* same for `unit` falling back to `extractQuantity`'s `remainder` before
* being matched against `unitCatalog` — a source that already states a
* clean unit/quantity is trusted over re-deriving it from `rawText`.
*
* When a quantity was found but nothing in the remaining text matched a
* unit (e.g. `"4 Egg Yolks"` — quantity `4`, remainder `"Egg Yolks"`, no
* unit word anywhere in it), `unitId` falls back to the catalog's generic
* `piece` unit rather than staying `null`: a bare count with no explicit
* unit word is overwhelmingly "N of them" (eggs, onions, cloves not
* spelled out as "clove") in practice, not a genuinely missing unit — see
* issue #53, where this previously left the import review form's submit
* button disabled with no indication why on almost any recipe with a
* whole-item ingredient. No fallback when `quantity` itself is `null`
* (e.g. `"To taste"`) — there's nothing to count, so nothing to default.
*
* `locale` (default `"en"`, matching every pre-existing caller) must agree
* with whichever locale `ingredientCatalog`/`unitCatalog` were loaded in
* (see `loadIngredientCatalog`/`loadUnitCatalog`) — it's threaded through to
* `matchIngredientName`/`matchUnit` for stemming, and picks the right
* spelling of the "piece" fallback below.
*/
export function translateRecipeIngredients(
ingredients: ParsedRecipeIngredient[],
ingredientCatalog: IngredientMatchEntry[],
unitCatalog: UnitMatchEntry[],
locale = "en",
): TranslatedRecipeIngredient[] {
const fallbackCountUnitLabel = FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE[locale] ?? "piece";
return ingredients.map((ingredient) => {
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog, locale);
const extracted = extractQuantity(ingredient.rawText);
const quantity = ingredient.quantity ?? extracted.quantity;
const unitText = ingredient.unit ?? extracted.remainder;
const unitId =
matchUnit(unitText, unitCatalog, locale) ??
(quantity !== null ? matchUnit(fallbackCountUnitLabel, unitCatalog, locale) : null);
return { ...ingredient, quantity, ingredientId, unitId };
});
}
/** What {@link mergeDuplicateIngredients} needs to know about a `Unit` to combine two of them — the `type`/`toBaseFactor` slice of `UnitView` (`packages/shared`). */
export interface UnitConversionEntry {
id: number;
type: UnitType;
toBaseFactor: number;
}
/**
* Combines `a`/`b` — two lines already confirmed to resolve to the same
* ingredient — into one, summing their quantities, or returns `null` when
* that can't be done safely. `null` (quantity or unit missing on either
* side, unit not found in `unitById`, mismatched `UnitType`, or either side
* a `COUNT` unit) means "don't merge", not "error" — see
* {@link mergeDuplicateIngredients}.
*
* Same unit on both sides sums directly. Different units of the same
* measurable *type* (`MASS`/`VOLUME`) convert `b`'s quantity into `a`'s
* unit via `toBaseFactor` first — the groundwork that field's own doc
* comment (`UnitView`, `packages/shared`) already anticipated ("a future
* conversion feature ... summing '500g' + '0.5kg'"). `COUNT` units are
* never converted against each other even when `toBaseFactor` matches — a
* "pincée" isn't a fixed fraction of a "gousse" (same doc comment) — so two
* different `COUNT` units for the same ingredient are left unmerged.
* Rounded to 2 decimal places (`RecipeIngredient.quantity` is
* `Decimal(10, 2)`, schema.prisma) to avoid floating-point noise from the
* conversion.
*/
function combineIngredientLines(
a: TranslatedRecipeIngredient,
b: TranslatedRecipeIngredient,
unitById: Map<number, UnitConversionEntry>,
): TranslatedRecipeIngredient | null {
if (a.quantity === null || b.quantity === null || a.unitId === null || b.unitId === null) {
return null;
}
if (a.unitId === b.unitId) {
return {
...a,
quantity: a.quantity + b.quantity,
rawText: `${a.rawText} + ${b.rawText}`,
};
}
const unitA = unitById.get(a.unitId);
const unitB = unitById.get(b.unitId);
if (!unitA || !unitB) return null;
if (unitA.type !== unitB.type || unitA.type === "COUNT") return null;
const combinedInBaseUnit = a.quantity * unitA.toBaseFactor + b.quantity * unitB.toBaseFactor;
const quantity = Math.round((combinedInBaseUnit / unitA.toBaseFactor) * 100) / 100;
return { ...a, quantity, rawText: `${a.rawText} + ${b.rawText}` };
}
/**
* Folds `ingredients` down to one line per resolved `ingredientId`,
* concatenating (summing the quantity of) every duplicate into the first
* line it matches — see issue #53's follow-up: two raw source lines (e.g.
* TheMealDB's "Egg Yolks"/"Eggs", or "100g Sugar" used in two different
* steps) can independently resolve to the same catalog `Ingredient`, and
* `RecipeIngredient`'s primary key (`recipeId`, `ingredientId`) only
* allows one row per ingredient per recipe — the review form used to
* either crash on submit (before `createRecipeSchema` rejected it) or
* require the person to manually delete every extra line by hand.
*
* Unresolved lines (`ingredientId: null`) are never merged with one
* another or with anything else — nothing reliable to key them on. Two
* lines that resolve to the same ingredient but can't be combined safely
* (see {@link combineIngredientLines} — mismatched quantity/unit, or
* genuinely incompatible units) are left as separate, still-duplicate
* lines: `createRecipeSchema` still rejects the result, and
* `RecipeImportForm` still highlights them, same safety net as before this
* merge step existed — merging never *invents* a number it isn't confident
* in.
*
* Pure — testable with a hand-built `unitCatalog`, no database involved.
* Order-preserving: a merged line keeps its first occurrence's position.
*/
export function mergeDuplicateIngredients(
ingredients: TranslatedRecipeIngredient[],
unitCatalog: UnitConversionEntry[],
): TranslatedRecipeIngredient[] {
const unitById = new Map(unitCatalog.map((unit) => [unit.id, unit]));
const merged: TranslatedRecipeIngredient[] = [];
const mergedIndexByIngredientId = new Map<number, number>();
for (const line of ingredients) {
const existingIndex =
line.ingredientId !== null ? mergedIndexByIngredientId.get(line.ingredientId) : undefined;
const existingLine = existingIndex !== undefined ? merged[existingIndex] : undefined;
if (existingIndex === undefined || existingLine === undefined) {
if (line.ingredientId !== null) {
mergedIndexByIngredientId.set(line.ingredientId, merged.length);
}
merged.push(line);
continue;
}
const combined = combineIngredientLines(existingLine, line, unitById);
if (combined === null) {
merged.push(line);
} else {
merged[existingIndex] = combined;
}
}
return merged;
}
/**
* Convenience wrapper around {@link translateRecipeSteps}/
* {@link translateRecipeIngredients} that loads every catalog itself —
* what a caller reaches for when translating a single recipe on its own
* (e.g. the eventual "import this one recipe" endpoint). A caller
* translating many recipes at once should load the catalogs once and reuse
* them across calls instead, the same "don't requery per item" reasoning
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` already follow for
* manually-authored recipes.
*
* No user- or recipe-level language preference exists anywhere in the app
* yet (see `tech-step-matcher.ts`'s `TechStepClassifierService`) — callers
* pass a locale explicitly rather than this module guessing one. Note that
* an English-language source (e.g. TheMealDB) translated against `"fr"`
* will currently get an empty (or nonsensical) `techStepIds` sequence on
* every step — the classifier is trained per-locale, so calling it with a
* locale that doesn't match the actual text's language doesn't degrade
* gracefully, it just gets things wrong.
*
* Ingredient/unit matching has data for `"en"` and `"fr"` today
* (`INGREDIENT_LABELS_EN`/`_FR`, `UNIT_LABELS_EN`/`_FR`, `packages/shared`)
* — `loadIngredientCatalog(locale)`/`loadUnitCatalog(locale)` are always
* called, never specially skipped for a particular locale: a locale with no
* label table of its own (anything but `"en"`/`"fr"`) just gets back empty
* catalogs from those two loaders, and `translateRecipeIngredients` over an
* empty catalog naturally leaves every ingredient's `ingredientId`/`unitId`
* at the neutral `null` `translateRecipeSteps` already stubs in — the same
* "no matching-language data" degradation tech-step matching already has
* for a locale with no mappings, just arrived at by *not* special-casing
* which locales are "supported" here at all (that's `INGREDIENT_LABELS_BY_LOCALE`/
* `UNIT_LABELS_BY_LOCALE`'s job, in `ingredient-matcher.ts` — this function
* doesn't need its own copy of that list to stay in sync with).
*/
export async function translateRecipe(
recipe: ParsedRecipe,
locale: string,
): Promise<TranslatedRecipe> {
try {
const translated = await translateRecipeSteps(recipe, locale);
const [ingredientCatalog, unitCatalog] = await Promise.all([
loadIngredientCatalog(locale),
loadUnitCatalog(locale),
]);
return {
...translated,
ingredients: translateRecipeIngredients(
recipe.ingredients,
ingredientCatalog,
unitCatalog,
locale,
),
};
} catch (err) {
// Rethrown as-is — the caller (`sources.service.ts`) already
// handles/logs failures centrally; this function just isn't allowed a
// bare `await` per the repo's async/try-catch convention.
throw err;
}
}

View file

@ -1,263 +0,0 @@
/**
* Hand-labeled evaluation set for {@link techStepClassifier} — what
* `tech-step-eval.test.ts` runs the real classifier against to compute
* precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any
* future change to `services/tech-step-intent-service`'s `training_data.py`
* must clear (see that module's own doc comment).
*
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
* verbatim — scoring the classifier against the exact sentences it was
* trained on would measure memorization, not generalization. Every
* description below is original phrasing; where a case still needs to name
* a technique's own verb to be labeled with confidence (most of them, see
* this file's own limits below), it's at least a different sentence shape
* than anything in the training corpus.
*
* `expectedKeys` is a multiset in reading order (see
* `TechStepEvalOutcome`'s doc comment in `tech-step-evaluator.ts` for why
* order isn't scored but repetition is) of `TechStep.key`s — resolved to
* real DB ids and back by `tech-step-eval-runner.ts`, this file only ever
* deals in stable keys so it doesn't need DB access to author or read.
*
* Known limit of this dataset, confirmed against a real run (see
* `MIN_OVERALL_F1`'s own doc comment, `tech-step-eval-runner.ts`): most
* cases anchor on a technique's own registered synonym, but `_classifyClause`
* only falls back to that anchor when the intent classifier's own score is
* *below* `CONFIDENCE_THRESHOLD` — a confidently *wrong* whole-clause
* classification (e.g. "Blanchissez les haricots verts..." scoring
* confidently as `peel` despite the correct `blanch` anchor) overrides the
* anchor just as readily as a confidently *right* one does, so this
* dataset genuinely does measure real classifier failures, not just a
* synthetic floor. A handful of such real mismatches are expected and
* intentionally left uncorrected here (see `MIN_OVERALL_F1`'s doc comment)
* — fixing the classifier's actual behavior on them is corpus work for a
* future change, not something to hide by loosening this dataset's own
* expectations to match whatever it currently outputs.
*/
export interface TechStepEvalCase {
description: string;
locale: "fr" | "en";
expectedKeys: string[];
}
export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
// --- One straightforward case per technique (fr), covering all 26 ---
{
description: "Faites cuire les pâtes al dente dans une grande casserole d'eau bien salée.",
locale: "fr",
expectedKeys: ["cook"],
},
{
description: "Faites bouillir l'eau dans une grande casserole avant d'y plonger les pâtes.",
locale: "fr",
expectedKeys: ["boil"],
},
{
description: "Plongez les beignets dans l'huile très chaude pour les faire frire.",
locale: "fr",
expectedKeys: ["fry"],
},
{
description: "Faites fondre le chocolat noir au bain-marie en remuant.",
locale: "fr",
expectedKeys: ["melt"],
},
{
description: "Déglacez la casserole avec un trait de vinaigre balsamique.",
locale: "fr",
expectedKeys: ["deglaze"],
},
{
description: "Laissez frémir la sauce tomate vingt minutes à couvert.",
locale: "fr",
expectedKeys: ["simmer"],
},
{
description: "Faites rôtir la volaille entière sur la broche du four.",
locale: "fr",
expectedKeys: ["roast"],
},
{
description: "Faites griller les brochettes de poulet quelques minutes de chaque côté.",
locale: "fr",
expectedKeys: ["grill"],
},
{
description: "Faites sauter les champignons à feu vif dans une poêle très chaude.",
locale: "fr",
expectedKeys: ["panFry"],
},
{
description: "Blanchissez les haricots verts trois minutes avant de les refroidir.",
locale: "fr",
expectedKeys: ["blanch"],
},
{
description: "Laissez mariner les brochettes de poulet deux heures au frais.",
locale: "fr",
expectedKeys: ["marinate"],
},
{
description: "Hachez grossièrement le persil frais avant de le parsemer.",
locale: "fr",
expectedKeys: ["chop"],
},
{
description: "Épluchez les carottes avant de les couper en rondelles.",
locale: "fr",
expectedKeys: ["peel"],
},
{
description: "Émincez finement l'échalote pour la vinaigrette.",
locale: "fr",
expectedKeys: ["mince"],
},
{
description: "Mélangez la farine, le sucre et les œufs dans un grand saladier.",
locale: "fr",
expectedKeys: ["mix"],
},
{
description: "Fouettez énergiquement la crème jusqu'à ce qu'elle épaississe.",
locale: "fr",
expectedKeys: ["whisk"],
},
{
description: "Incorporez délicatement la farine tamisée à la préparation.",
locale: "fr",
expectedKeys: ["foldIn"],
},
{
description: "Réservez la pâte au réfrigérateur pendant que vous préparez la garniture.",
locale: "fr",
expectedKeys: ["setAside"],
},
{
description: "Assaisonnez le poisson avec du sel, du poivre et un filet de citron.",
locale: "fr",
expectedKeys: ["season"],
},
{
description: "Égouttez soigneusement le riz dans une passoire fine.",
locale: "fr",
expectedKeys: ["drain"],
},
{
description:
"Faites dorer les morceaux de veau sur toutes leurs faces avant de mouiller avec le bouillon.",
locale: "fr",
expectedKeys: ["brown"],
},
{
description: "Laissez reposer la viande dix minutes avant de la trancher.",
locale: "fr",
expectedKeys: ["rest"],
},
{
description: "Préchauffez le four à 200 degrés avant d'y glisser le gratin.",
locale: "fr",
expectedKeys: ["preheat"],
},
{
description: "Enfournez la tarte pendant trente-cinq minutes jusqu'à ce qu'elle soit dorée.",
locale: "fr",
expectedKeys: ["bake"],
},
{
description: "Dressez harmonieusement les légumes autour de la pièce de viande.",
locale: "fr",
expectedKeys: ["plate"],
},
{
description: "Nappez le fond du moule d'une fine couche de caramel.",
locale: "fr",
expectedKeys: ["coat"],
},
// --- English coverage (same technique verbs, distinct sentences) ---
{
description: "Simmer the stock gently for forty minutes, skimming occasionally.",
locale: "en",
expectedKeys: ["simmer"],
},
{
description: "Peel the potatoes and rinse them under cold water.",
locale: "en",
expectedKeys: ["peel"],
},
{
description: "Whisk the eggs with a pinch of salt until frothy.",
locale: "en",
expectedKeys: ["whisk"],
},
{
description: "Season the soup generously with black pepper before serving.",
locale: "en",
expectedKeys: ["season"],
},
{
description: "Make sure the chicken is cooked through before serving.",
locale: "en",
expectedKeys: ["cook"],
},
// --- Multi-technique sentences, in reading order ---
{
description: "Préchauffez le four, puis faites rôtir le poulet pendant une heure.",
locale: "fr",
expectedKeys: ["preheat", "roast"],
},
{
description: "Faites revenir les oignons, puis déglacez la poêle avec du vin blanc.",
locale: "fr",
expectedKeys: ["brown", "deglaze"],
},
{
description:
"Faites cuire les légumes à la vapeur, puis assaisonnez-les avec des herbes fraîches.",
locale: "fr",
expectedKeys: ["cook", "season"],
},
{
description:
"Émincez l'oignon, faites-le suer, puis mouillez avec le bouillon et laissez mijoter.",
locale: "fr",
expectedKeys: ["mince", "simmer"],
},
// --- No technique mentioned at all ---
{
description: "Répartissez les convives autour de la table avant de commencer le repas.",
locale: "fr",
expectedKeys: [],
},
{
description: "Rangez les couverts propres dans le tiroir de la cuisine.",
locale: "fr",
expectedKeys: [],
},
{
description: "Take the dishes and glasses out of the cupboard.",
locale: "en",
expectedKeys: [],
},
// --- Documented false-positive traps, re-verified with fresh wording ---
// `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not
// bare "brown" — precisely so this doesn't false-positive (see that
// entry's own comment in training_data.py).
{
description: "This recipe calls for two tablespoons of brown sugar.",
locale: "en",
expectedKeys: [],
},
// `rest`'s EN synonyms are anchored phrases ("let it rest"/"resting
// for"...), not bare "rest" — so a sentence using the word in its
// "remainder" sense must not anchor `rest` at all.
{
description: "There is no time to rest before the guests arrive.",
locale: "en",
expectedKeys: [],
},
];

View file

@ -1,71 +0,0 @@
import { prisma } from "../../db/prisma.js";
import { TECH_STEP_EVAL_DATASET } from "./tech-step-eval-dataset.js";
import {
computeTechStepMetrics,
type TechStepEvalOutcome,
type TechStepEvalResult,
} from "./tech-step-evaluator.js";
import { techStepClassifier } from "./tech-step-matcher.js";
/**
* Regression floor (not a target) both `runTechStepEvalSuite`'s consumers
* gate on — exported from here (not defined separately in each consumer)
* so the CI regression gate and `scripts/retrain-tech-steps.ts`'s
* pre-backfill gate can never silently drift to different thresholds.
*
* Calibrated against a real run: the classifier trained on the corpus as
* of this constant's introduction scored **0.815** aggregate F1
* (33 TP / 9 FP / 6 FN) against `TECH_STEP_EVAL_DATASET` — `0.8` leaves a
* small margin below that for run-to-run noise while still catching a
* real regression (not a floor picked blind before ever running this
* suite — see this feature's plan document for that earlier state). The
* mismatches this run surfaced (e.g. "Blanchissez les haricots verts..."
* misclassified as `peel`, a handful of anchor-less sentences expected to
* match nothing instead scoring confidently as some technique) are real,
* known classifier weaknesses — evidence this harness is doing its job,
* not something to quietly paper over by loosening the dataset's own
* expectations. Improving them is corpus work for a future change, gated
* by this same suite.
*/
export const MIN_OVERALL_F1 = 0.8;
/**
* Runs {@link TECH_STEP_EVAL_DATASET} against the real, currently-trained
* `techStepClassifier` and returns the aggregate/per-technique metrics
* (`computeTechStepMetrics`, `tech-step-evaluator.ts`) — the one place this
* DB-touching "resolve ids to keys, then score" logic lives, shared by
* `test/recipe-matching/tech-step-eval.test.ts` (this feature's CI
* regression gate) and `scripts/retrain-tech-steps.ts` (the same gate, run
* by a maintainer before applying a corpus change). Kept out of
* `tech-step-evaluator.ts` itself, which is deliberately pure/DB-free (see
* that module's own doc comment) so its scoring logic stays unit-testable
* without a database.
*/
export async function runTechStepEvalSuite(): Promise<TechStepEvalResult> {
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
const keyById = new Map(techSteps.map((techStep) => [techStep.id, techStep.key]));
const outcomes: TechStepEvalOutcome[] = [];
for (const evalCase of TECH_STEP_EVAL_DATASET) {
const techStepIds = await techStepClassifier.matchTechSteps(
evalCase.description,
evalCase.locale,
);
const actualKeys = techStepIds.map((id) => {
const key = keyById.get(id);
// A `techStepId` the classifier resolved that isn't in the seeded
// catalog would be a bug in the classifier or the seed data, not
// this dataset — fail loudly rather than silently dropping it (see
// `_train`'s own comment in `tech-step-matcher.ts` on the
// equivalent, deliberately silent `undefined` case it has to
// tolerate for a different reason).
if (key === undefined) {
throw new Error(`Unknown TechStep id ${id} returned for "${evalCase.description}"`);
}
return key;
});
outcomes.push({ expectedKeys: evalCase.expectedKeys, actualKeys });
}
return computeTechStepMetrics(outcomes);
}

View file

@ -1,135 +0,0 @@
/**
* Precision/recall/F1 for {@link techStepClassifier}'s output against a
* hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective
* counterpart to the "inspected by eye" verdict every corpus change used to
* get before this module existed. Every future edit to
* `services/tech-step-intent-service`'s `training_data.py` (including the
* LLM-assisted suggestions the worker in `services/tech-step-llm-worker`
* proposes) is expected to run
* through `tech-step-eval.test.ts`'s regression gate, which calls
* {@link computeTechStepMetrics} — a corpus change that raises recall on one
* technique but silently tanks another's precision should fail loudly here,
* not get merged on the strength of a few manually-checked examples.
*
* Pure (no DB/model access) so it's unit-testable on its own — same
* convention as `tech-step-matcher.ts`'s own pure helpers (`normalizeText`,
* `splitIntoClauses`): this module only ever receives already-resolved
* `TechStep.key` strings, never DB ids or a live classifier instance, so it
* has nothing to mock to test.
*/
/** True/false-positive/negative counts for one technique (or the aggregate across all of them), plus the precision/recall/F1 derived from them. */
export interface TechStepMetrics {
truePositives: number;
falsePositives: number;
falseNegatives: number;
precision: number;
recall: number;
f1: number;
}
/**
* One evaluation case's outcome — what {@link TechStepEvalCase.expectedKeys}
* said should be found, against what the classifier actually returned for
* that case (already mapped from `TechStepMatch.techStepId` back to
* `TechStep.key`, see `tech-step-eval.test.ts`).
*
* Both lists are *multisets*, not sets — a description that names the same
* technique twice (rare, but not impossible: "faire cuire, puis... remettre
* à cuire") is expected to produce two matches, and comparing as plain sets
* would silently treat a classifier that only found one of them as a
* perfect match.
*/
export interface TechStepEvalOutcome {
expectedKeys: string[];
actualKeys: string[];
}
/** {@link computeTechStepMetrics}'s result — the aggregate across every case, plus a breakdown per technique so a regression hiding behind a healthy overall F1 (one technique's recall collapsing, offset by another's improving) is still visible. */
export interface TechStepEvalResult {
overall: TechStepMetrics;
byKey: Record<string, TechStepMetrics>;
}
interface RawCounts {
tp: number;
fp: number;
fn: number;
}
function emptyCounts(): RawCounts {
return { tp: 0, fp: 0, fn: 0 };
}
/** Counts occurrences of each key in a multiset, e.g. `["cook", "cook", "bake"]` -> `{cook: 2, bake: 1}`. */
function countByKey(keys: string[]): Map<string, number> {
const counts = new Map<string, number>();
for (const key of keys) {
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}
/**
* Standard vacuous-truth convention for the `0/0` cases: precision defaults
* to `1` when nothing was predicted for a key (`tp + fp === 0` — no false
* accusation to be precise about), recall defaults to `1` when nothing was
* expected (`tp + fn === 0` — nothing to have missed). Neither inflates F1
* on its own: a technique the classifier fully misses still has `recall =
* 0` (there *were* expected occurrences, just none matched), which is what
* pulls F1 down to `0` for that case regardless of precision's vacuous `1`.
*/
function toMetrics(counts: RawCounts): TechStepMetrics {
const { tp, fp, fn } = counts;
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
return { truePositives: tp, falsePositives: fp, falseNegatives: fn, precision, recall, f1 };
}
/**
* Aggregates every {@link TechStepEvalOutcome} into one overall
* precision/recall/F1 plus a per-technique breakdown.
*
* Counted key by key, multiset-style, per outcome: for a given technique,
* `min(expectedCount, actualCount)` true positives, any actual occurrences
* beyond that are false positives, any expected occurrences short of that
* are false negatives — generalizes the usual set-based TP/FP/FN definition
* to handle a technique mentioned (or matched) more than once in the same
* step without over- or under-counting it.
*/
export function computeTechStepMetrics(outcomes: TechStepEvalOutcome[]): TechStepEvalResult {
const overallCounts = emptyCounts();
const countsByKey = new Map<string, RawCounts>();
for (const outcome of outcomes) {
const expectedCounts = countByKey(outcome.expectedKeys);
const actualCounts = countByKey(outcome.actualKeys);
const allKeys = new Set([...expectedCounts.keys(), ...actualCounts.keys()]);
for (const key of allKeys) {
const expected = expectedCounts.get(key) ?? 0;
const actual = actualCounts.get(key) ?? 0;
const tp = Math.min(expected, actual);
const fp = Math.max(0, actual - expected);
const fn = Math.max(0, expected - actual);
overallCounts.tp += tp;
overallCounts.fp += fp;
overallCounts.fn += fn;
const keyCounts = countsByKey.get(key) ?? emptyCounts();
keyCounts.tp += tp;
keyCounts.fp += fp;
keyCounts.fn += fn;
countsByKey.set(key, keyCounts);
}
}
const byKey: Record<string, TechStepMetrics> = {};
for (const [key, counts] of countsByKey) {
byKey[key] = toMetrics(counts);
}
return { overall: toMetrics(overallCounts), byKey };
}

View file

@ -1,636 +0,0 @@
import { prisma } from "../../db/prisma.js";
import {
findIngredientMentions,
type IngredientMention,
loadIngredientCatalog,
loadUnitCatalog,
} from "./ingredient-matcher.js";
import { type IntentServiceTemperature, intentServiceClient } from "./intent-service-client.js";
/**
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
* step description corresponds to — groundwork for a future batch-cooking
* optimization algorithm, and (via `matchTechStepSpans`) what
* `recipe.service.ts` persists as `StepTechStep.start`/`end` so the recipe
* UI can highlight the exact matched words (see `StepView` in
* `packages/shared`).
*
* Regex-only matching used to live here (matching literal verb-form
* patterns from a DB-backed `TechStepMapping` table) but couldn't
* generalize past its own vocabulary — a step describing melting butter as
* "jusqu'à ce que le beurre ait disparu dans la poêle" mentions no verb any
* regex could anchor on, yet unmistakably *means* `melt`. Replaced with a
* small hybrid pipeline (originally built on `node-nlp`, now entirely
* delegated to `services/tech-step-intent-service` — a spaCy-based
* microservice, see {@link IntentServiceClient} and that service's own
* README):
*
* 1. **NER** (the intent service's `PhraseMatcher`, built from its own
* `training_data.py`'s `synonyms`) finds every *candidate* technique
* mention in the whole description, each with its exact character span —
* mechanically the same job the old regexes did, just as flat synonym
* lists instead of hand-written patterns. This step alone is *not* the
* final answer — see step 3.
* 2. The description is cut into clauses around those candidate spans
* ({@link splitIntoClauses}) — a step naming two techniques ("Dans une
* poêle chaude, faire chauffer une noix de beurre" is both `preheat`
* and `melt`) needs each judged on its own surrounding context, not the
* whole step lumped into one classification.
* 3. **NLP intent classification** (the intent service's `textcat`, trained
* on its own `training_data.py`'s `utterances`) then classifies each
* clause on its own — this is what actually delivers "meaning, not
* keywords": the classifier was deliberately trained on paraphrases that
* never use the technique's own verb (e.g. "jusqu'à ce que le beurre ait
* disparu" for `melt`), so a clause reaching it gets labeled by what it
* was trained to recognize as *meaning* a technique, not by which
* literal word the NER step happened to anchor on. The NER-implied
* technique is kept only as a fallback for a clause the classifier
* isn't confident about (see `CONFIDENCE_THRESHOLD`) — a clearly
* keyword-anchored clause a small model merely isn't sure how to
* classify shouldn't be dropped outright.
*
* A single instruction can genuinely involve more than one technique — see
* point 2 above — so `matchTechSteps`/`matchTechStepSpans` both return the
* whole *ordered sequence* they find, not a single winner, matching
* `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table).
*
* `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
* access) so they stay unit-testable in isolation (see
* `test/tech-step-matcher.test.ts`); this class only ever needs a
* `TechStep.key -> id` lookup from the DB, memoized on the shared
* {@link techStepClassifier} singleton rather than repeated per call — the
* NLP model itself trains once, inside `services/tech-step-intent-service`'s
* own startup, entirely independently of this class (see that service's
* README — this repo no longer pushes any corpus to it over HTTP).
*/
/**
* Lowercases and strips diacritics (NFD decomposition + removal of
* combining marks, e.g. "Déglacer" -> "deglacer"). Still used by
* `ingredient-matcher.ts` for its own, unrelated free-text matching — kept
* here and exported rather than duplicated, this module owned it first.
*/
const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu;
export function normalizeText(text: string): string {
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
}
/**
* One technique {@link matchTechStepSpans} found, alongside exactly where in
* `description` it matched — two nested spans, both `[start, end)` (same
* convention as `String.prototype.slice`):
*
* - `start`/`end` — the tight *keyword* span (e.g. "préchauffer") that
* directly triggered the match, or (when no NER anchor exists at all —
* see {@link splitIntoClauses}'s zero-candidate case) the whole clause,
* same as `contextStart`/`contextEnd` below.
* - `contextStart`/`contextEnd` — the wider *clause* the keyword was found
* in (e.g. "Dans une poêle chaude" for a `preheat` keyword of "poêle
* chaude") — what actually got fed to the classifier (see this file's
* doc comment, point 3), kept alongside the tight span so a caller can
* show *both*: the exact trigger word(s), and how much of the sentence
* is understood to be about that technique. Always contains `start`/`end`
* (`contextStart <= start`, `end <= contextEnd`).
*
* Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
* (`recipe.service.ts`) so the recipe detail view can highlight both spans,
* not just know a technique was mentioned somewhere.
*
* `ingredients`/`utensils` are the metadata found in this match's own
* *clause* (see this file's doc comment, point 2) — an ingredient/utensil
* mentioned in a different clause of the same description belongs to
* *that* clause's own match, never this one, the same "judged on its own
* surrounding context" rule the technique itself is judged by. Always `[]`
* rather than omitted when nothing was found, so every caller can iterate
* unconditionally. Persisted as `StepTechStepIngredient`/`StepTechStepUtensil`
* rows (`recipe.service.ts`).
*/
export interface TechStepMatch {
techStepId: number;
start: number;
end: number;
contextStart: number;
contextEnd: number;
ingredients: IngredientMention[];
utensils: UtensilMention[];
temperatures: TemperatureMention[];
}
/**
* A utensil mention found by the intent service's utensil `PhraseMatcher`
* (`kind: "utensil"` entities in `IntentServiceProcessResult`, see
* `intent-service-client.ts`), resolved to a local `Utensil.id` and
* attributed to whichever clause its span falls inside — same
* `[start, end)` convention as every other span in this file.
*/
export interface UtensilMention {
utensilId: number;
start: number;
end: number;
}
/**
* A temperature the intent service's regex pass found (`temperatures` in
* `IntentServiceProcessResult`), attributed to whichever clause its span
* falls inside — same rule as {@link UtensilMention}. No DB id to resolve
* (unlike a technique/ingredient/utensil): the structured value *is* the
* data. Persisted verbatim as a `StepTechStepTemperature` row.
*/
export type TemperatureMention = IntentServiceTemperature;
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
export interface TechniqueCandidate {
/** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
uid: string;
start: number;
end: number;
}
/** One clause {@link splitIntoClauses} produced — `anchor` is `null` only for the single "whole description, no candidate found at all" fallback clause (see that function's doc comment). */
export interface TechStepClause {
/** `[start, end)` into the original description — the text handed to the classifier for this clause, and (see {@link TechStepMatch}) what ends up as a match's `contextStart`/`contextEnd`. */
start: number;
end: number;
/** The candidate this clause was cut around, if any — its own (tighter) span is what gets persisted as a match's `start`/`end` for the keyword highlight, the wider clause span is always its `contextStart`/`contextEnd`. */
anchor: TechniqueCandidate | null;
}
/** Matches a sentence-ending punctuation mark, for {@link findGapSplitPoint}'s preferred split points. */
const SENTENCE_END_PATTERN = /[.!?]/;
/**
* Picks where to cut the gap `[gapStart, gapEnd)` between two consecutive
* candidates — preferring a *sentence* boundary (right after `.`/`!`/`?`)
* nearest the gap's midpoint when one exists in the gap, otherwise any
* whitespace nearest the midpoint, so a clause boundary (surfaced to users
* as `contextStart`/`contextEnd`, unlike a keyword's own `[start, end)`
* which always lands on a real word by construction) never slices through
* the middle of a word — found while testing a context span that cut
* "poêle" into "poêl"/"e" across two clauses.
*
* The sentence-boundary preference matters beyond cosmetics: a description
* with two techniques in two different sentences ("Préchauffer le four à
* 180°C. Dans un saladier, mettre le beurre... et mélanger.") used to only
* get a plain nearest-midpoint whitespace split, which for a long first
* sentence lands *inside* the second one — handing the classifier a clause
* like "...(thermostat 6). Dans un saladier, mettre" that trails off
* mid-instruction with no object. That garbled, incomplete text is nothing
* like the short, complete training utterances, and was found to
* misclassify real recipe steps with high (>0.65) confidence in both
* halves — "Préchauffer..." scored as `mix`, its actual "mélanger" clause
* as `melt`. Splitting at the real sentence boundary instead hands the
* classifier two complete, grammatical clauses, each far closer to what it
* was trained on.
*
* Falls back to the raw midpoint when the gap has no whitespace at all
* (adjacent candidates, or a gap that's pure punctuation with no space) —
* same "some split point, however imperfect" fallback a plain midpoint
* always was.
*/
function findGapSplitPoint(description: string, gapStart: number, gapEnd: number): number {
if (gapStart >= gapEnd) return gapStart;
const midpoint = Math.floor((gapStart + gapEnd) / 2);
let bestSentenceEnd: number | null = null;
let bestSentenceEndDistance = Number.POSITIVE_INFINITY;
let bestWhitespace: number | null = null;
let bestWhitespaceDistance = Number.POSITIVE_INFINITY;
for (let i = gapStart; i < gapEnd; i++) {
if (!/\s/.test(description[i] ?? "")) continue;
const distance = Math.abs(i - midpoint);
if (distance < bestWhitespaceDistance) {
bestWhitespace = i;
bestWhitespaceDistance = distance;
}
if (
i > gapStart &&
SENTENCE_END_PATTERN.test(description[i - 1] ?? "") &&
distance < bestSentenceEndDistance
) {
bestSentenceEnd = i;
bestSentenceEndDistance = distance;
}
}
return bestSentenceEnd ?? bestWhitespace ?? midpoint;
}
/**
* Cuts `description` into clauses around `candidates` (NER's found
* technique mentions, already sorted or not — sorted internally), one
* clause per candidate, so each can be judged by the classifier on its own
* surrounding context rather than the whole (possibly multi-technique)
* description at once.
*
* - **Zero candidates**: the whole description is one clause with no
* anchor — still worth classifying (a description mentioning no literal
* keyword at all can still *mean* a technique, the entire point of the
* classification step), just with no tight span to highlight, so callers
* fall back to highlighting the whole thing.
* - **One candidate**: the whole description is one clause too (nothing to
* cut around a single mention), but *with* that candidate as its anchor
* — callers get its tight span for highlighting.
* - **Two or more**: split points fall at the whitespace nearest the
* midpoint of each consecutive pair's `[end, nextStart]` gap (see
* {@link findGapSplitPoint} — never mid-word), producing that many
* contiguous, non-overlapping clauses covering the whole description —
* clause *i* is anchored on candidate *i*.
*
* Pure and DB/model-free — unit-tested directly (see
* `test/tech-step-matcher.test.ts`) without needing a trained classifier.
*/
export function splitIntoClauses(
description: string,
candidates: TechniqueCandidate[],
): TechStepClause[] {
if (candidates.length === 0) {
return [{ start: 0, end: description.length, anchor: null }];
}
const sorted = [...candidates].sort((a, b) => a.start - b.start);
const [first, ...rest] = sorted;
if (first === undefined) {
// Unreachable — `candidates.length === 0` already returned above, so
// `sorted` (same length) always has a first element here. Satisfies
// `noUncheckedIndexedAccess`, which can't see that from the length
// check alone.
return [{ start: 0, end: description.length, anchor: null }];
}
// Single pass, pairing each candidate with the next one as it goes —
// avoids re-indexing a separately-built `splitPoints` array afterward
// (also awkward under `noUncheckedIndexedAccess` for no real benefit,
// since every split point is only ever read once, right after it's
// computed).
const clauses: TechStepClause[] = [];
let clauseStart = 0;
let anchor = first;
for (const next of rest) {
const splitPoint = findGapSplitPoint(description, anchor.end, next.start);
clauses.push({ start: clauseStart, end: splitPoint, anchor });
clauseStart = splitPoint;
anchor = next;
}
clauses.push({ start: clauseStart, end: description.length, anchor });
return clauses;
}
/**
* Below this confidence, a clause's classifier verdict isn't trusted on its
* own — falls back to its NER anchor's own technique instead (see this
* file's doc comment, point 3). Tuned empirically against
* `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the
* cases this threshold was picked to pass.
*
* Recalibrated for the migration off `node-nlp` to
* `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes)
* — its score distribution is meaningfully different from node-nlp's own
* classifier, and shifts again every time the corpus' technique count
* changes (more exclusive classes generally means a *lower* natural
* confidence ceiling, softmax mass spread thinner).
*
* Currently `0.25`, set against the corpus as expanded to ~74 techniques
* (`services/tech-step-intent-service/intent_service/training_data.py`,
* `_TRAINING_ITERATIONS = 25`, `textcat` trained on each technique's own
* `synonyms` in addition to its `utterances` — see that constant's own
* comment for the calibration history) from manual spot-checks, not yet a
* real `calibrate-tech-step-threshold.ts` sweep against
* `TECH_STEP_EVAL_DATASET` (needs Postgres — see that script's own doc
* comment): observed real-case scores ranged `0.31`-`0.89` (`simmer`
* lowest, still correct in argmax and anchored anyway; `melt` highest, the
* motivating anchor-less case), against a noise floor around `0.02`
* (English text through the French classifier). `0.25` sits with real
* margin above the noise floor and below every real case seen so far, but
* **this is a placeholder pending the real eval-dataset sweep** — do not
* treat it as load-bearing precision the way the original `0.45`
* (calibrated against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET`
* F1 plateauing exactly there) was.
*/
export const CONFIDENCE_THRESHOLD = 0.25;
/**
* One clause's full classification detail — the finer-grained sibling of
* {@link TechStepMatch}, exposing the raw intent/score
* `TechStepClassifierService`'s private `_classifyClause` normally
* collapses into a single accepted-or-fallback verdict. Nothing on the
* interactive save/read path needs this (that's exactly what
* `_classifyClause`'s threshold + fallback logic is for) — it exists for
* `services/tech-step-llm-worker`'s "audit low-confidence clauses" job
* (`modules/internal/tech-step-worker.service.ts`'s `getAuditBatch`), which
* needs to see *which* clauses the classifier itself wasn't sure about, not
* just its final best-effort verdict.
*/
export interface TechStepClauseClassification {
/** The clause's own text (`description.slice(start, end)`, trimmed). */
clauseText: string;
start: number;
end: number;
/** The clause's NER anchor's own implied technique `uid`, if it had one — same as `TechStepClause.anchor.uid`. */
anchorUid: string | null;
/** The intent classifier's own top guess for this clause, whatever its score — `null` only when the intent service had nothing trained for `locale`, or the clause text was blank. Unlike {@link TechStepMatch}, never silently replaced by the anchor's uid — the whole point of this type is to expose the classifier's raw opinion, confident or not. */
intentUid: string | null;
/** The intent classifier's own confidence for `intentUid` — `0` when `intentUid` is `null` (nothing to have a score about). */
score: number;
}
/**
* Owns the `TechStep.key -> id` lookup behind {@link matchTechStepSpans} —
* a real class (not a plain object of functions) per this repo's
* service-style-logic convention, even though it's only ever used as the
* one shared {@link techStepClassifier} singleton below: it holds real
* state (the memoized lookup promise), not just grouped stateless helpers.
* The actual NER/intent-classification model lives entirely in
* `services/tech-step-intent-service` (a separate process, trained from
* its own `training_data.py` at its own startup) — this class never
* trains or pushes anything to it, it only calls `POST /v1/process` and
* resolves whatever `uid` comes back to a local DB id.
*/
export class TechStepClassifierService {
/** Memoized `TechStep.key -> id` lookup — resolved from the DB once, reused by every call rather than queried per request. `undefined` until the first call starts loading it, after which every caller (concurrent or not) awaits the same promise. */
private _techStepIdsLoaded: Promise<void> | undefined;
private _techStepIdByUid: Map<string, number> | undefined;
/** Same memoized-lookup shape as {@link _techStepIdsLoaded}/{@link _techStepIdByUid}, for `Utensil.key -> id` instead — a `kind: "utensil"` entity from the intent service resolves through this map, never `_techStepIdByUid`. */
private _utensilIdsLoaded: Promise<void> | undefined;
private _utensilIdByUid: Map<string, number> | undefined;
/**
* Forces the `TechStep.key -> id` lookup to load now, synchronously with
* server startup (see `server.ts`, which also retries this against a
* not-yet-reachable intent service), rather than stalling whichever
* request happens to be first to save/preview a recipe. Doesn't wait on
* `services/tech-step-intent-service` finishing its own training — that
* service is only ever considered "up" by Docker Compose/CI once it
* already is (see that service's `GET /health`), so by the time this
* runs in a real deployment it's already trained; a request racing an
* intent service that's genuinely still starting just gets an empty
* match list back (see `IntentServiceProcessResult`'s own doc comment),
* not an error.
*/
public async warmUp(): Promise<void> {
try {
await this.matchTechStepSpans("faire cuire à feu doux", "fr");
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/**
* Detects every technique `description` means, as an ordered sequence of
* matches (each carrying *where* it matched) — empty if none apply. See
* this file's doc comment for the full NER -> split -> classify
* pipeline.
*
* @param locale Which of `TECH_STEP_TRAINING_DATA`'s locales to match
* against — same "caller already knows/validated this" contract the
* old `matchTechStepSpans(description, mappings)` had via its
* pre-filtered `mappings` argument, just as an explicit parameter now
* that the training data isn't pre-filtered by the caller anymore.
*/
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
try {
await Promise.all([this._ensureTechStepIdsLoaded(), this._ensureUtensilIdsLoaded()]);
if (description.trim().length === 0) return [];
// Loaded fresh per call (once per step, see `recipe.service.ts`'s
// `matchStepsTechSteps`) rather than memoized like the id lookups
// above — same "cheap enough, and reference data can change between
// calls without a restart" posture `loadIngredientCatalog`/
// `loadUnitCatalog`'s own doc comments already describe for their
// other callers (`ingredient-matcher.ts`, `sources.service.ts`).
const [ingredientCatalog, unitCatalog] = await Promise.all([
loadIngredientCatalog(locale),
loadUnitCatalog(locale),
]);
// The intent service returns two kinds of candidate (see `kind` on
// `IntentServiceEntity`): technique mentions (its corpus-trained
// `PhraseMatcher`) and utensil mentions (its static one, see
// `utensil_vocabulary.py`). Only the former ever anchor a clause —
// `splitIntoClauses` cuts a description around *techniques*, a
// mentioned utensil doesn't introduce a clause boundary of its own,
// it just gets attributed to whichever clause its span falls inside
// (see the loop below). Its `start`/`end` are already `[start, end)`
// (matching `String.prototype.slice`), unlike node-nlp's inclusive
// `end` — no `+ 1` needed either.
const nerResult = await intentServiceClient.process(locale, description);
const candidates: TechniqueCandidate[] = nerResult.entities
.filter((entity) => entity.kind === "technique")
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
const temperatureMentions = nerResult.temperatures;
const clauses = splitIntoClauses(description, candidates);
const matches: TechStepMatch[] = [];
for (const clause of clauses) {
const uid = await this._classifyClause(description, clause, locale);
if (uid === null) continue;
const techStepId = this._techStepIdByUid?.get(uid);
// A `uid` the classifier/NER was trained on but that no longer has
// a matching `TechStep` row (e.g. training data and
// `reference-seed-data.ts` drifted apart) — skip rather than
// persist a dangling id.
if (techStepId === undefined) continue;
const span = clause.anchor ?? { start: clause.start, end: clause.end };
const ingredients = findIngredientMentions(
description.slice(clause.start, clause.end),
ingredientCatalog,
unitCatalog,
locale,
).map((mention) => ({
...mention,
start: mention.start + clause.start,
end: mention.end + clause.start,
}));
const utensils: UtensilMention[] = utensilEntities.flatMap((entity) => {
if (entity.start < clause.start || entity.end > clause.end) return [];
const utensilId = this._utensilIdByUid?.get(entity.uid);
// Same drift guard as `techStepId` above.
return utensilId === undefined
? []
: [{ utensilId, start: entity.start, end: entity.end }];
});
// Temperatures carry no id to resolve — kept as-is, just attributed
// to the clause whose span contains them (same rule as utensils).
const temperatures: TemperatureMention[] = temperatureMentions.filter(
(temp) => temp.start >= clause.start && temp.end <= clause.end,
);
matches.push({
techStepId,
start: span.start,
end: span.end,
contextStart: clause.start,
contextEnd: clause.end,
ingredients,
utensils,
temperatures,
});
}
matches.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
return matches;
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles
// it, this service layer just isn't allowed a bare `await` without a
// try/catch per the repo's convention.
throw err;
}
}
/**
* Splits `description` into clauses exactly like {@link matchTechStepSpans}
* does, but returns each clause's *raw* classification detail
* ({@link TechStepClauseClassification}) instead of the threshold-applied,
* anchor-fallback-resolved `TechStepMatch` — see that type's doc comment
* for why/who needs this. Deliberately a separate traversal rather than a
* shared refactor with `matchTechStepSpans`/`_classifyClause`: this method
* exists purely to add a new, additive read path without risking a
* behavior change to the two already-relied-on methods above.
*/
public async classifyClauses(
description: string,
locale: string,
): Promise<TechStepClauseClassification[]> {
try {
await this._ensureTechStepIdsLoaded();
if (description.trim().length === 0) return [];
const nerResult = await intentServiceClient.process(locale, description);
const candidates: TechniqueCandidate[] = nerResult.entities
.filter((entity) => entity.kind === "technique")
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
const clauses = splitIntoClauses(description, candidates);
const results: TechStepClauseClassification[] = [];
for (const clause of clauses) {
const clauseText = description.slice(clause.start, clause.end).trim();
const anchorUid = clause.anchor?.uid ?? null;
if (clauseText.length === 0) {
results.push({
clauseText,
start: clause.start,
end: clause.end,
anchorUid,
intentUid: null,
score: 0,
});
continue;
}
const result = await intentServiceClient.process(locale, clauseText);
results.push({
clauseText,
start: clause.start,
end: clause.end,
anchorUid,
intentUid: result.intent,
score: result.intent === null ? 0 : result.score,
});
}
return results;
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/**
* Convenience wrapper around {@link matchTechStepSpans} for callers that
* only care about *which* techniques matched, not where — e.g.
* `recipe-translation.ts`'s `translateRecipeSteps`, which declares a
* step's technique sequence for an imported recipe that isn't saved (and
* so has no `StepTechStep` row to persist a span into) yet.
*/
public async matchTechSteps(description: string, locale: string): Promise<number[]> {
try {
return (await this.matchTechStepSpans(description, locale)).map((match) => match.techStepId);
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/**
* Classifies one clause, returning the technique `uid` it means (or
* `null` if none applies) — the classifier's own verdict when it's
* confident enough ({@link CONFIDENCE_THRESHOLD}), otherwise the
* clause's NER anchor (if it has one) as a floor: a clearly
* keyword-anchored clause a small model merely isn't sure how to
* classify shouldn't be dropped outright, only a genuinely
* anchor-less/low-confidence one should.
*/
private async _classifyClause(
description: string,
clause: TechStepClause,
locale: string,
): Promise<string | null> {
try {
const clauseText = description.slice(clause.start, clause.end).trim();
if (clauseText.length === 0) return clause.anchor?.uid ?? null;
const result = await intentServiceClient.process(locale, clauseText);
if (result.intent !== null && result.score >= CONFIDENCE_THRESHOLD) {
return result.intent;
}
return clause.anchor?.uid ?? null;
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/**
* Resolves the `uid -> TechStep.id` lookup exactly once — memoized on
* `_techStepIdsLoaded` so a burst of concurrent calls (several steps of
* the same recipe save, awaited via the same event loop tick) all await
* the one in-flight DB query rather than each firing their own.
*/
private async _ensureTechStepIdsLoaded(): Promise<void> {
if (this._techStepIdsLoaded === undefined) {
this._techStepIdsLoaded = this._loadTechStepIds();
}
try {
await this._techStepIdsLoaded;
} catch (err) {
// A failed load must be retried by the *next* call, not leave every
// future call permanently rejecting against a stale failed promise.
this._techStepIdsLoaded = undefined;
throw err;
}
}
private async _loadTechStepIds(): Promise<void> {
try {
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/** `Utensil.key -> id` counterpart of {@link _ensureTechStepIdsLoaded} — same memoize-once-retry-on-failure shape. */
private async _ensureUtensilIdsLoaded(): Promise<void> {
if (this._utensilIdsLoaded === undefined) {
this._utensilIdsLoaded = this._loadUtensilIds();
}
try {
await this._utensilIdsLoaded;
} catch (err) {
this._utensilIdsLoaded = undefined;
throw err;
}
}
private async _loadUtensilIds(): Promise<void> {
try {
const utensils = await prisma.utensil.findMany({ select: { id: true, key: true } });
this._utensilIdByUid = new Map(utensils.map((utensil) => [utensil.key, utensil.id]));
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
}
/** Single shared instance — every caller reuses the one memoized `TechStep.key -> id` lookup rather than re-querying the DB. The actual model training (expensive — a couple of minutes, both locales combined) happens entirely inside `services/tech-step-intent-service`'s own startup, not here — see that service's `_TRAINING_ITERATIONS`. */
export const techStepClassifier = new TechStepClassifierService();

View file

@ -1,64 +0,0 @@
import type { Prisma } from "@prisma/client";
import type { TechStepMatch } from "./tech-step-matcher.js";
/**
* Turns one step's {@link TechStepMatch}es into the Prisma nested-`create`
* payload for its `techSteps` relation — the technique row **and** all three
* of its clause-metadata child sets (`ingredients` / `utensils` /
* `temperatures`).
*
* The single source of truth for that shape. It used to be written out by
* hand in three places — `recipe.service.ts`'s `createRecipeInternal` and
* `updateRecipe`, plus `src/scripts/backfill-tech-steps.ts` — and they had
* drifted: only the create path wrote the metadata children, so **every
* recipe resaved through the UI, and every recipe the backfill touched,
* silently lost every ingredient / utensil / temperature the classifier had
* attributed to its steps**. That left `cooking-optimizer.ts`'s
* temperature-aware `preheat` grouping (the "don't preheat to 150 °C and
* 200 °C at once" note) with no data to work from on any recipe that wasn't
* a pristine, never-edited import. Building the payload once, here, is what
* stops the three call sites diverging again.
*
* `order` is each match's index in `matches` (0-based). Callers pass matches
* in reading order already; a caller that needs a different base (the
* backfill, which offsets past untouched `"manual"` rows) overrides `order`
* on the returned objects before handing them to Prisma.
*/
export function toStepTechStepCreateInputs(
matches: TechStepMatch[],
): Prisma.StepTechStepUncheckedCreateWithoutStepInput[] {
return matches.map((match, order) => ({
techStepId: match.techStepId,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
order,
ingredients: {
create: match.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
start: ingredient.start,
end: ingredient.end,
})),
},
utensils: {
create: match.utensils.map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
temperatures: {
create: match.temperatures.map((temperature) => ({
start: temperature.start,
end: temperature.end,
celsius: temperature.celsius,
gasMark: temperature.gasMark,
qualitative: temperature.qualitative,
raw: temperature.raw,
})),
},
}));
}

View file

@ -16,9 +16,9 @@
* user has already brought in as if they were new. A separate, pure * user has already brought in as if they were new. A separate, pure
* step rather than something `list()` itself does: an adapter only * step rather than something `list()` itself does: an adapter only
* knows its source, never our database — same reasoning as * knows its source, never our database — same reasoning as
* `tech-step-matcher.ts`'s split between pure `splitIntoClauses` and * `tech-step-matcher.ts`'s split between pure `matchTechStep` and its
* its DB/model-touching `TechStepClassifierService`. Whichever future * DB-touching `loadTechStepMappingRules`. Whichever future layer
* layer queries "which externalIds from this source do we already have" * queries "which externalIds from this source do we already have"
* (not yet decided — it needs a place to persist that link, * (not yet decided — it needs a place to persist that link,
* see {@link RecipeSourceListItem.externalId}) calls this to annotate * see {@link RecipeSourceListItem.externalId}) calls this to annotate
* the page before returning it. * the page before returning it.
@ -144,9 +144,6 @@ export interface ParsedRecipe {
* const myAdapter: RecipeSourceAdapter<{ html: string }> = { * const myAdapter: RecipeSourceAdapter<{ html: string }> = {
* key: "someRecipeSite", * key: "someRecipeSite",
* name: "Some Recipe Site", * name: "Some Recipe Site",
* official: false,
* iconUrl: "https://somerecipesite.example/favicon.svg",
* locale: "fr",
* async list(params) { ... }, * async list(params) { ... },
* async fetchDetail(externalId) { ... }, * async fetchDetail(externalId) { ... },
* parse(raw) { ... }, * parse(raw) { ... },
@ -159,29 +156,6 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
key: string; key: string;
/** Human-readable name, for display in a source picker. */ /** Human-readable name, for display in a source picker. */
name: string; name: string;
/**
* Whether this source is an official API (the site/publisher itself
* provides structured recipe data) versus unofficial web scraping (we
* parse HTML the site never committed to a stable shape for) — surfaced
* to households (`Source.official`, synced via `syncRecipeSources`) so
* they can tell the two apart when deciding which sources to enable
* (see `HouseSource`, schema.prisma). No default on purpose: every
* adapter author has to consciously pick one rather than silently
* inheriting a guess.
*/
official: boolean;
/** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */
iconUrl: string | null;
/**
* Language of the text this source produces (`ParsedRecipe.description`/
* `steps[].description`/`ingredients[].name`) — e.g. `"en"` for
* TheMealDB. Not a user preference: the language the source's own
* content is actually written in, regardless of who's browsing it.
* Determines which trained-classifier/ingredient-label locale
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
* recipes against when previewing/importing one.
*/
locale: string;
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>; list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
fetchDetail(externalId: string): Promise<TRawDetail>; fetchDetail(externalId: string): Promise<TRawDetail>;
parse(raw: TRawDetail): ParsedRecipe; parse(raw: TRawDetail): ParsedRecipe;

View file

@ -1,20 +0,0 @@
import type { AdminUserView } from "@batch-cooking/shared";
import type { AdminUser } from "@prisma/client";
/**
* Shapes a Prisma `AdminUser` into the {@link AdminUserView} sent to the
* admin client — drops `passwordHash` **and** `tokenVersion` (an internal
* invalidation counter the client never needs, unlike `SafeUserProfile`
* which does expose it), and serializes the two dates to ISO strings. The
* one place this security-relevant stripping happens, same role as
* `toSafeProfile` (`lib/safe-profile.ts`).
*/
export function toSafeAdmin(admin: AdminUser): AdminUserView {
return {
id: admin.id,
email: admin.email,
name: admin.name,
createdAt: admin.createdAt.toISOString(),
lastLoginAt: admin.lastLoginAt === null ? null : admin.lastLoginAt.toISOString(),
};
}

View file

@ -1,40 +0,0 @@
import { errorHandlerService } from "@batch-cooking/error-tools";
import type { NextFunction, Request, Response } from "express";
import { logger } from "../lib/logger.service.js";
/**
* Logs every error that reaches Express's error-handling chain, then
* passes it straight on (`next(err)`) to the real error-to-response
* middleware (`createErrorMiddleware`, `@batch-cooking/express-tools`) —
* mounted immediately after this one in `app.ts`. Reuses
* `errorHandlerService.handle()` (`@batch-cooking/error-tools`) just to
* classify the error for logging purposes (its `status`/`body.code`) —
* `.handle()` is pure/stateless, so calling it here and then again in
* `createErrorMiddleware` right after is harmless, and it's the one place
* that already knows a bare `ZodError` maps to `400 VALIDATION_ERROR`, an
* `HttpError` maps to its own `status`/`code`, and anything else is a
* `500`. Doesn't build the actual response itself — that's still
* `createErrorMiddleware`'s job.
*
* A resulting `4xx` is routine, expected operation (a validation failure,
* a 404, an unauthenticated request) — logged at `warn`, not `error`, so a
* genuine `5xx` (an unhandled exception, a bug) stands out instead of
* being buried under normal client mistakes.
*/
export function errorLogger(err: unknown, req: Request, _res: Response, next: NextFunction): void {
const meta = { method: req.method, path: req.originalUrl };
const { status, body } = errorHandlerService.handle(err);
if (status >= 500) {
logger.error(err instanceof Error ? err.message : body.message, {
...meta,
status,
code: body.code,
stack: err instanceof Error ? err.stack : undefined,
});
} else {
logger.warn(body.message, { ...meta, status, code: body.code });
}
next(err);
}

View file

@ -1,45 +0,0 @@
import type { NextFunction, Request, Response } from "express";
import { logger } from "../lib/logger.service.js";
/**
* Logs one line per request once it finishes — method, path, status code,
* and duration. Mounted first in `app.ts` (before every route, and before
* the error handler) so it wraps the whole request/response cycle,
* including requests that end in a 404 or an error response.
*
* Listens on `res`'s `"finish"` event rather than wrapping `next()` in a
* `try`/`finally`: this middleware calls `next()` immediately and returns,
* so it never itself sits on the stack waiting for the rest of the
* pipeline to resolve — `"finish"` fires once Express has actually flushed
* the response, whichever handler (or the error middleware) produced it.
*
* `4xx`/`5xx` responses log at `warn`/`error` respectively (status alone
* decides the level — this middleware has no idea *why* a request failed,
* just that it did); everything else logs at `info`. A route's own
* handler/the error middleware may log more detail about *why* separately
* (see `error-middleware` wiring in `app.ts`) — this line is just the
* "a request happened, here's the outcome" operational trace.
*/
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
const startedAt = process.hrtime.bigint();
res.on("finish", () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const meta = {
method: req.method,
path: req.originalUrl,
status: res.statusCode,
durationMs: Math.round(durationMs * 100) / 100,
};
if (res.statusCode >= 500) {
logger.error("Request completed", meta);
} else if (res.statusCode >= 400) {
logger.warn("Request completed", meta);
} else {
logger.info("Request completed", meta);
}
});
next();
}

View file

@ -1,43 +0,0 @@
import { parseCookie } from "cookie";
import type { Socket } from "socket.io";
import { env } from "../config/env.js";
import { resolveAdminFromToken } from "./require-admin.js";
/**
* `socket.io` handshake middleware guarding the admin WebSocket surface —
* the connection-level equivalent of {@link requireAdmin} (`require-
* admin.ts`). A `socket.io` handshake is a plain HTTP request that never
* runs through Express's own middleware stack (`cookie-parser` included),
* so the raw `Cookie` header has to be parsed by hand here with the
* `cookie` package (the same one `cookie-parser` itself wraps).
*
* Registered via `io.use(requireAdminSocket)` — `socket.io` calls every
* `io.use` middleware once per connection attempt, before `"connection"`
* fires, exactly like Express's per-request middleware chain. Calling
* `next()` with no argument admits the connection; calling it with an
* `Error` rejects it, which the client sees as a `connect_error` event
* (`socket.io-client` never even fires `"connect"` for it).
*
* On success, stashes the resolved admin on `socket.data.adminUser` —
* `socket.io`'s per-connection storage bag, the WebSocket equivalent of
* `res.locals` — for any handler that ends up needing it later.
*/
export async function requireAdminSocket(
socket: Socket,
next: (err?: Error) => void,
): Promise<void> {
try {
const cookieHeader = socket.handshake.headers.cookie;
const token = cookieHeader ? parseCookie(cookieHeader)[env.ADMIN_COOKIE_NAME] : undefined;
if (typeof token !== "string") {
throw new Error("Not authenticated");
}
socket.data.adminUser = await resolveAdminFromToken(token);
next();
} catch {
// Covers everything resolveAdminFromToken can throw, plus a missing
// cookie — same "never distinguish the reason" posture as requireAdmin.
next(new Error("Not authenticated"));
}
}

View file

@ -1,88 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import { type AdminUserView, ErrorCode } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { verifyAdminToken } from "../lib/admin-jwt.js";
import { toSafeAdmin } from "../lib/safe-admin.js";
/**
* Shape of `res.locals` once {@link requireAdmin} has run successfully.
* Type a handler's response as `Response<unknown, AdminLocals>` to read
* `res.locals.adminUser` fully typed, no cast — same `res.locals` (not
* global `Request` augmentation) approach as {@link AuthLocals}
* (`require-auth.ts`).
*/
export interface AdminLocals {
/** The authenticated admin operator, resolved from the admin session cookie's JWT. */
adminUser: AdminUserView;
}
/**
* Verifies an admin session JWT and resolves the `AdminUser` it belongs
* to — the transport-agnostic core both {@link requireAdmin} (HTTP) and
* `requireAdminSocket` (`require-admin-socket.ts`, the WebSocket handshake
* equivalent) build on; they only differ in *where* they read the raw
* cookie value from (`req.cookies`, already parsed by `cookie-parser`, vs
* a `socket.io` handshake's raw `Cookie` header, which isn't). Kept here
* rather than in `lib/admin-jwt.ts`, since it also does the DB
* `tokenVersion` re-check that file deliberately doesn't know about.
*
* @throws {Error} for any failure — malformed/expired JWT, unknown admin,
* stale `tokenVersion`, or no `ADMIN_JWT_SECRET` configured (from
* {@link verifyAdminToken}). Always the same generic message; callers
* map it to their own transport's "unauthenticated" shape.
*/
export async function resolveAdminFromToken(token: string): Promise<AdminUserView> {
const payload = verifyAdminToken(token);
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
throw new Error("Not authenticated");
}
return toSafeAdmin(admin);
}
/**
* Express middleware guarding every `/admin/api/*` route — the operations
* UI (`apps/web`'s `/admin/*` pages) authenticating as an `AdminUser`.
* Reads the admin session cookie (`ADMIN_COOKIE_NAME`, deliberately
* **not** the same cookie as end-user sessions) and resolves it via
* {@link resolveAdminFromToken}.
*
* A completely separate mechanism from {@link requireAuth}, not layered on
* it: an end-user session token and an admin session token are never
* interchangeable in either direction.
*
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
* that doesn't run the admin app) makes {@link resolveAdminFromToken}
* throw, so every request is rejected rather than the surface left open —
* same posture as `requireInternalWorker`.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
* cookie or anything {@link resolveAdminFromToken} rejects on. Never
* distinguishes the reason.
*/
export async function requireAdmin(
req: Request,
res: Response<unknown, AdminLocals>,
next: NextFunction,
) {
try {
const token = req.cookies?.[env.ADMIN_COOKIE_NAME];
if (typeof token !== "string") {
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
res.locals.adminUser = await resolveAdminFromToken(token);
next();
} catch (err) {
if (err instanceof HttpError) {
next(err);
} else {
// Covers everything resolveAdminFromToken can throw.
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
}
}
}

View file

@ -13,34 +13,16 @@ import { toSafeProfile } from "../lib/safe-profile.js";
* typed, no cast needed. * typed, no cast needed.
*/ */
export interface AuthLocals { export interface AuthLocals {
/** The authenticated profile, resolved from the session cookie's JWT (or an `Authorization: Bearer` one, for cookie-less clients). */ /** The authenticated profile, resolved from the session cookie's JWT. */
userProfile: SafeUserProfile; userProfile: SafeUserProfile;
} }
/**
* Pulls the raw JWT out of an `Authorization: Bearer <jwt>` header, or
* returns `null` if it's absent or malformed. This is the fallback path for
* clients that can't rely on the `session` cookie — a Capacitor WebView,
* whose origin never receives a `SameSite=Lax` cookie (see `AuthResponse`
* in packages/shared). The cookie remains the primary mechanism; this only
* kicks in when the cookie isn't there.
*/
function readBearerToken(header: string | undefined): string | null {
if (!header?.startsWith("Bearer ")) {
return null;
}
const token = header.slice("Bearer ".length).trim();
return token.length > 0 ? token : null;
}
/** /**
* Express middleware guarding routes that require an authenticated * Express middleware guarding routes that require an authenticated
* profile. Reads the session cookie (falling back to an `Authorization: * profile. Reads the session cookie, verifies the JWT, and re-checks
* Bearer <jwt>` header when there's no cookie — see {@link readBearerToken}), * `tokenVersion` against the database — so a stateless JWT can still be
* verifies the JWT, and re-checks `tokenVersion` against the database — so a * invalidated server-side (e.g. on password change / logout-everywhere,
* stateless JWT can still be invalidated server-side (e.g. on password * once that feature exists) despite carrying no server-side session.
* change / logout-everywhere, once that feature exists) despite carrying no
* server-side session.
* *
* On success, attaches the resolved profile to `res.locals.userProfile` * On success, attaches the resolved profile to `res.locals.userProfile`
* (typed via {@link AuthLocals}) for downstream handlers to use. * (typed via {@link AuthLocals}) for downstream handlers to use.
@ -51,9 +33,9 @@ function readBearerToken(header: string | undefined): string | null {
* project-wide ambient augmentation silently changing every `Request` in * project-wide ambient augmentation silently changing every `Request` in
* the codebase, whether or not it went through this middleware. * the codebase, whether or not it went through this middleware.
* *
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — no cookie * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
* and no usable Bearer header, malformed/expired JWT, unknown profile, or * cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
* stale tokenVersion. Never distinguishes the reason to the client. * Never distinguishes the reason to the client.
*/ */
export async function requireAuth( export async function requireAuth(
req: Request, req: Request,
@ -61,11 +43,7 @@ export async function requireAuth(
next: NextFunction, next: NextFunction,
) { ) {
try { try {
const cookieToken = req.cookies?.[env.AUTH_COOKIE_NAME]; const token = req.cookies?.[env.AUTH_COOKIE_NAME];
// Cookie first (the browser's mechanism, unchanged); `Authorization:
// Bearer` as a fallback for cookie-less clients — see readBearerToken.
const token =
typeof cookieToken === "string" ? cookieToken : readBearerToken(req.headers.authorization);
if (typeof token !== "string") { if (typeof token !== "string") {
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
} }

View file

@ -1,50 +0,0 @@
import { timingSafeEqual } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";
/** Header `services/tech-step-llm-worker` sends its shared secret on. Not `Authorization`/a bearer scheme — this isn't a user session, just one internal caller authenticating to another, same "one flat shared secret" shape as e.g. a webhook signing header. */
const INTERNAL_WORKER_SECRET_HEADER = "x-internal-worker-secret";
/**
* Express middleware guarding `/internal/tech-steps/*` — the surface
* `services/tech-step-llm-worker` (a process outside this monorepo, no
* Prisma access of its own, see that service's own README) reads
* low-confidence NLP clauses and pending `StepTechStepCorrection`s from,
* and posts `TechStepTrainingSuggestion`s back to. Never reachable by an
* end user's session cookie — deliberately a *different* auth mechanism
* than {@link requireAuth} (`require-auth.ts`), not layered on top of it,
* since the worker has no `UserProfile`/session of its own to authenticate
* as.
*
* Fails closed: an unset `INTERNAL_WORKER_SECRET` (the default in any
* environment that doesn't run the worker, see `config/env.ts`) rejects
* every request rather than leaving the surface open, same posture as a
* misconfigured `JWT_SECRET` would if it had a working fallback.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` if the header is missing,
* wrong, or the server has no secret configured at all — never
* distinguishes the reason, same posture as {@link requireAuth}.
*/
export function requireInternalWorker(req: Request, _res: Response, next: NextFunction): void {
const provided = req.header(INTERNAL_WORKER_SECRET_HEADER);
if (env.INTERNAL_WORKER_SECRET === undefined || provided === undefined) {
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
return;
}
// `timingSafeEqual` throws on mismatched buffer lengths rather than
// returning `false` — checked separately first. A length mismatch alone
// already means "not equal", so this loses no timing-attack protection
// (an attacker learns nothing beyond what a differing length itself
// already reveals, no different from `!==` on the common case where the
// secret's real length isn't a secret worth protecting).
const expected = Buffer.from(env.INTERNAL_WORKER_SECRET);
const actual = Buffer.from(provided);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
return;
}
next();
}

View file

@ -1,51 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { adminLoginSchema } from "@batch-cooking/shared";
import { type CookieOptions, type Response, Router } from "express";
import { env } from "../../config/env.js";
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
import { adminLogin } from "./admin-auth.service.js";
/** Router mounted at `/admin/api/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
export const adminAuthRouter = Router();
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Cookie options for the admin session — same shape as `auth.routes.ts`'s
* end-user cookie (httpOnly, `Secure` in production unless `COOKIE_SECURE`
* overrides, `SameSite=Lax`), just written under {@link env.ADMIN_COOKIE_NAME}
* so the two sessions never collide in one browser.
*/
const adminCookieOptions: CookieOptions = {
httpOnly: true,
secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SEVEN_DAYS_MS,
};
// `res.clearCookie` sets its own expiry — passing `maxAge` alongside is
// deprecated as of Express 4.20, so the logout route reuses the options
// minus that one field (same trick as `auth.routes.ts`).
const { maxAge: _maxAge, ...clearAdminCookieOptions } = adminCookieOptions;
/** Verifies admin credentials and starts an admin session. */
adminAuthRouter.post(
"/login",
wrapAsyncHandler(async (req, res) => {
const input = adminLoginSchema.parse(req.body);
const { admin, token } = await adminLogin(input);
res.cookie(env.ADMIN_COOKIE_NAME, token, adminCookieOptions);
res.status(200).json(admin);
}),
);
/** Ends the admin session by clearing the cookie. Stateless JWT — nothing to revoke server-side beyond bumping `tokenVersion` (no UI for that yet). */
adminAuthRouter.post("/logout", (_req, res) => {
res.clearCookie(env.ADMIN_COOKIE_NAME, clearAdminCookieOptions);
res.status(204).end();
});
/** Returns the currently authenticated admin. Behind `requireAdmin` — 401s if there's no valid admin session. */
adminAuthRouter.get("/me", requireAdmin, (_req, res: Response<unknown, AdminLocals>) => {
res.status(200).json(res.locals.adminUser);
});

View file

@ -1,59 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import { type AdminLoginInput, type AdminUserView, ErrorCode } from "@batch-cooking/shared";
import argon2 from "argon2";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
import { signAdminToken } from "../../lib/admin-jwt.js";
import { toSafeAdmin } from "../../lib/safe-admin.js";
/** Result of a successful admin login: the safe admin view plus the signed admin session JWT to set as a cookie. */
interface AdminAuthResult {
admin: AdminUserView;
token: string;
}
// Same reasoning as `auth.service.ts`'s `hashOptions`: argon2's real
// defaults are deliberately expensive; the test suite hashes/verifies
// against throwaway data many times per run, so a cheaper cost keeps it
// fast without weakening anything real. Never applies outside NODE_ENV=test.
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
/** Exposed so `src/scripts/create-admin.ts` hashes exactly the same way `login` verifies. */
export function hashAdminPassword(password: string): Promise<string> {
return argon2.hash(password, hashOptions);
}
/**
* Verifies an admin operator's credentials, stamps `lastLoginAt`, and
* issues a fresh admin session token.
*
* @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email
* or a wrong password — deliberately indistinguishable, same reasoning as
* `auth.service.ts`'s `login`.
*/
export async function adminLogin(input: AdminLoginInput): Promise<AdminAuthResult> {
try {
const admin = await prisma.adminUser.findUnique({ where: { email: input.email } });
if (!admin || !(await argon2.verify(admin.passwordHash, input.password))) {
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
}
const updated = await prisma.adminUser.update({
where: { id: admin.id },
data: { lastLoginAt: new Date() },
});
const token = signAdminToken({
adminUserId: updated.id,
tokenVersion: updated.tokenVersion,
});
return { admin: toSafeAdmin(updated), token };
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

@ -1,80 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
listPlaceholdersQuerySchema,
listTechStepPlaceholdersQuerySchema,
markPlaceholdersReviewedSchema,
markTechStepPlaceholdersReviewedSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
listPlaceholderGroups,
markPlaceholdersReviewed,
pruneOrphanPlaceholders,
} from "./admin-catalog.service.js";
import {
listTechStepPlaceholders,
markTechStepPlaceholdersReviewed,
} from "./admin-tech-step-catalog.service.js";
/**
* Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
* behind {@link requireAdmin}. Surfaces the off-catalog "placeholders" users
* typed when the seeded catalog fell short — free-text ingredients on a
* recipe line, and free-text techniques while correcting a step (issue #130)
* — so a maintainer can see what's missing and mark gaps as handled.
*/
export const adminCatalogRouter = Router();
adminCatalogRouter.get(
"/placeholders",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res.status(200).json(await listPlaceholderGroups(listPlaceholdersQuerySchema.parse(req.query)));
}),
);
adminCatalogRouter.patch(
"/placeholders/mark-reviewed",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(await markPlaceholdersReviewed(markPlaceholdersReviewedSchema.parse(req.body)));
}),
);
/** Deletes placeholder rows no recipe references any more — see {@link pruneOrphanPlaceholders}. */
adminCatalogRouter.post(
"/placeholders/prune-orphans",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await pruneOrphanPlaceholders());
}),
);
// --- Missing techniques (issue #130) --------------------------------------
adminCatalogRouter.get(
"/tech-step-placeholders",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(await listTechStepPlaceholders(listTechStepPlaceholdersQuerySchema.parse(req.query)));
}),
);
adminCatalogRouter.patch(
"/tech-step-placeholders/mark-reviewed",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(
await markTechStepPlaceholdersReviewed(
markTechStepPlaceholdersReviewedSchema.parse(req.body),
),
);
}),
);

View file

@ -1,156 +0,0 @@
import type {
CatalogPlaceholderGroupView,
ListPlaceholdersQuery,
MarkPlaceholdersReviewedInput,
PruneOrphansResultView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/**
* Grouping key for two placeholder spellings that mean the same missing
* ingredient — lower-cased, accent-stripped, punctuation-neutralised,
* whitespace-collapsed. "Piment d'Espelette", "piment d espelette" and
* "PIMENT D'ESPELETTE" all normalise to `"piment d espelette"`, so the
* admin view shows one gap, not three. Pure (no DB) — unit-tested on its
* own, same split convention as `matchXxx()` vs `loadXxx()` elsewhere.
*/
export function normalizePlaceholderName(raw: string): string {
return raw
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
.trim()
.replace(/\s+/g, " ");
}
/** Prisma `include` for the placeholder query — up to a few `RecipeIngredient` links per row, each with just enough of its recipe for the "seen in…" preview and the distinct-recipe count. */
const placeholderInclude = {
recipes: {
take: 5,
include: { recipe: { select: { id: true, name: true } } },
},
} as const;
/**
* Every placeholder `Ingredient` (see `Ingredient.isPlaceholder` in
* schema.prisma), grouped by {@link normalizePlaceholderName} so a
* maintainer reviews one row per *missing ingredient* rather than one per
* recipe line. Ordered by recipe impact (most-requested gap first), then
* name.
*
* `query.reviewed` selects which side to show: omitted / `"false"` drops
* groups whose every row has already been triaged (`reviewedAt` set) — the
* default working list; `"true"` keeps only those fully-triaged groups.
*/
export async function listPlaceholderGroups(
query: ListPlaceholdersQuery,
): Promise<CatalogPlaceholderGroupView[]> {
try {
const rows = await prisma.ingredient.findMany({
where: { isPlaceholder: true },
include: placeholderInclude,
orderBy: { createdAt: "asc" },
});
/** Accumulator per normalized name — mutated in the loop, shaped into the view after. */
interface GroupAccumulator {
normalizedName: string;
displayNames: Set<string>;
ingredientIds: number[];
recipeIds: Set<number>;
sampleRecipes: Map<number, string>;
firstSeenAt: Date | null;
allReviewed: boolean;
}
const groups = new Map<string, GroupAccumulator>();
for (const row of rows) {
const name = row.displayName ?? "";
const normalizedName = normalizePlaceholderName(name);
let group = groups.get(normalizedName);
if (!group) {
group = {
normalizedName,
displayNames: new Set(),
ingredientIds: [],
recipeIds: new Set(),
sampleRecipes: new Map(),
firstSeenAt: null,
allReviewed: true,
};
groups.set(normalizedName, group);
}
if (name.length > 0) group.displayNames.add(name);
group.ingredientIds.push(row.id);
for (const link of row.recipes) {
group.recipeIds.add(link.recipe.id);
if (group.sampleRecipes.size < 5) group.sampleRecipes.set(link.recipe.id, link.recipe.name);
}
if (row.createdAt && (group.firstSeenAt === null || row.createdAt < group.firstSeenAt)) {
group.firstSeenAt = row.createdAt;
}
if (row.reviewedAt === null) group.allReviewed = false;
}
// `reviewed=true` → the archive of handled gaps; anything else → the
// working list of gaps still to look at.
const wantReviewed = query.reviewed === "true";
return [...groups.values()]
.filter((group) => group.allReviewed === wantReviewed)
.map((group) => ({
normalizedName: group.normalizedName,
displayNames: [...group.displayNames].sort((a, b) => a.localeCompare(b, "fr")),
ingredientIds: group.ingredientIds,
recipeCount: group.recipeIds.size,
sampleRecipes: [...group.sampleRecipes.entries()].map(([id, name]) => ({ id, name })),
firstSeenAt: group.firstSeenAt?.toISOString() ?? null,
allReviewed: group.allReviewed,
}))
.sort(
(a, b) => b.recipeCount - a.recipeCount || a.normalizedName.localeCompare(b.normalizedName),
);
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Stamps `reviewedAt` on the given placeholder ids — a maintainer has seen
* this gap (and, if it warranted it, added the real catalog entry by hand;
* this endpoint never touches the catalog itself). Scoped to
* `isPlaceholder: true` so a stray real id is a silent no-op, not a
* mislabel. Returns how many rows were actually stamped.
*/
export async function markPlaceholdersReviewed(
input: MarkPlaceholdersReviewedInput,
): Promise<{ reviewed: number }> {
try {
const { count } = await prisma.ingredient.updateMany({
where: { id: { in: input.ingredientIds }, isPlaceholder: true },
data: { reviewedAt: new Date() },
});
return { reviewed: count };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Deletes placeholder rows no recipe references any more — the debris left
* when a recipe edit drops a placeholder line (the `RecipeIngredient` row
* goes, the `Ingredient` row doesn't). A manual GC (button in the admin
* catalog view, or `scripts/prune-orphan-placeholders.ts`) rather than a
* cascade: a placeholder is still evidence of a catalog gap even with no
* live recipe, so dropping it is a deliberate call, not automatic.
*/
export async function pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
try {
const { count } = await prisma.ingredient.deleteMany({
where: { isPlaceholder: true, recipes: { none: {} } },
});
return { deleted: count };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -1,59 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
createForgejoIssueSchema,
ErrorCode,
listContactMessagesQuerySchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
createForgejoIssueFromMessage,
getForgejoStatus,
listContactMessages,
} from "./admin-contact.service.js";
/**
* Router mounted at `/admin/api/contact` (via `admin.routes.ts`) — behind
* {@link requireAdmin}. Backs the `/admin/messages` page: the read-only list
* of contact-form messages (issue #80), plus "create a Forgejo issue from
* this message" (issue #81). Still no message-mutation routes — the message
* itself stays read-only.
*/
export const adminContactRouter = Router();
adminContactRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(await listContactMessages(listContactMessagesQuerySchema.parse(req.query)));
}),
);
// Static path — declared before `/:id/...` so it's never parsed as an id.
adminContactRouter.get(
"/forgejo/status",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(getForgejoStatus());
}),
);
adminContactRouter.post(
"/:id/forgejo-issue",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a valid contact message id: ${req.params.id}`,
);
}
const input = createForgejoIssueSchema.parse(req.body);
res.status(200).json(await createForgejoIssueFromMessage(id, input));
}),
);

View file

@ -1,133 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type ContactMessageAdminView,
type ContactMessageType,
type CreateForgejoIssueInput,
ErrorCode,
type ForgejoIntegrationStatus,
type ListContactMessagesQuery,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { forgejo } from "../../lib/forgejo/forgejo.service.js";
/** How many messages `listContactMessages` returns per call — the page is a triage view, not an export (same cap/rationale as `CORRECTIONS_PAGE_SIZE`). */
const CONTACT_MESSAGES_PAGE_SIZE = 200;
const contactMessageInclude = {
author: { select: { id: true, firstName: true, lastName: true, email: true } },
} as const;
type ContactMessageRow = Awaited<
ReturnType<
typeof prisma.contactMessage.findFirstOrThrow<{ include: typeof contactMessageInclude }>
>
>;
/** Shapes one Prisma `ContactMessage` row (with {@link contactMessageInclude}) into its admin view. */
function toContactMessageAdminView(row: ContactMessageRow): ContactMessageAdminView {
return {
id: row.id,
// Safe cast: `POST /contact` validates `type` against
// `CONTACT_MESSAGE_TYPES` before insert, so the column only ever holds
// one of those values.
type: row.type as ContactMessageType,
message: row.message,
pageUrl: row.pageUrl,
userAgent: row.userAgent,
appVersion: row.appVersion,
createdAt: row.createdAt.toISOString(),
// `null` when the sender's account was deleted after sending
// (`authorId` is `onDelete: SetNull`).
author: row.author,
forgejoIssueNumber: row.forgejoIssueNumber,
forgejoIssueUrl: row.forgejoIssueUrl,
};
}
/**
* Every `ContactMessage` for the admin read view (`/admin/messages`, issue
* #80) — newest first, capped at {@link CONTACT_MESSAGES_PAGE_SIZE}, with an
* optional single-`type` filter. Read-only: this first version has no
* triage workflow (status / assignment / notes), so there's no update
* counterpart.
*/
export async function listContactMessages(
query: ListContactMessagesQuery,
): Promise<ContactMessageAdminView[]> {
try {
const rows = await prisma.contactMessage.findMany({
where: query.type ? { type: query.type } : {},
orderBy: { createdAt: "desc" },
take: CONTACT_MESSAGES_PAGE_SIZE,
include: contactMessageInclude,
});
return rows.map(toContactMessageAdminView);
} catch (err) {
// Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch.
throw err;
}
}
/** Whether the "create a Forgejo issue from a message" feature is usable (issue #81) — drives whether the admin UI shows the button. */
export function getForgejoStatus(): ForgejoIntegrationStatus {
return { enabled: forgejo.isConfigured() };
}
/**
* Creates a Forgejo issue from contact message `messageId` and records the
* link on the row (issue #81). The `title`/`body` come from the admin's
* pre-filled, edited modal.
*
* Order matters: every precondition is checked before Forgejo is called,
* and the row is only touched *after* Forgejo succeeds — a failed call
* leaves no partial state (no linked issue, button still shown).
*
* @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` (env not set),
* `404 CONTACT_MESSAGE_NOT_FOUND`, `409 CONTACT_MESSAGE_ALREADY_LINKED`
* (already has an issue), or `502 FORGEJO_REQUEST_FAILED` (the Forgejo
* call failed — DB untouched).
*/
export async function createForgejoIssueFromMessage(
messageId: number,
input: CreateForgejoIssueInput,
): Promise<ContactMessageAdminView> {
try {
if (!forgejo.isConfigured()) {
throw new HttpError(
503,
ErrorCode.FORGEJO_NOT_CONFIGURED,
"Forgejo integration is not configured",
);
}
const existing = await prisma.contactMessage.findUnique({ where: { id: messageId } });
if (!existing) {
throw new HttpError(
404,
ErrorCode.CONTACT_MESSAGE_NOT_FOUND,
`Contact message ${messageId} not found`,
);
}
if (existing.forgejoIssueNumber !== null) {
throw new HttpError(
409,
ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED,
`Contact message ${messageId} already links to Forgejo issue #${existing.forgejoIssueNumber}`,
);
}
// Throws `502 FORGEJO_REQUEST_FAILED` on any failure — propagated below,
// before the row is touched.
const issue = await forgejo.createIssue({ title: input.title, body: input.body });
const updated = await prisma.contactMessage.update({
where: { id: messageId },
data: { forgejoIssueNumber: issue.number, forgejoIssueUrl: issue.htmlUrl },
include: contactMessageInclude,
});
return toContactMessageAdminView(updated);
} catch (err) {
// Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch.
throw err;
}
}

View file

@ -1,22 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { getMetricsSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import { getMetrics } from "./admin-metrics.service.js";
/** Router mounted at `/admin/api/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */
export const adminMetricsRouter = Router();
/**
* Returns the admin dashboard's usage metrics — a `snapshot` of current
* totals plus `?days=` (7–365, default 30) days of daily time series (see
* {@link getMetrics}). Read-only; no side effects.
*/
adminMetricsRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const { days } = getMetricsSchema.parse(req.query);
res.status(200).json(await getMetrics(days));
}),
);

View file

@ -1,242 +0,0 @@
import type {
MetricsBreakdownRow,
MetricsEventSeries,
MetricsSnapshotView,
MetricsTimeBucket,
MetricsView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/** UTC `YYYY-MM-DD` for a `Date` — the bucket key used by {@link bucketByDay}. */
function utcDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** Start-of-day (UTC) `Date` that is `daysAgo` days before `from`. */
function startOfUtcDay(from: Date, daysAgo: number): Date {
return new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate() - daysAgo));
}
/**
* Buckets `dates` into `days` consecutive daily counts starting at `since`
* (a start-of-UTC-day `Date`). Every day in the window is present, days
* with no matching date carry `count: 0`. Pure/synchronous — factored out
* so the bucketing is unit-testable without a database, same split as
* `aggregateShoppingList`.
*/
export function bucketByDay(dates: Date[], since: Date, days: number): MetricsTimeBucket[] {
const counts = new Map<string, number>();
for (let i = 0; i < days; i++) {
const day = new Date(since.getTime() + i * 86_400_000);
counts.set(utcDayKey(day), 0);
}
for (const date of dates) {
const key = utcDayKey(date);
const current = counts.get(key);
if (current !== undefined) counts.set(key, current + 1);
}
return [...counts.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));
}
/** Shapes a Prisma `groupBy ... _count` result into the frontend's `{ key, label, count }` rows, sorted by count desc. */
function toBreakdown(
rows: { key: string | null; count: number }[],
labelFor: (key: string) => string = (key) => key,
): MetricsBreakdownRow[] {
return rows
.map(({ key, count }) => {
const resolved = key ?? "unknown";
return { key: resolved, label: labelFor(resolved), count };
})
.sort((a, b) => b.count - a.count);
}
/** Every point-in-time `COUNT` for the KPI tiles — see {@link MetricsSnapshotView}. */
async function getSnapshot(): Promise<MetricsSnapshotView> {
try {
const [
admins,
users,
households,
activeHouseholdGroups,
recipes,
recipesManual,
recipesImported,
recipeBySourceGroups,
sources,
plannings,
planningItems,
steps,
detectedTechniques,
favorites,
corrections,
correctionsUnconsumed,
correctionsRemoval,
trainingSuggestions,
suggestionStatusGroups,
suggestionSourceTypeGroups,
] = await Promise.all([
prisma.adminUser.count(),
prisma.userProfile.count(),
prisma.house.count(),
prisma.planning.groupBy({ by: ["houseId"] }),
prisma.recipe.count(),
prisma.recipe.count({ where: { sourceId: null } }),
prisma.recipe.count({ where: { sourceId: { not: null } } }),
prisma.recipe.groupBy({
by: ["sourceId"],
where: { sourceId: { not: null } },
_count: { _all: true },
}),
prisma.source.findMany({ select: { id: true, key: true, name: true } }),
prisma.planning.count(),
prisma.planningItem.count(),
prisma.step.count(),
prisma.stepTechStep.count(),
prisma.recipeFavorite.count(),
prisma.stepTechStepCorrection.count(),
prisma.stepTechStepCorrection.count({ where: { consumedAt: null } }),
prisma.stepTechStepCorrection.count({ where: { correctedTechStepId: null } }),
prisma.techStepTrainingSuggestion.count(),
prisma.techStepTrainingSuggestion.groupBy({ by: ["status"], _count: { _all: true } }),
prisma.techStepTrainingSuggestion.groupBy({ by: ["sourceType"], _count: { _all: true } }),
]);
const sourceById = new Map(sources.map((source) => [source.id, source]));
return {
admins,
users,
households,
activeHouseholds: activeHouseholdGroups.length,
recipes,
recipesManual,
recipesImported,
recipesBySource: toBreakdown(
recipeBySourceGroups.map((group) => ({
key: group.sourceId === null ? null : (sourceById.get(group.sourceId)?.key ?? null),
count: group._count._all,
})),
(key) => {
const source = sources.find((s) => s.key === key);
return source ? source.name : key;
},
),
plannings,
planningItems,
steps,
detectedTechniques,
favorites,
corrections,
correctionsUnconsumed,
correctionsRemoval,
trainingSuggestions,
trainingSuggestionsByStatus: toBreakdown(
suggestionStatusGroups.map((group) => ({ key: group.status, count: group._count._all })),
),
trainingSuggestionsBySourceType: toBreakdown(
suggestionSourceTypeGroups.map((group) => ({
key: group.sourceType,
count: group._count._all,
})),
),
};
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Builds the admin dashboard's full metrics payload — a `snapshot` of
* current totals plus `rangeDays` days of daily time series, derived from
* the `createdAt` columns the `admin_metrics` migration added and from the
* `AnalyticsEvent` table. `days` is the caller-validated `?days=` value
* (see `getMetricsSchema`, 7–365).
*/
export async function getMetrics(days: number): Promise<MetricsView> {
try {
const now = new Date();
const since = startOfUtcDay(now, days - 1);
const [
snapshot,
signups,
recipesCreated,
planningItemsAdded,
correctionsSubmitted,
trainingSuggestions,
eventRows,
] = await Promise.all([
getSnapshot(),
prisma.userProfile.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.recipe.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true } }),
prisma.planningItem.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.stepTechStepCorrection.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.techStepTrainingSuggestion.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.analyticsEvent.findMany({
where: { createdAt: { gte: since } },
select: { type: true, createdAt: true },
}),
]);
const eventsByType = new Map<string, Date[]>();
for (const row of eventRows) {
const list = eventsByType.get(row.type);
if (list) list.push(row.createdAt);
else eventsByType.set(row.type, [row.createdAt]);
}
const events: MetricsEventSeries[] = [...eventsByType.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([type, dates]) => ({ type, buckets: bucketByDay(dates, since, days) }));
return {
generatedAt: now.toISOString(),
rangeDays: days,
snapshot,
series: {
signups: bucketByDay(
signups.map((r) => r.createdAt),
since,
days,
),
recipesCreated: bucketByDay(
recipesCreated.map((r) => r.createdAt),
since,
days,
),
planningItemsAdded: bucketByDay(
planningItemsAdded.map((r) => r.createdAt),
since,
days,
),
correctionsSubmitted: bucketByDay(
correctionsSubmitted.map((r) => r.createdAt),
since,
days,
),
trainingSuggestions: bucketByDay(
trainingSuggestions.map((r) => r.createdAt),
since,
days,
),
},
events,
};
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -1,180 +0,0 @@
import type { MonitoringView, ServiceHealthView, ServiceStatus } from "@batch-cooking/shared";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
/** How long each outbound probe (Postgres query, intent-service HTTP) is allowed to take before it counts as `down`. */
const PROBE_TIMEOUT_MS = 2000;
/**
* Heartbeat-age thresholds for the LLM worker. Its default cron is weekly
* (`TECH_STEP_WORKER_CRON`, `0 3 * * 0`), and it also pings on boot/tick —
* so no ping for **8 days** means it likely missed its last scheduled fire
* (`degraded`), and none for **3 weeks** means it's almost certainly not
* running at all (`down`).
*/
const WORKER_STALE_AFTER_MS = 8 * 24 * 60 * 60 * 1000;
const WORKER_DOWN_AFTER_MS = 21 * 24 * 60 * 60 * 1000;
const WORKER_KEY = "tech-step-llm-worker";
function nowIso(): string {
return new Date().toISOString();
}
function roundMs(value: number): number {
return Math.round(value * 10) / 10;
}
function errMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** `12500` → `"il y a 12 s"`, `90000` → `"il y a 1 min"`, `172800000` → `"il y a 2 j"`. */
function formatAgo(ms: number): string {
const s = Math.round(ms / 1000);
if (s < 60) return `il y a ${s} s`;
const m = Math.round(s / 60);
if (m < 60) return `il y a ${m} min`;
const h = Math.round(m / 60);
if (h < 48) return `il y a ${h} h`;
return `il y a ${Math.round(h / 24)} j`;
}
/** `process.uptime()` seconds → `"3 h 12 min"` / `"5 min"` / `"42 s"`. */
function formatUptime(seconds: number): string {
const s = Math.floor(seconds);
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
return `${h} h ${m % 60} min`;
}
/** `prisma.$queryRaw\`SELECT 1\`` with a bounded timeout — the DB connectivity probe. */
async function probePostgres(): Promise<ServiceHealthView> {
const start = performance.now();
try {
// `$queryRaw` doesn't take an AbortSignal — bound it with a race instead.
await Promise.race([
prisma.$queryRaw`SELECT 1`,
new Promise((_resolve, reject) =>
setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS),
),
]);
return {
key: "postgres",
status: "up",
latencyMs: roundMs(performance.now() - start),
detail: null,
checkedAt: nowIso(),
};
} catch (err) {
return {
key: "postgres",
status: "down",
latencyMs: null,
detail: errMessage(err),
checkedAt: nowIso(),
};
}
}
/** The API itself — trivially "up" (it's answering), reported with its process uptime/memory. */
function probeApi(): ServiceHealthView {
const mem = process.memoryUsage();
return {
key: "api",
status: "up",
latencyMs: 0,
detail: `uptime ${formatUptime(process.uptime())} · RSS ${Math.round(mem.rss / 1_000_000)} Mo`,
checkedAt: nowIso(),
};
}
/** `GET {INTENT_SERVICE_BASE_URL}/health` — no secret needed on that route (see the service's `routes/health.py`). */
async function probeIntentService(): Promise<ServiceHealthView> {
const start = performance.now();
try {
const res = await fetch(`${env.INTENT_SERVICE_BASE_URL}/health`, {
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
const latencyMs = roundMs(performance.now() - start);
return {
key: "intent-service",
status: res.ok ? "up" : "degraded",
latencyMs,
detail: `HTTP ${res.status}`,
checkedAt: nowIso(),
};
} catch (err) {
return {
key: "intent-service",
status: "down",
latencyMs: null,
detail: errMessage(err),
checkedAt: nowIso(),
};
}
}
/** Reads the LLM worker's stored `WorkerHeartbeat` (it has no HTTP surface to probe directly) and grades it by age + last job outcome. */
async function probeWorker(): Promise<ServiceHealthView> {
const heartbeat = await prisma.workerHeartbeat.findUnique({ where: { workerKey: WORKER_KEY } });
if (!heartbeat) {
return {
key: WORKER_KEY,
status: "unknown",
latencyMs: null,
detail: "aucun battement reçu",
checkedAt: nowIso(),
lastRunAt: null,
lastResult: null,
};
}
const ageMs = Date.now() - heartbeat.lastSeenAt.getTime();
const lastResult = (heartbeat.lastResult ?? null) as ServiceHealthView["lastResult"];
let status: ServiceStatus = "up";
if (ageMs > WORKER_DOWN_AFTER_MS) status = "down";
else if (ageMs > WORKER_STALE_AFTER_MS || lastResult?.ok === false) status = "degraded";
return {
key: WORKER_KEY,
status,
latencyMs: null,
detail: `dernier battement ${formatAgo(ageMs)}`,
checkedAt: nowIso(),
lastRunAt: heartbeat.lastRunAt?.toISOString() ?? null,
lastResult,
};
}
/**
* Actively probes every dependency the admin monitoring board watches —
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
* LLM worker (via its stored heartbeat). Each probe is independent and
* bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the
* others or this call as a whole.
*
* No HTTP route calls this directly (there was one, `GET
* /admin/api/monitoring` — removed) — `MonitoringBroadcastService`
* (`lib/monitoring-broadcast.service.ts`) is the only caller now, on a
* server-side timer, broadcasting the result over WebSocket instead of
* each admin's browser polling this on its own.
*/
export async function getMonitoring(): Promise<MonitoringView> {
try {
const [postgres, intentService, worker] = await Promise.all([
probePostgres(),
probeIntentService(),
probeWorker(),
]);
return {
generatedAt: nowIso(),
services: [postgres, probeApi(), intentService, worker],
};
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -1,87 +0,0 @@
import type {
ListTechStepPlaceholdersQuery,
MarkTechStepPlaceholdersReviewedInput,
TechStepPlaceholderView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/**
* The missing-technique review (issue #130) — the tech-step counterpart of
* `admin-catalog.service.ts`'s off-catalog *ingredient* review. Kept a flat
* list rather than grouping near-duplicate spellings the way that one does:
* correcting a technique — let alone typing one the catalog is missing — is
* rare enough that a handful of raw rows is all a maintainer ever sees here.
*/
/** Prisma `include` for the placeholder query — a bounded set of the `StepTechStep` rows pointing at this technique, each resolved to just its recipe id/name for the count + "seen in…" preview. */
const placeholderInclude = {
steps: {
take: 50,
include: { step: { select: { recipe: { select: { id: true, name: true } } } } },
},
} as const;
/**
* Every placeholder `TechStep` (see `TechStep.isPlaceholder` in
* schema.prisma), most-impactful first (most distinct recipes, then name).
*
* `query.reviewed` selects which side to show: omitted / `"false"` drops
* rows already triaged (`reviewedAt` set) — the default working list;
* `"true"` keeps only those.
*/
export async function listTechStepPlaceholders(
query: ListTechStepPlaceholdersQuery,
): Promise<TechStepPlaceholderView[]> {
try {
const rows = await prisma.techStep.findMany({
where: { isPlaceholder: true },
include: placeholderInclude,
orderBy: { createdAt: "asc" },
});
const wantReviewed = query.reviewed === "true";
return rows
.filter((row) => (row.reviewedAt !== null) === wantReviewed)
.map((row) => {
const recipeById = new Map<number, string>();
for (const link of row.steps) {
recipeById.set(link.step.recipe.id, link.step.recipe.name);
}
return {
id: row.id,
displayName: row.displayName ?? "",
createdAt: row.createdAt?.toISOString() ?? null,
reviewedAt: row.reviewedAt?.toISOString() ?? null,
recipeCount: recipeById.size,
sampleRecipes: [...recipeById.entries()].slice(0, 5).map(([id, name]) => ({ id, name })),
};
})
.sort(
(a, b) => b.recipeCount - a.recipeCount || a.displayName.localeCompare(b.displayName, "fr"),
);
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Stamps `reviewedAt` on the given placeholder `TechStep` ids — a
* maintainer has seen this proposal (and, if it warranted it, promoted it
* into `reference-seed-data.ts` + `tech-step-training-data.ts` by hand; this
* endpoint never touches the catalog itself). Scoped to
* `isPlaceholder: true` so a stray real id is a silent no-op. Returns how
* many rows were actually stamped.
*/
export async function markTechStepPlaceholdersReviewed(
input: MarkTechStepPlaceholdersReviewedInput,
): Promise<{ reviewed: number }> {
try {
const { count } = await prisma.techStep.updateMany({
where: { id: { in: input.techStepIds }, isPlaceholder: true },
data: { reviewedAt: new Date() },
});
return { reviewed: count };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -1,80 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
ErrorCode,
listCorrectionsQuerySchema,
listSuggestionsQuerySchema,
retrainRequestSchema,
trainingDataSnippetQuerySchema,
updateTrainingSuggestionSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
getTrainingDataSnippet,
listCorrections,
listSuggestions,
runRetrain,
updateSuggestion,
} from "./admin-tech-steps.service.js";
/** Router mounted at `/admin/api/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */
export const adminTechStepsRouter = Router();
adminTechStepsRouter.get(
"/suggestions",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res.status(200).json(await listSuggestions(listSuggestionsQuerySchema.parse(req.query)));
}),
);
adminTechStepsRouter.get(
"/corrections",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res.status(200).json(await listCorrections(listCorrectionsQuerySchema.parse(req.query)));
}),
);
adminTechStepsRouter.get(
"/training-data-snippet",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(await getTrainingDataSnippet(trainingDataSnippetQuerySchema.parse(req.query)));
}),
);
adminTechStepsRouter.patch(
"/suggestions/:id",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a valid suggestion id: ${req.params.id}`,
);
}
const input = updateTrainingSuggestionSchema.parse(req.body);
res.status(200).json(await updateSuggestion(id, input));
}),
);
/**
* Runs the F1 regression gate then (if it passes) the full step backfill,
* and marks the given suggestion ids — see {@link runRetrain}. Long-running
* and process-locked: `409 RETRAIN_ALREADY_RUNNING` if one is already
* underway.
*/
adminTechStepsRouter.post(
"/retrain",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const input = retrainRequestSchema.parse(req.body);
res.status(200).json(await runRetrain(input));
}),
);

View file

@ -1,325 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type CorrectionAdminView,
ErrorCode,
type ListCorrectionsQuery,
type ListSuggestionsQuery,
type RetrainRequestInput,
type RetrainResultView,
type TrainingDataSnippetQuery,
type TrainingDataSnippetView,
type TrainingSuggestionAdminView,
type TrainingSuggestionGroupView,
type UpdateTrainingSuggestionInput,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import {
MIN_OVERALL_F1,
runTechStepEvalSuite,
} from "../../lib/recipe-matching/tech-step-eval-runner.js";
import { backfillTechSteps } from "../../scripts/backfill-tech-steps.js";
/** How many raw corrections `listCorrections` returns per call — the browser is a triage view, not an export. */
const CORRECTIONS_PAGE_SIZE = 200;
const suggestionInclude = {
techStep: { select: { key: true } },
sourceCorrection: {
include: {
step: { select: { id: true, recipeId: true, description: true } },
previousTechStep: { select: { key: true } },
correctedTechStep: { select: { key: true } },
},
},
} as const;
type SuggestionRow = Awaited<
ReturnType<
typeof prisma.techStepTrainingSuggestion.findFirstOrThrow<{ include: typeof suggestionInclude }>
>
>;
/** Shapes one Prisma suggestion row (with {@link suggestionInclude}) into its admin view. */
function toSuggestionView(row: SuggestionRow): TrainingSuggestionAdminView {
const correction = row.sourceCorrection;
return {
id: row.id,
techStepKey: row.techStep.key,
locale: row.locale,
suggestedSynonyms: row.suggestedSynonyms,
suggestedUtterances: row.suggestedUtterances,
sourceType: row.sourceType,
status: row.status,
createdAt: row.createdAt.toISOString(),
sourceCorrection: correction
? {
id: correction.id,
recipeId: correction.step.recipeId,
stepId: correction.step.id,
clauseText: correction.step.description.slice(correction.start, correction.end),
previousTechStepKey: correction.previousTechStep?.key ?? null,
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
}
: null,
};
}
/**
* Every `TechStepTrainingSuggestion` matching the (all-optional) filters,
* grouped by technique key — same "one block per technique" organisation
* as `list-pending-training-suggestions.ts`'s CLI report, which this UI
* replaces.
*/
export async function listSuggestions(
query: ListSuggestionsQuery,
): Promise<TrainingSuggestionGroupView[]> {
try {
const rows = await prisma.techStepTrainingSuggestion.findMany({
where: {
...(query.status ? { status: query.status } : {}),
...(query.sourceType ? { sourceType: query.sourceType } : {}),
...(query.locale ? { locale: query.locale } : {}),
...(query.techStepKey ? { techStep: { key: query.techStepKey } } : {}),
},
orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
include: suggestionInclude,
});
const byKey = new Map<string, TrainingSuggestionAdminView[]>();
for (const row of rows) {
const view = toSuggestionView(row);
const group = byKey.get(view.techStepKey);
if (group) group.push(view);
else byKey.set(view.techStepKey, [view]);
}
return [...byKey.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([techStepKey, suggestions]) => ({ techStepKey, suggestions }));
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Raw `StepTechStepCorrection`s for the admin browser — newest first,
* capped at {@link CORRECTIONS_PAGE_SIZE}. Unlike the worker's own
* `getPendingCorrections`, this **includes** the `correctedTechStepId IS NULL`
* removals ("no technique here") that never become suggestions and are
* otherwise invisible.
*/
export async function listCorrections(query: ListCorrectionsQuery): Promise<CorrectionAdminView[]> {
try {
const consumedFilter =
query.consumed === "true"
? { consumedAt: { not: null } }
: query.consumed === "false"
? { consumedAt: null }
: {};
const correctedFilter =
query.hasCorrectedTechStep === "true"
? { correctedTechStepId: { not: null } }
: query.hasCorrectedTechStep === "false"
? { correctedTechStepId: null }
: {};
const rows = await prisma.stepTechStepCorrection.findMany({
where: { ...consumedFilter, ...correctedFilter },
orderBy: { createdAt: "desc" },
take: CORRECTIONS_PAGE_SIZE,
include: {
step: { select: { id: true, recipeId: true, description: true } },
previousTechStep: { select: { key: true } },
correctedTechStep: { select: { key: true } },
},
});
return rows.map((row) => ({
id: row.id,
recipeId: row.step.recipeId,
stepId: row.step.id,
stepDescription: row.step.description,
clauseText: row.step.description.slice(row.start, row.end),
start: row.start,
end: row.end,
previousTechStepKey: row.previousTechStep?.key ?? null,
correctedTechStepKey: row.correctedTechStep?.key ?? null,
createdAt: row.createdAt.toISOString(),
consumedAt: row.consumedAt?.toISOString() ?? null,
}));
} catch (err) {
throw err;
}
}
/**
* Curates one suggestion — edit its proposed synonyms/utterances and/or
* flip its `status`. At least one field must be present.
*
* @throws {HttpError} `400 VALIDATION_ERROR` if the body is empty.
* @throws {HttpError} `404 NOT_FOUND` if `id` matches no suggestion.
*/
export async function updateSuggestion(
id: number,
input: UpdateTrainingSuggestionInput,
): Promise<TrainingSuggestionAdminView> {
try {
if (
input.status === undefined &&
input.suggestedSynonyms === undefined &&
input.suggestedUtterances === undefined
) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Nothing to update");
}
const existing = await prisma.techStepTrainingSuggestion.findUnique({ where: { id } });
if (!existing) {
throw new HttpError(404, ErrorCode.NOT_FOUND, `Training suggestion ${id} not found`);
}
const updated = await prisma.techStepTrainingSuggestion.update({
where: { id },
data: {
...(input.status !== undefined ? { status: input.status } : {}),
...(input.suggestedSynonyms !== undefined
? { suggestedSynonyms: input.suggestedSynonyms }
: {}),
...(input.suggestedUtterances !== undefined
? { suggestedUtterances: input.suggestedUtterances }
: {}),
},
include: suggestionInclude,
});
return toSuggestionView(updated);
} catch (err) {
throw err;
}
}
/** Deduplicates while preserving first-seen order — for pooling synonyms/utterances across suggestions. */
function dedupe(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
}
/** Indents each entry as a Python list literal line (4-space, trailing comma) — the shape `training_data.py`'s blocks use. */
function pythonListBody(entries: string[]): string {
return entries.map((entry) => ` ${JSON.stringify(entry)},`).join("\n");
}
/**
* Aggregates the synonyms/utterances of every suggestion matching
* `techStepKey` + `locale` + `status` into a ready-to-paste
* `training_data.py` block. **Read-only** — editing that Python file and
* restarting the intent-service stay a manual maintainer step.
*/
export async function getTrainingDataSnippet(
query: TrainingDataSnippetQuery,
): Promise<TrainingDataSnippetView> {
try {
const rows = await prisma.techStepTrainingSuggestion.findMany({
where: {
locale: query.locale,
status: query.status,
techStep: { key: query.techStepKey },
},
select: { suggestedSynonyms: true, suggestedUtterances: true },
});
const synonyms = dedupe(rows.flatMap((row) => row.suggestedSynonyms));
const utterances = dedupe(rows.flatMap((row) => row.suggestedUtterances));
const snippet = [
`# ${query.techStepKey} (${query.locale}) — ${rows.length} suggestion(s) "${query.status}"`,
`"synonyms": [`,
pythonListBody(synonyms),
`],`,
`"utterances": [`,
pythonListBody(utterances),
`],`,
]
.filter((line) => line.length > 0)
.join("\n");
return {
techStepKey: query.techStepKey,
locale: query.locale,
status: query.status,
suggestionCount: rows.length,
synonyms,
utterances,
snippet,
};
} catch (err) {
throw err;
}
}
/** Process-wide lock — the F1 gate + full backfill is heavy and must never run twice concurrently. */
let retrainInProgress = false;
/**
* Runs the training-corpus regression gate then, if it passes, backfills
* every step and marks the given suggestion ids — the same three steps as
* `scripts/retrain-tech-steps.ts`, callable from the admin UI.
*
* **Only meaningful after** a maintainer has hand-edited
* `services/tech-step-intent-service/intent_service/training_data.py` **and
* restarted that service** (it trains once at boot) — this endpoint can do
* neither, and the admin UI states that prominently.
*
* A failed gate returns `gatePassed: false` with no backfill / no marking
* (HTTP 200 — it's an expected outcome to show the operator, not an error).
*
* @throws {HttpError} `409 RETRAIN_ALREADY_RUNNING` if a retrain is already in flight.
*/
export async function runRetrain(input: RetrainRequestInput): Promise<RetrainResultView> {
if (retrainInProgress) {
throw new HttpError(
409,
ErrorCode.RETRAIN_ALREADY_RUNNING,
"A retrain (F1 gate + backfill) is already running",
);
}
retrainInProgress = true;
try {
const { overall } = await runTechStepEvalSuite();
const gatePassed = overall.f1 >= MIN_OVERALL_F1;
let backfilled: RetrainResultView["backfilled"] = null;
const marked = { applied: 0, rejected: 0 };
if (gatePassed) {
backfilled = await backfillTechSteps();
const appliedIds = input.appliedIds ?? [];
const rejectedIds = input.rejectedIds ?? [];
if (appliedIds.length > 0) {
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
where: { id: { in: appliedIds } },
data: { status: "applied" },
});
marked.applied = count;
}
if (rejectedIds.length > 0) {
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
where: { id: { in: rejectedIds } },
data: { status: "rejected" },
});
marked.rejected = count;
}
}
return {
f1: overall.f1,
precision: overall.precision,
recall: overall.recall,
minF1: MIN_OVERALL_F1,
gatePassed,
backfilled,
marked,
};
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
} finally {
retrainInProgress = false;
}
}

View file

@ -1,80 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
createTourScenarioSchema,
ErrorCode,
updateTourScenarioSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
createTourScenario,
deleteTourScenario,
listTourScenarios,
listTourTargets,
updateTourScenario,
} from "./admin-tours.service.js";
/**
* Router mounted at `/admin/api/tours` (via `admin.routes.ts`) — every
* route behind {@link requireAdmin}. Full CRUD over the guided-tour
* scenarios the public `GET /reference/tours` serves to the web tour
* engine (issues #42/#44).
*/
export const adminToursRouter = Router();
/** Express types a numeric route param `string | undefined`; parse + validate here. */
function parseId(value: string | undefined): number {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, `Invalid tour scenario id "${value}"`);
}
return id;
}
adminToursRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await listTourScenarios());
}),
);
/** The catalogue of valid highlight targets — for the editor's per-step target picker. */
adminToursRouter.get(
"/targets",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await listTourTargets());
}),
);
adminToursRouter.post(
"/",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const scenario = await createTourScenario(createTourScenarioSchema.parse(req.body));
res.status(201).json(scenario);
}),
);
adminToursRouter.patch(
"/:id",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const scenario = await updateTourScenario(
parseId(req.params.id),
updateTourScenarioSchema.parse(req.body),
);
res.status(200).json(scenario);
}),
);
adminToursRouter.delete(
"/:id",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
await deleteTourScenario(parseId(req.params.id));
res.status(204).end();
}),
);

View file

@ -1,175 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type AdminTourScenarioView,
type CreateTourScenarioInput,
ErrorCode,
type TourTargetView,
type UpdateTourScenarioInput,
} from "@batch-cooking/shared";
import { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
/** Shapes one Prisma `TourScenario` (with its steps) into the admin view. */
function toAdminView(scenario: {
id: number;
key: string;
name: string;
enabled: boolean;
steps: Array<{
order: number;
route: string | null;
targetKey: string;
title: string;
content: string;
}>;
}): AdminTourScenarioView {
return {
id: scenario.id,
key: scenario.key,
name: scenario.name,
enabled: scenario.enabled,
steps: scenario.steps
.slice()
.sort((a, b) => a.order - b.order)
.map((step) => ({
order: step.order,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
};
}
/** Every tour scenario — enabled *and* disabled — with its ordered steps. */
export async function listTourScenarios(): Promise<AdminTourScenarioView[]> {
try {
const scenarios = await prisma.tourScenario.findMany({
orderBy: { key: "asc" },
include: { steps: { orderBy: { order: "asc" } } },
});
return scenarios.map(toAdminView);
} catch (err) {
throw err; // see the repo's service-layer try/catch convention
}
}
/** The catalogue of valid highlight targets, for the editor's target picker. */
export async function listTourTargets(): Promise<TourTargetView[]> {
try {
return await prisma.tourTarget.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err;
}
}
/**
* Creates an empty scenario (no steps yet — the editor adds them via
* {@link updateTourScenario}).
*
* @throws {HttpError} `409 TOUR_KEY_ALREADY_IN_USE` if `key` is taken.
*/
export async function createTourScenario(
input: CreateTourScenarioInput,
): Promise<AdminTourScenarioView> {
try {
const scenario = await prisma.tourScenario.create({
data: { key: input.key, name: input.name },
include: { steps: true },
});
return toAdminView(scenario);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
throw new HttpError(
409,
ErrorCode.TOUR_KEY_ALREADY_IN_USE,
`A tour scenario with key "${input.key}" already exists`,
);
}
throw err;
}
}
/**
* Updates a scenario's `name`/`enabled` and **replaces its entire step
* list** (delete-all then recreate with `order` = array index) — the same
* block-replace `updateRecipe` uses for a recipe's steps, not a partial
* patch. `key` is immutable.
*
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `id` matches no scenario.
* @throws {HttpError} `404 TOUR_TARGET_NOT_FOUND` if a step's `targetKey`
* isn't a seeded `TourTarget`.
*/
export async function updateTourScenario(
id: number,
input: UpdateTourScenarioInput,
): Promise<AdminTourScenarioView> {
try {
const existing = await prisma.tourScenario.findUnique({ where: { id } });
if (!existing) {
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No tour scenario with id ${id}`);
}
const targetKeys = [...new Set(input.steps.map((step) => step.targetKey))];
if (targetKeys.length > 0) {
const known = await prisma.tourTarget.findMany({
where: { key: { in: targetKeys } },
select: { key: true },
});
const knownKeys = new Set(known.map((target) => target.key));
const unknown = targetKeys.find((key) => !knownKeys.has(key));
if (unknown !== undefined) {
throw new HttpError(
404,
ErrorCode.TOUR_TARGET_NOT_FOUND,
`No tour target with key "${unknown}"`,
);
}
}
const [, , scenario] = await prisma.$transaction([
prisma.tourScenario.update({
where: { id },
data: { name: input.name, enabled: input.enabled },
}),
prisma.tourScenarioStep.deleteMany({ where: { scenarioId: id } }),
prisma.tourScenario.update({
where: { id },
data: {
steps: {
create: input.steps.map((step, index) => ({
order: index,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
},
},
include: { steps: { orderBy: { order: "asc" } } },
}),
]);
return toAdminView(scenario);
} catch (err) {
throw err;
}
}
/**
* Deletes a scenario. Its steps and every user's `TourProgress` row for it
* cascade away (a scenario that no longer exists has no "seen" state worth
* keeping).
*
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `id` matches no scenario.
*/
export async function deleteTourScenario(id: number): Promise<void> {
try {
const existing = await prisma.tourScenario.findUnique({ where: { id } });
if (!existing) {
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No tour scenario with id ${id}`);
}
await prisma.tourScenario.delete({ where: { id } });
} catch (err) {
throw err;
}
}

View file

@ -1,31 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { adminCreateUserSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import { createUserProfile, listUsers } from "./admin-users.service.js";
/**
* Router mounted at `/admin/api/users` (via `admin.routes.ts`) — every route
* behind {@link requireAdmin}. Backs the `/admin/utilisateurs` page: a
* read-only list of end-user accounts and a "create an account by hand"
* form. The server generates a one-time temporary password on create and
* returns it in the response (see `admin-users.service.ts`).
*/
export const adminUsersRouter = Router();
adminUsersRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await listUsers());
}),
);
adminUsersRouter.post(
"/",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const input = adminCreateUserSchema.parse(req.body);
res.status(201).json(await createUserProfile(input));
}),
);

View file

@ -1,145 +0,0 @@
import { randomInt } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import {
type AdminCreateUserInput,
type AdminUserCreatedView,
type AdminUserListItemView,
ErrorCode,
} from "@batch-cooking/shared";
import argon2 from "argon2";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
/**
* How many accounts `listUsers` returns per call. Like `CONTACT_MESSAGES_PAGE_SIZE`
* this is a triage view, not an export — the admin creates/looks up a handful
* of accounts, it never needs the whole table paginated.
*/
const USERS_PAGE_SIZE = 200;
// Same reasoning as `admin-auth.service.ts`'s / `auth.service.ts`'s `hashOptions`:
// argon2's real defaults are deliberately expensive; the test suite hashes
// against throwaway data many times per run, so a cheaper cost keeps it fast
// without weakening anything real. Never applies outside NODE_ENV=test.
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
/**
* Charset for {@link generateTemporaryPassword} — upper/lower letters + digits,
* minus the visually ambiguous ones (`0`/`O`/`1`/`l`/`I`), same spirit as
* `house.service.ts`'s `INVITE_CODE_CHARS`: this string is read off one screen
* and typed into another (or pasted, but not always).
*/
const TEMP_PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
/** Length of a generated temporary password — comfortably above `signupSchema`'s 8-char floor. */
const TEMP_PASSWORD_LENGTH = 16;
/**
* A fresh random temporary password for an admin-created account. Uses
* `node:crypto`'s `randomInt` (not `Math.random`) exactly like
* `generateInviteCode()` — this value guards a real account until its owner
* changes it. Returned to the operator once and never stored in plain text.
*/
function generateTemporaryPassword(): string {
let password = "";
for (let i = 0; i < TEMP_PASSWORD_LENGTH; i++) {
password += TEMP_PASSWORD_CHARS[randomInt(TEMP_PASSWORD_CHARS.length)];
}
return password;
}
/** A `UserProfile` row with its household name joined in — the shape {@link toListItem} maps from. */
type UserRow = Awaited<
ReturnType<
typeof prisma.userProfile.findFirstOrThrow<{
include: { house: { select: { name: true } } };
}>
>
>;
/** Shapes one Prisma `UserProfile` row (with its `house` name) into the admin list view — drops `passwordHash`/`tokenVersion`, resolves the household name. */
function toListItem(row: UserRow): AdminUserListItemView {
return {
id: row.id,
firstName: row.firstName,
lastName: row.lastName,
email: row.email,
householdName: row.house?.name ?? null,
createdAt: row.createdAt.toISOString(),
};
}
/**
* Every end-user account for the admin `/admin/utilisateurs` list — newest
* first, capped at {@link USERS_PAGE_SIZE}. Read-only: this first version has
* no edit/disable/delete (deleting an account is irreversible and stays a
* deliberate self-service action in `AccountSettingsPage`).
*/
export async function listUsers(): Promise<AdminUserListItemView[]> {
try {
const rows = await prisma.userProfile.findMany({
// `id` as a tiebreaker so the order is total even if two accounts share
// a `createdAt` (bulk-provisioned in the same tick) — `id` ascends with
// `createdAt` anyway.
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: USERS_PAGE_SIZE,
include: { house: { select: { name: true } } },
});
return rows.map(toListItem);
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
// this layer just isn't allowed a bare `await`. Same as `admin-contact.service.ts`.
throw err;
}
}
/**
* Provisions a new end-user account by hand and returns it together with the
* one-time temporary password the caller must relay to the user. Mirrors
* `auth.service.ts`'s `signup` for the parts that matter (email-uniqueness
* check, argon2 hash, `UserProfile` row with `houseId`/`dietId` left `null`)
* but issues **no session token** — the operator isn't logging in as this
* user — and generates the password instead of accepting one.
*
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
*/
export async function createUserProfile(
input: AdminCreateUserInput,
): Promise<AdminUserCreatedView> {
try {
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
if (existing) {
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
}
const temporaryPassword = generateTemporaryPassword();
const passwordHash = await argon2.hash(temporaryPassword, hashOptions);
const profile = await prisma.userProfile.create({
// No household/diet here, same as `signup` — the user picks those in the
// onboarding wizard after their first login.
data: {
firstName: input.firstName,
lastName: input.lastName,
email: input.email,
passwordHash,
},
include: { house: { select: { name: true } } },
});
// Fire-and-forget, one line, no migration (see `analytics.service.ts`). A
// distinct type from `user.signup` so the dashboard can tell hand-provisioned
// accounts apart; `actorType: "system"` because the actor is an operator, not
// the new user (and `AnalyticsEvent.actorId` only ever means a `UserProfile.id`).
analytics.recordEvent("admin.user_created", {
actorType: "system",
context: { userId: profile.id },
});
return { user: toListItem(profile), temporaryPassword };
} catch (err) {
throw err; // see listUsers()'s catch comment
}
}

View file

@ -1,32 +0,0 @@
import { Router } from "express";
import { adminAuthRouter } from "./admin-auth.routes.js";
import { adminCatalogRouter } from "./admin-catalog.routes.js";
import { adminContactRouter } from "./admin-contact.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
import { adminToursRouter } from "./admin-tours.routes.js";
import { adminUsersRouter } from "./admin-users.routes.js";
/**
* Aggregator for the admin application's API surface, mounted at
* `/admin/api` in `app.ts` (not bare `/admin` — see that mount point's own
* comment for why: it would collide with apps/web's `/admin/*` page
* routes). Every sub-router here backs one of those pages —
* `/admin/api/auth` is public (login), everything added later
* (`/admin/api/metrics`, `/admin/api/tech-steps/*`, `/admin/api/catalog/*`)
* sits behind `requireAdmin` (`middlewares/require-admin.ts`).
*
* Monitoring has **no** REST route here on purpose — it's pushed over
* WebSocket instead (`lib/monitoring-broadcast.service.ts`, wired in
* `server.ts`), not requested per-page-load. See that service's doc
* comment for the full mechanism.
*/
export const adminRouter = Router();
adminRouter.use("/auth", adminAuthRouter);
adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter);
adminRouter.use("/tours", adminToursRouter);
adminRouter.use("/contact", adminContactRouter);
adminRouter.use("/users", adminUsersRouter);

View file

@ -1,12 +1,7 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
type AuthResponse,
deleteAccountSchema,
loginSchema,
signupSchema,
} from "@batch-cooking/shared";
import type { CookieOptions, Response } from "express";
import { Router } from "express"; import { Router } from "express";
import type { CookieOptions, Response } from "express";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { deleteAccount, login, signup } from "./auth.service.js"; import { deleteAccount, login, signup } from "./auth.service.js";
@ -48,11 +43,7 @@ authRouter.post(
const input = signupSchema.parse(req.body); const input = signupSchema.parse(req.body);
const { profile, token } = await signup(input); const { profile, token } = await signup(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
// Token is echoed in the body too, not just the cookie — see res.status(201).json(profile);
// `AuthResponse` (packages/shared) for why (Capacitor WebView origin
// can't receive a SameSite cookie). Purely additive: every existing
// profile field stays exactly where it was.
res.status(201).json({ ...profile, token } satisfies AuthResponse);
}), }),
); );
@ -63,7 +54,7 @@ authRouter.post(
const input = loginSchema.parse(req.body); const input = loginSchema.parse(req.body);
const { profile, token } = await login(input); const { profile, token } = await login(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(200).json({ ...profile, token } satisfies AuthResponse); res.status(200).json(profile);
}), }),
); );

View file

@ -8,7 +8,6 @@ import {
import argon2 from "argon2"; import argon2 from "argon2";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { signAuthToken } from "../../lib/jwt.js"; import { signAuthToken } from "../../lib/jwt.js";
import { toSafeProfile } from "../../lib/safe-profile.js"; import { toSafeProfile } from "../../lib/safe-profile.js";
import { leaveCurrentHouse } from "../house/house.service.js"; import { leaveCurrentHouse } from "../house/house.service.js";
@ -36,43 +35,28 @@ const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken. * @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
*/ */
export async function signup(input: SignupInput): Promise<AuthResult> { export async function signup(input: SignupInput): Promise<AuthResult> {
try { const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
const existing = await prisma.userProfile.findUnique({ if (existing) {
where: { email: input.email }, throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
});
if (existing) {
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
}
const passwordHash = await argon2.hash(input.password, hashOptions);
// No household is created here — it's now an optional step of the
// onboarding wizard (create or join one, or skip — see
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
const profile = await prisma.userProfile.create({
data: {
firstName: input.firstName,
lastName: input.lastName,
email: input.email,
passwordHash,
},
});
analytics.recordEvent("user.signup", { actorId: profile.id });
const token = signAuthToken({
userProfileId: profile.id,
tokenVersion: profile.tokenVersion,
});
return { profile: toSafeProfile(profile), token };
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which already
// logs it, see `error-logger.ts`) is what actually handles it, this
// service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
} }
const passwordHash = await argon2.hash(input.password, hashOptions);
// No household is created here — it's now an optional step of the
// onboarding wizard (create or join one, or skip — see
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
const profile = await prisma.userProfile.create({
data: {
firstName: input.firstName,
lastName: input.lastName,
email: input.email,
passwordHash,
},
});
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
return { profile: toSafeProfile(profile), token };
} }
/** /**
@ -90,21 +74,15 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong. * @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
*/ */
export async function deleteAccount(profileId: number, password: string): Promise<void> { export async function deleteAccount(profileId: number, password: string): Promise<void> {
try { const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
const profile = await prisma.userProfile.findUnique({ if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
where: { id: profileId }, throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
});
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
}
if (profile.houseId !== null) {
await leaveCurrentHouse(profile.id, profile.houseId);
}
await prisma.userProfile.delete({ where: { id: profile.id } });
} catch (err) {
throw err; // see signup()'s catch comment above
} }
if (profile.houseId !== null) {
await leaveCurrentHouse(profile.id, profile.houseId);
}
await prisma.userProfile.delete({ where: { id: profile.id } });
} }
/** /**
@ -115,21 +93,12 @@ export async function deleteAccount(profileId: number, password: string): Promis
* caller can never learn whether a given email has an account. * caller can never learn whether a given email has an account.
*/ */
export async function login(input: LoginInput): Promise<AuthResult> { export async function login(input: LoginInput): Promise<AuthResult> {
try { const profile = await prisma.userProfile.findUnique({ where: { email: input.email } });
const profile = await prisma.userProfile.findUnique({
where: { email: input.email },
});
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) { if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password"); throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
}
const token = signAuthToken({
userProfileId: profile.id,
tokenVersion: profile.tokenVersion,
});
return { profile: toSafeProfile(profile), token };
} catch (err) {
throw err; // see signup()'s catch comment above
} }
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
return { profile: toSafeProfile(profile), token };
} }

View file

@ -1,34 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { submitContactMessageSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { submitContactMessage } from "./contact.service.js";
/**
* Router mounted at `/contact` in app.ts (issue #80). A single endpoint —
* `POST /` — for the in-app "signaler un bug / suggestion" form. Requires a
* session: only signed-in users can send a message, so every report is
* attributable (see `ContactMessage.authorId`). The admin-side read of
* these rows lives entirely in the admin router, not here.
*/
export const contactRouter = Router();
contactRouter.post(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = submitContactMessageSchema.parse(req.body);
// Read the UA from the request, never from the body — the schema omits
// it on purpose so it can't be spoofed. Header can be an array or
// absent; normalize to `string | null`.
const rawUserAgent = req.headers["user-agent"];
const userAgent =
typeof rawUserAgent === "string" && rawUserAgent.length > 0 ? rawUserAgent : null;
await submitContactMessage(res.locals.userProfile.id, input, userAgent);
// 204, not 201-with-a-body: the form has nothing to do with the stored
// row, and the shared `ApiClient._request` treats only 204 as bodyless
// (a 201 with an empty body makes its `response.json()` throw). Same
// "mutation returns nothing" shape as `POST /auth/logout`.
res.status(204).end();
}),
);

View file

@ -1,50 +0,0 @@
import type { SubmitContactMessageInput } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
/**
* Persists one contact-form submission (issue #80). Write-only from the
* user's side — there is no read/update/delete counterpart here: admins
* consult these rows through the admin app (`admin-contact.service.ts`, PR
* 2), never this module.
*
* `userAgent` comes straight from the request header, never the body — the
* shared `submitContactMessageSchema` deliberately omits it so the client
* can't spoof it. `pageUrl` / `appVersion` are client-supplied context and
* default to `null` when the (older, or non-web) client didn't send them.
*
* Returns nothing: the form only needs to know the write succeeded (a
* `201`), not the stored row.
*/
export async function submitContactMessage(
authorId: number,
input: SubmitContactMessageInput,
userAgent: string | null,
): Promise<void> {
try {
await prisma.contactMessage.create({
data: {
authorId,
type: input.type,
message: input.message,
pageUrl: input.pageUrl ?? null,
userAgent,
appVersion: input.appVersion ?? null,
},
});
// Fire-and-forget (see AnalyticsService's contract) — `admin-metrics`
// aggregates `AnalyticsEvent` by `type` generically, so this shows up in
// the dashboard series with no extra code; `context.type` lets it be
// broken down by bug/suggestion/other.
analytics.recordEvent("contact.submitted", {
actorType: "user",
actorId: authorId,
context: { type: input.type },
});
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler` / the error middleware (which
// already logs it) handles it; this service layer just isn't allowed a
// bare `await` per the repo's async/try-catch convention.
throw err;
}
}

View file

@ -1,37 +0,0 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getCookingSessionSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getCookingPlanForDate } from "./cooking-session.service.js";
/** Router mounted at `/cooking-session` in app.ts. */
export const cookingSessionRouter = Router();
/**
* Returns the authenticated user's household's optimized cooking plan for
* the week covering `?date=` (`YYYY-MM-DD`) — every recipe planned that
* week reorganized into ordered phases (see {@link getCookingPlanForDate}).
* Always `200`, never `null` — no household or nothing planned that week
* both come back as a normal `OptimizedCookingPlanView` with empty
* `recipes`/`phases`. Same request contract as `GET /shopping-list`.
*/
cookingSessionRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = getCookingSessionSchema.parse(req.query);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const plan = await getCookingPlanForDate(res.locals.userProfile.houseId, date);
res.status(200).json(plan);
}),
);

View file

@ -1,192 +0,0 @@
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import {
type OptimizerRecipeInput,
type OptimizerStepInput,
optimizeCookingPlan,
} from "../../lib/recipe-matching/cooking-optimizer.js";
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
/**
* Prisma `include` for a `Planning` query that needs, for every item, its
* recipe's ordered steps with the full detected-technique tree — the raw
* material the optimizer works on (see `cooking-optimizer.ts`). It's the
* `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved
* the same way so {@link toIngredientView}/{@link toUnitView} can be reused
* as-is; deliberately narrower than a full `RecipeView` fetch (no
* diets/favorites/recipe-level ingredient list — the optimizer reads
* quantities off the technique clauses, not the recipe header).
*/
function cookingSessionPlanningInclude() {
return {
items: {
// A "leftovers" item (`sourcePlanningItemId` set) reserves part of an
// already-cooked batch — it has nothing to cook, so it must not enter
// the optimized cooking plan (issue #34). Excluded at the query so it
// never reaches `toOptimizerRecipe`.
where: { sourcePlanningItemId: null },
include: {
recipe: {
select: {
id: true,
name: true,
portions: true,
steps: {
orderBy: { order: "asc" },
include: {
techSteps: {
orderBy: { order: "asc" },
include: {
techStep: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
temperatures: true,
},
},
},
},
},
},
},
},
} satisfies Prisma.PlanningInclude;
}
type PlanningWithSteps = Prisma.PlanningGetPayload<{
include: ReturnType<typeof cookingSessionPlanningInclude>;
}>;
type PlanningItemWithSteps = PlanningWithSteps["items"][number];
/**
* Maps one planning item's recipe (with {@link cookingSessionPlanningInclude})
* to the optimizer's pure input shape — ingredient/unit/technique/utensil
* rows resolved to their reference views here so the optimizer itself never
* touches Prisma. `Decimal` quantities become plain numbers (same
* `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an
* unresolved-unit line keeps `unit: null`.
*/
function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({
stepId: step.id,
order: step.order,
description: step.description,
techSteps: step.techSteps.map((techStep) => {
const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({
ingredient: toIngredientView(line.ingredient),
quantity: line.quantity === null ? null : Number(line.quantity),
unit: line.unit === null ? null : toUnitView(line.unit),
}));
return {
techStep: {
id: techStep.techStep.id,
key: techStep.techStep.key,
isPlaceholder: techStep.techStep.isPlaceholder,
displayName: techStep.techStep.displayName,
},
order: techStep.order,
ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
// Structured value only — `Decimal` °C → plain number, `qualitative`
// re-narrowed to the literal union (same posture as
// `recipe.service.ts`'s `toStepTechStepViews`).
temperatures: techStep.temperatures.map((temperature) => ({
celsius: temperature.celsius === null ? null : Number(temperature.celsius),
gasMark: temperature.gasMark,
qualitative:
temperature.qualitative === "low" ||
temperature.qualitative === "medium" ||
temperature.qualitative === "high"
? temperature.qualitative
: null,
})),
};
}),
}));
return {
recipeId: item.recipe.id,
name: item.recipe.name,
// The slot's own portion count vs. the recipe's as-written yield — the
// optimizer scales technique-clause quantities by the ratio, same
// reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`.
portions: item.portions,
recipePortions: item.recipe.portions,
steps,
};
}
/**
* Builds the household's optimized cooking plan for the week covering
* `date` — every recipe planned that week, reorganized into ordered phases
* that pool shared prep and float passive cooks into the background (see
* `cooking-optimizer.ts`). `date` follows the same convention as
* `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`,
* not necessarily a Monday).
*
* Like `getShoppingListForDate` and unlike `getPlanningForDate`, this
* **never** returns `null` — no household and "no planning covers this week
* yet" both degrade to an empty `phases`/`recipes` on an otherwise normal
* {@link OptimizedCookingPlanView} (the week's date range is always
* computable from `date` alone).
*/
export async function getCookingPlanForDate(
houseId: number | null,
date: DateTime,
): Promise<OptimizedCookingPlanView> {
try {
const weekStart = getWeekStart(toDateOnly(date));
const weekFinish = weekStart.plus({ days: 6 });
const emptyPlan: OptimizedCookingPlanView = {
startDate: weekStart.toJSDate().toISOString(),
finishDate: weekFinish.toJSDate().toISOString(),
recipes: [],
phases: [],
};
if (houseId === null) {
return emptyPlan;
}
// Same "covering range" lookup as getShoppingListForDate — see
// getPlanningForDate's doc comment for the UTC-midnight `Date` rationale.
const dateOnly = toDateOnly(date).toJSDate();
const planning = await prisma.planning.findFirst({
where: {
houseId,
startDate: { lte: dateOnly },
finishDate: { gte: dateOnly },
},
orderBy: { startDate: "desc" },
include: cookingSessionPlanningInclude(),
});
if (!planning) {
return emptyPlan;
}
const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe));
return {
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
recipes,
phases,
};
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

@ -1,11 +1,10 @@
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { import {
createHouseSchema,
ErrorCode, ErrorCode,
createHouseSchema,
joinHouseSchema, joinHouseSchema,
renameHouseSchema, renameHouseSchema,
updateHouseSourcesSchema,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
@ -13,12 +12,10 @@ import {
createHouse, createHouse,
deleteHouse, deleteHouse,
getCurrentHouse, getCurrentHouse,
getHouseSourceIds,
joinHouse, joinHouse,
leaveCurrentHouse, leaveCurrentHouse,
removeMember, removeMember,
renameHouse, renameHouse,
updateHouseSources,
} from "./house.service.js"; } from "./house.service.js";
/** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */ /** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */
@ -94,27 +91,6 @@ houseRouter.delete(
}), }),
); );
/** Which recipe sources the household currently sees in its recipe tabs — the source step of the onboarding wizard and the `/parametres/foyer` settings page both call this. */
houseRouter.get(
"/current/sources",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
const sourceIds = await getHouseSourceIds(res.locals.userProfile.houseId);
res.status(200).json(sourceIds);
}),
);
/** Replaces the household's enabled-source set — same callers as the GET above. */
houseRouter.patch(
"/current/sources",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = updateHouseSourcesSchema.parse(req.body);
const sourceIds = await updateHouseSources(res.locals.userProfile.houseId, input.sourceIds);
res.status(200).json(sourceIds);
}),
);
/** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */ /** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */
houseRouter.delete( houseRouter.delete(
"/members/:memberId", "/members/:memberId",

View file

@ -44,18 +44,10 @@ const houseWithMembers = {
/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */ /** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> { export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
try { if (houseId === null) {
if (houseId === null) { return null;
return null;
}
return toHouseView(await findHouseOrThrow(houseId));
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
} }
return toHouseView(await findHouseOrThrow(houseId));
} }
/** /**
@ -66,20 +58,16 @@ export async function getCurrentHouse(houseId: number | null): Promise<HouseView
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
*/ */
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> { export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
try { if (houseId === null) {
if (houseId === null) { throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
await findHouseOrThrow(houseId);
const house = await prisma.house.update({
where: { id: houseId },
data: { name },
include: houseWithMembers,
});
return toHouseView(house);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
} }
await findHouseOrThrow(houseId);
const house = await prisma.house.update({
where: { id: houseId },
data: { name },
include: houseWithMembers,
});
return toHouseView(house);
} }
/** /**
@ -93,45 +81,30 @@ export async function createHouse(
houseId: number | null, houseId: number | null,
name: string, name: string,
): Promise<HouseView> { ): Promise<HouseView> {
try { if (houseId !== null) {
if (houseId !== null) { throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
throw new HttpError(
409,
ErrorCode.ALREADY_HAS_HOUSE,
"Profile already belongs to a household",
);
}
// Astronomically unlikely to collide (33^8 possibilities), but retried
// rather than assumed — a `@unique` constraint failure is the only fully
// reliable way to detect it.
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const house = await prisma.$transaction(async (tx) => {
const created = await tx.house.create({
data: {
name,
adminId: profileId,
inviteCode: generateInviteCode(),
},
});
await tx.userProfile.update({
where: { id: profileId },
data: { houseId: created.id },
});
return created;
});
return await getCurrentHouseOrThrow(house.id);
} catch (err) {
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
throw err;
}
}
throw new Error("Failed to generate a unique invite code after several attempts");
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
} }
// Astronomically unlikely to collide (33^8 possibilities), but retried
// rather than assumed — a `@unique` constraint failure is the only fully
// reliable way to detect it.
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const house = await prisma.$transaction(async (tx) => {
const created = await tx.house.create({
data: { name, adminId: profileId, inviteCode: generateInviteCode() },
});
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } });
return created;
});
return getCurrentHouseOrThrow(house.id);
} catch (err) {
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
throw err;
}
}
throw new Error("Failed to generate a unique invite code after several attempts");
} }
/** /**
@ -145,32 +118,21 @@ export async function joinHouse(
houseId: number | null, houseId: number | null,
inviteCode: string, inviteCode: string,
): Promise<HouseView> { ): Promise<HouseView> {
try { if (houseId !== null) {
if (houseId !== null) { throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
throw new HttpError(
409,
ErrorCode.ALREADY_HAS_HOUSE,
"Profile already belongs to a household",
);
}
const house = await prisma.house.findUnique({ where: { inviteCode } });
if (!house) {
throw new HttpError(
404,
ErrorCode.INVITE_CODE_NOT_FOUND,
"No household matches this invite code",
);
}
await prisma.userProfile.update({
where: { id: profileId },
data: { houseId: house.id },
});
return await getCurrentHouseOrThrow(house.id);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
} }
const house = await prisma.house.findUnique({ where: { inviteCode } });
if (!house) {
throw new HttpError(
404,
ErrorCode.INVITE_CODE_NOT_FOUND,
"No household matches this invite code",
);
}
await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } });
return getCurrentHouseOrThrow(house.id);
} }
/** /**
@ -187,38 +149,28 @@ export async function joinHouse(
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household. * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
*/ */
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> { export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
try { if (houseId === null) {
if (houseId === null) { throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const house = await findHouseOrThrow(houseId);
const remainingMembers = house.members.filter((member) => member.id !== profileId);
await prisma.$transaction(async (tx) => {
await tx.userProfile.update({
where: { id: profileId },
data: { houseId: null },
});
if (house.adminId !== profileId) {
return;
}
if (remainingMembers.length === 0) {
await tx.house.delete({ where: { id: house.id } });
return;
}
const nextAdmin = remainingMembers.reduce((oldest, member) =>
member.id < oldest.id ? member : oldest,
);
await tx.house.update({
where: { id: house.id },
data: { adminId: nextAdmin.id },
});
});
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
} }
const house = await findHouseOrThrow(houseId);
const remainingMembers = house.members.filter((member) => member.id !== profileId);
await prisma.$transaction(async (tx) => {
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } });
if (house.adminId !== profileId) {
return;
}
if (remainingMembers.length === 0) {
await tx.house.delete({ where: { id: house.id } });
return;
}
const nextAdmin = remainingMembers.reduce((oldest, member) =>
member.id < oldest.id ? member : oldest,
);
await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } });
});
} }
/** /**
@ -231,32 +183,21 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
* @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin. * @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
*/ */
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> { export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
try { if (houseId === null) {
if (houseId === null) { throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const house = await findHouseOrThrow(houseId);
if (house.adminId !== profileId) {
throw new HttpError(
403,
ErrorCode.NOT_HOUSE_ADMIN,
"Only the household's admin can delete it",
);
}
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
// but clearing it explicitly first keeps the outcome obvious without
// relying on that FK behavior being read alongside this function.
await prisma.$transaction([
prisma.userProfile.updateMany({
where: { houseId: house.id },
data: { houseId: null },
}),
prisma.house.delete({ where: { id: house.id } }),
]);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
} }
const house = await findHouseOrThrow(houseId);
if (house.adminId !== profileId) {
throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it");
}
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
// but clearing it explicitly first keeps the outcome obvious without
// relying on that FK behavior being read alongside this function.
await prisma.$transaction([
prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }),
prisma.house.delete({ where: { id: house.id } }),
]);
} }
/** /**
@ -274,115 +215,35 @@ export async function removeMember(
houseId: number | null, houseId: number | null,
targetMemberId: number, targetMemberId: number,
): Promise<HouseView> { ): Promise<HouseView> {
try { if (houseId === null) {
if (houseId === null) { throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const house = await findHouseOrThrow(houseId);
if (house.adminId !== profileId) {
throw new HttpError(
403,
ErrorCode.NOT_HOUSE_ADMIN,
"Only the household's admin can remove a member",
);
}
if (targetMemberId === profileId) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
"Use POST /house/leave to remove yourself",
);
}
if (!house.members.some((member) => member.id === targetMemberId)) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
}
await prisma.userProfile.update({
where: { id: targetMemberId },
data: { houseId: null },
});
return await getCurrentHouseOrThrow(house.id);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
} }
} const house = await findHouseOrThrow(houseId);
if (house.adminId !== profileId) {
/** throw new HttpError(
* Current enabled-source ids for a household — an empty array is normal 403,
* and is this household's starting state (opt-in: see `HouseSource` in ErrorCode.NOT_HOUSE_ADMIN,
* schema.prisma), not just "no preference set yet". "Only the household's admin can remove a member",
* );
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
*/
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const rows = await prisma.houseSource.findMany({
where: { houseId },
select: { sourceId: true },
});
return rows.map((row) => row.sourceId);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
} }
} if (targetMemberId === profileId) {
throw new HttpError(
/** 400,
* Replaces a household's full set of enabled recipe sources (not a merge — ErrorCode.VALIDATION_ERROR,
* same "replace, not merge" contract as `profile.service.ts`'s "Use POST /house/leave to remove yourself",
* `updateAllergies`). Every recipe-catalog tab (`recipe.service.ts`'s );
* `listRecipes`) filters against this set — a source left out here simply
* never shows its recipes to this household, in any tab.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if any `sourceId` doesn't match a reference `Source` row.
*/
export async function updateHouseSources(
houseId: number | null,
sourceIds: number[],
): Promise<number[]> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
if (sourceIds.length > 0) {
const found = await prisma.source.findMany({
where: { id: { in: sourceIds } },
select: { id: true },
});
const foundIds = new Set(found.map((source) => source.id));
const missing = sourceIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.SOURCE_NOT_FOUND,
`Unknown source id(s): ${missing.join(", ")}`,
);
}
}
await prisma.$transaction([
prisma.houseSource.deleteMany({ where: { houseId } }),
prisma.houseSource.createMany({
data: sourceIds.map((sourceId) => ({ houseId, sourceId })),
}),
]);
return sourceIds;
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
} }
if (!house.members.some((member) => member.id === targetMemberId)) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
}
await prisma.userProfile.update({ where: { id: targetMemberId }, data: { houseId: null } });
return getCurrentHouseOrThrow(house.id);
} }
/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */ /** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> { async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
try { return toHouseView(await findHouseOrThrow(houseId));
return toHouseView(await findHouseOrThrow(houseId));
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
} }
/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */ /** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
@ -404,16 +265,12 @@ function isUniqueInviteCodeViolation(err: unknown): boolean {
* `HOUSE_NOT_FOUND` HttpError. * `HOUSE_NOT_FOUND` HttpError.
*/ */
async function findHouseOrThrow(houseId: number) { async function findHouseOrThrow(houseId: number) {
try { const house = await prisma.house.findUnique({
const house = await prisma.house.findUnique({ where: { id: houseId },
where: { id: houseId }, include: houseWithMembers,
include: houseWithMembers, });
}); if (!house) {
if (!house) { throw new Error(`House ${houseId} referenced by a profile but not found`);
throw new Error(`House ${houseId} referenced by a profile but not found`);
}
return house;
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
} }
return house;
} }

View file

@ -1,67 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
auditBatchQuerySchema,
submitTrainingSuggestionsSchema,
workerBatchQuerySchema,
workerHeartbeatSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
import {
getAuditBatch,
getPendingCorrections,
recordWorkerHeartbeat,
submitTrainingSuggestions,
} from "./tech-step-worker.service.js";
/**
* Router mounted at `/internal/tech-steps` in app.ts — every route requires
* {@link requireInternalWorker}, never {@link requireAuth}
* (`middlewares/require-auth.ts`): this is `services/tech-step-llm-worker`
* authenticating as itself, not a user session. See that middleware's own
* doc comment for why the two are deliberately separate mechanisms.
*/
export const techStepWorkerRouter = Router();
techStepWorkerRouter.get(
"/audit-batch",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = auditBatchQuerySchema.parse(req.query);
res.status(200).json(await getAuditBatch(input.locale, input.limit));
}),
);
techStepWorkerRouter.get(
"/pending-corrections",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = workerBatchQuerySchema.parse(req.query);
res.status(200).json(await getPendingCorrections(input.limit));
}),
);
techStepWorkerRouter.post(
"/training-suggestions",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = submitTrainingSuggestionsSchema.parse(req.body);
res.status(201).json(await submitTrainingSuggestions(input));
}),
);
/**
* Liveness ping from the worker (which has no inbound HTTP surface of its
* own) — upserts its `WorkerHeartbeat` row so the admin monitoring board
* can show it as up / stale / down and surface its last job result. Sent
* on boot, on every scheduler tick, and after each job.
*/
techStepWorkerRouter.post(
"/heartbeat",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = workerHeartbeatSchema.parse(req.body);
await recordWorkerHeartbeat(input);
res.status(200).json({ ok: true });
}),
);

View file

@ -1,244 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
ErrorCode,
type PendingTechStepCorrectionView,
type SubmitTrainingSuggestionsInput,
type TechStepAuditClauseView,
type WorkerHeartbeatInput,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import {
CONFIDENCE_THRESHOLD,
techStepClassifier,
} from "../../lib/recipe-matching/tech-step-matcher.js";
/**
* Read/write surface `services/tech-step-llm-worker` calls through
* `/internal/tech-steps/*` (`tech-step-worker.routes.ts`, guarded by
* `requireInternalWorker`) — the worker has no Prisma client or database
* credentials of its own (see that service's own README), so every
* corrections/audit-sample read and every suggestion write goes through
* here rather than the worker touching this schema directly. Keeps
* `apps/api` the single owner of the schema/migrations, and keeps the
* worker a pure "read some text, run inference, post a suggestion" process
* with nothing to keep in sync if the schema changes shape.
*/
/**
* How many of the most recently created `Step`s {@link getAuditBatch} scans
* per call before filtering down to low-confidence clauses — a fixed
* recency-biased sample, not every `Step` in the database, to keep this
* endpoint's cost bounded regardless of how large the recipe catalog gets.
* Recently-added steps are also the steps most likely to still use
* vocabulary the training corpus hasn't caught up with yet, which is
* exactly what this audit is for. A smarter sampling strategy (e.g.
* weighted by how often a recipe is actually viewed/planned) is future
* work, not needed for this feature's first version.
*/
const AUDIT_SAMPLE_SIZE = 200;
/**
* Every low-confidence clause found across a recency-biased sample of
* existing `Step`s (see {@link AUDIT_SAMPLE_SIZE}), for
* `services/tech-step-llm-worker`'s `audit-low-confidence` job to get a
* second opinion on. "Low-confidence" mirrors exactly what
* `TechStepClassifierService._classifyClause` itself distrusts (a clause
* with an NER anchor but a classifier score under
* {@link CONFIDENCE_THRESHOLD}) — the same clauses that pipeline already
* has to fall back to keyword-anchor guessing for, not an arbitrary
* separate cutoff.
*/
export async function getAuditBatch(
locale: string,
limit: number,
): Promise<TechStepAuditClauseView[]> {
try {
const steps = await prisma.step.findMany({
orderBy: { id: "desc" },
take: AUDIT_SAMPLE_SIZE,
select: { id: true, recipeId: true, description: true },
});
const results: TechStepAuditClauseView[] = [];
for (const step of steps) {
if (results.length >= limit) break;
const clauses = await techStepClassifier.classifyClauses(step.description, locale);
for (const clause of clauses) {
if (results.length >= limit) break;
const isLowConfidence = clause.anchorUid !== null && clause.score < CONFIDENCE_THRESHOLD;
if (!isLowConfidence) continue;
results.push({
stepId: step.id,
recipeId: step.recipeId,
clauseText: clause.clauseText,
anchorKey: clause.anchorUid,
intentKey: clause.intentUid,
score: clause.score,
locale,
});
}
}
return results;
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Every `StepTechStepCorrection` not yet turned into a
* `TechStepTrainingSuggestion` (`consumedAt IS NULL`), oldest first — a
* FIFO queue the worker's `transform-corrections` job drains, `limit` at a
* time.
*
* `correctedTechStepId IS NOT NULL` on top of `consumedAt IS NULL`: a
* correction that *removes* a match ("no technique belongs here",
* `correctedTechStepId: null` — see `StepTechStepCorrection`'s schema doc
* comment) has no technique to propose new positive training data *for*.
* Surfacing it here would leave it permanently unconsumable (the worker
* has nothing to submit a suggestion for, so it would never stamp
* `consumedAt`, and it would keep re-appearing in every future batch
* forever) — excluded at the source instead, not filtered/skipped
* downstream by the worker.
*/
export async function getPendingCorrections(
limit: number,
): Promise<PendingTechStepCorrectionView[]> {
try {
const corrections = await prisma.stepTechStepCorrection.findMany({
where: { consumedAt: null, correctedTechStepId: { not: null } },
orderBy: { createdAt: "asc" },
take: limit,
include: {
step: { select: { id: true, recipeId: true, description: true } },
previousTechStep: { select: { key: true } },
correctedTechStep: { select: { key: true } },
},
});
return corrections.map((correction) => ({
id: correction.id,
stepId: correction.step.id,
recipeId: correction.step.recipeId,
clauseText: correction.step.description.slice(correction.start, correction.end),
start: correction.start,
end: correction.end,
previousTechStepKey: correction.previousTechStep?.key ?? null,
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
}));
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Persists a batch of `TechStepTrainingSuggestion`s and, for every
* suggestion sourced from a correction, stamps that correction's
* `consumedAt` in the same transaction — so a worker run that crashes
* partway through never leaves a correction consumed with no matching
* suggestion, or a suggestion created against a correction still (wrongly)
* eligible to be picked up again by the next run.
*
* @throws {HttpError} `404 TECH_STEP_NOT_FOUND` if any `techStepKey` in the
* batch doesn't match a reference `TechStep` — rejects the *whole* batch
* rather than skipping the bad entries, on the theory that a worker
* sending an unknown key is more likely a version-skew bug (its own
* taxonomy copy, `services/tech-step-llm-worker/src/tech-step-taxonomy.ts`,
* drifting from this API's `TechStep` catalog) than a one-off it should
* silently tolerate.
*/
export async function submitTrainingSuggestions(
input: SubmitTrainingSuggestionsInput,
): Promise<{ created: number }> {
try {
const techStepKeys = [
...new Set(input.suggestions.map((suggestion) => suggestion.techStepKey)),
];
const techSteps = await prisma.techStep.findMany({
where: { key: { in: techStepKeys } },
select: { id: true, key: true },
});
const techStepIdByKey = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
const missingKeys = techStepKeys.filter((key) => !techStepIdByKey.has(key));
if (missingKeys.length > 0) {
throw new HttpError(
404,
ErrorCode.TECH_STEP_NOT_FOUND,
`Unknown techStepKey(s): ${missingKeys.join(", ")}`,
);
}
await prisma.$transaction(async (tx) => {
for (const suggestion of input.suggestions) {
// Non-null by construction — every key in `input.suggestions` was
// just confirmed present in `techStepIdByKey` above (the `missingKeys`
// check would have thrown otherwise).
const techStepId = techStepIdByKey.get(suggestion.techStepKey);
if (techStepId === undefined) continue;
await tx.techStepTrainingSuggestion.create({
data: {
techStepId,
locale: suggestion.locale,
suggestedSynonyms: suggestion.suggestedSynonyms,
suggestedUtterances: suggestion.suggestedUtterances,
sourceType: suggestion.sourceType,
sourceCorrectionId: suggestion.sourceCorrectionId ?? null,
},
});
if (suggestion.sourceCorrectionId !== null && suggestion.sourceCorrectionId !== undefined) {
await tx.stepTechStepCorrection.update({
where: { id: suggestion.sourceCorrectionId },
data: { consumedAt: new Date() },
});
}
}
});
return { created: input.suggestions.length };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* The one worker with a `WorkerHeartbeat` row today — a fixed key, not
* something the caller supplies (only one worker exists, and letting it
* name itself would just be a spoofing surface behind the same shared
* secret).
*/
const WORKER_KEY = "tech-step-llm-worker";
/**
* Upserts `services/tech-step-llm-worker`'s heartbeat row (see
* `POST /internal/tech-steps/heartbeat`). Every ping bumps `lastSeenAt`; a
* `"job"` ping also records `lastRunAt` + a small `lastResult` summary so
* the admin monitoring board can show what the worker last did and whether
* it worked.
*/
export async function recordWorkerHeartbeat(input: WorkerHeartbeatInput): Promise<void> {
try {
const now = new Date();
const jobResult: Prisma.InputJsonValue | undefined =
input.event === "job"
? { job: input.job ?? null, ok: input.ok ?? null, counts: input.counts ?? {} }
: undefined;
await prisma.workerHeartbeat.upsert({
where: { workerKey: WORKER_KEY },
create: {
workerKey: WORKER_KEY,
lastSeenAt: now,
lastRunAt: input.event === "job" ? now : null,
lastResult: jobResult,
},
update: {
lastSeenAt: now,
...(input.event === "job" ? { lastRunAt: now, lastResult: jobResult } : {}),
},
});
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -1,31 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { Router } from "express";
import { getLatestApkUrl, getLatestVersion } from "./mobile.service.js";
/**
* Router mounted at `/mobile` in app.ts. Both routes are deliberately
* public (no `requireAuth`): the landing page's download link is shown to
* logged-out visitors, and the native update check (issue #66) runs on
* every app start, session or not. Both exist purely to work around
* Forgejo's Releases API sending no CORS headers — see `mobile.service.ts`'s
* own doc comment for the full story.
*/
export const mobileRouter = Router();
mobileRouter.get(
"/latest-release",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json({ version: await getLatestVersion() });
}),
);
// A plain redirect, not JSON — lets any caller (the landing page's `<a>`,
// the update banner's "Mettre à jour" link) use a static href with zero
// client-side fetch, and always resolves to something useful even if
// Forgejo is briefly unreachable (see getLatestApkUrl's fallback).
mobileRouter.get(
"/download",
wrapAsyncHandler(async (_req, res) => {
res.redirect(302, await getLatestApkUrl());
}),
);

View file

@ -1,75 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode } from "@batch-cooking/shared";
/**
* Forgejo's own Releases API for this repo — public, unauthenticated (the
* same fact `local-verification-while-ci-disabled` already leans on for
* CI-status checks without a token). **Must be called server-side, not
* from the browser**: verified live that Forgejo sends no
* `Access-Control-Allow-Origin` header on it, so a direct `fetch()` from
* `apps/web` fails with an opaque CORS error even though the endpoint
* itself is perfectly reachable — this whole module exists to proxy it
* from a context CORS doesn't apply to.
*/
const LATEST_RELEASE_API_URL =
"https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest";
/**
* Human-facing Forgejo release page — safe fallback for {@link getLatestApkUrl}
* when no matching asset is found (a release with nothing attached yet,
* Forgejo briefly unreachable, ...). Never a dead link, just a slightly
* less direct one: the visitor lands on the release itself and can find
* the asset from there.
*/
const LATEST_RELEASE_PAGE_URL = "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest";
/** Stable filename `release.yml` always attaches the signed APK under — see that workflow's own comment. */
const APK_ASSET_NAME = "batchcooking.apk";
interface ForgejoReleaseAsset {
name: string;
browser_download_url: string;
}
interface ForgejoRelease {
tag_name: string;
assets: ForgejoReleaseAsset[];
}
async function fetchLatestRelease(): Promise<ForgejoRelease> {
const response = await fetch(LATEST_RELEASE_API_URL);
if (!response.ok) {
throw new HttpError(
502,
ErrorCode.INTERNAL_ERROR,
`Forgejo releases API responded ${response.status}`,
);
}
return (await response.json()) as ForgejoRelease;
}
/**
* The latest tagged Android build's version (e.g. `"v1.4.2"`) — used by the
* native app's update check (issue #66, `useAppUpdateCheck.ts`).
*/
export async function getLatestVersion(): Promise<string> {
const release = await fetchLatestRelease();
return release.tag_name;
}
/**
* Where a "download the app" click should end up right now: the actual
* signed-APK asset of the latest release if one is attached, else
* {@link LATEST_RELEASE_PAGE_URL} as a safe fallback. Never throws — a
* download link must always go *somewhere* useful, even when Forgejo is
* briefly unreachable.
*/
export async function getLatestApkUrl(): Promise<string> {
try {
const release = await fetchLatestRelease();
const apk = release.assets.find((asset) => asset.name === APK_ASSET_NAME);
return apk?.browser_download_url ?? LATEST_RELEASE_PAGE_URL;
} catch {
return LATEST_RELEASE_PAGE_URL;
}
}

View file

@ -1,21 +1,10 @@
import { parseDateOnly } from "@batch-cooking/date-tools"; import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { import { ErrorCode, addPlanningItemSchema, getPlanningByDateSchema } from "@batch-cooking/shared";
addPlanningItemSchema,
assignLeftoverSchema,
ErrorCode,
getPlanningByDateSchema,
} from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js";
addPlanningItem,
assignLeftoverPlanningItem,
discardLeftover,
getPlanningForDate,
removePlanningItem,
} from "./planning.service.js";
/** Router mounted at `/planning` in app.ts. */ /** Router mounted at `/planning` in app.ts. */
export const planningRouter = Router(); export const planningRouter = Router();
@ -80,44 +69,6 @@ planningRouter.post(
}), }),
); );
/**
* Serves part of another planning item's leftover pool on a slot — creates
* a "leftovers" `PlanningItem` (see {@link assignLeftoverPlanningItem}).
* `POST /items/leftover`, not `/items/:id/...`: the `:id` here would be the
* *target* slot, which doesn't exist yet — the source is in the body.
*/
planningRouter.post(
"/items/leftover",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = assignLeftoverSchema.parse(req.body);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const { id: viewerId, houseId } = res.locals.userProfile;
const item = await assignLeftoverPlanningItem(houseId, viewerId, date, input);
res.status(201).json(item);
}),
);
/** Throws away item `:id`'s whole remaining leftover pool (idempotent — see {@link discardLeftover}). */
planningRouter.patch(
"/items/:id/discard-leftover",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parsePlanningItemId(req.params.id);
const { id: viewerId, houseId } = res.locals.userProfile;
const item = await discardLeftover(id, viewerId, houseId);
res.status(200).json(item);
}),
);
/** Removes one recipe from a planning slot. */ /** Removes one recipe from a planning slot. */
planningRouter.delete( planningRouter.delete(
"/items/:id", "/items/:id",

View file

@ -2,46 +2,13 @@ import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-too
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { import {
type AddPlanningItemInput, type AddPlanningItemInput,
type AssignLeftoverInput,
computeAvailableLeftover,
computeBatchedPortions,
ErrorCode, ErrorCode,
type PlanningItemView, type PlanningItemView,
type PlanningView, type PlanningView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { assertRecipeVisible } from "../recipe/recipe.service.js"; import { assertRecipeVisible } from "../recipe/recipe.service.js";
/**
* One `PlanningItem` row (with its recipe resolved to `{id, name}`) shaped
* into the wire {@link PlanningItemView}. The three read/write paths below
* that return an item — `getPlanningForDate`, `addPlanningItem`,
* `assignLeftoverPlanningItem`, `discardLeftover` — all go through this so
* the shape is defined once.
*/
function toPlanningItemView(item: {
id: number;
weekDay: string;
meal: string;
portions: number;
portionsRequested: number;
discardedPortions: number;
sourcePlanningItemId: number | null;
recipe: { id: number; name: string };
}): PlanningItemView {
return {
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
portionsRequested: item.portionsRequested,
discardedPortions: item.discardedPortions,
sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null,
recipe: item.recipe,
};
}
/** /**
* Finds the household's planning that covers `date` and shapes it into a * Finds the household's planning that covers `date` and shapes it into a
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is * {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
@ -60,51 +27,49 @@ export async function getPlanningForDate(
houseId: number | null, houseId: number | null,
date: DateTime, date: DateTime,
): Promise<PlanningView | null> { ): Promise<PlanningView | null> {
try { if (houseId === null) {
if (houseId === null) { return null;
return null;
}
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
// component) — comparing against a UTC-midnight JS `Date` lines up with
// how Postgres stores/returns them, regardless of the server's local
// timezone.
const dateOnly = toDateOnly(date).toJSDate();
const planning = await prisma.planning.findFirst({
where: {
houseId,
startDate: { lte: dateOnly },
finishDate: { gte: dateOnly },
},
// A household should never have two plannings covering the same day,
// but nothing in the schema enforces that yet — pick the most recently
// started one rather than letting the query fail if it ever happens.
orderBy: { startDate: "desc" },
include: {
items: {
include: { recipe: { select: { id: true, name: true } } },
},
},
});
if (!planning) {
return null;
}
return {
id: planning.id,
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
items: planning.items.map(toPlanningItemView),
};
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
} }
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
// component) — comparing against a UTC-midnight JS `Date` lines up with
// how Postgres stores/returns them, regardless of the server's local
// timezone.
const dateOnly = toDateOnly(date).toJSDate();
const planning = await prisma.planning.findFirst({
where: {
houseId,
startDate: { lte: dateOnly },
finishDate: { gte: dateOnly },
},
// A household should never have two plannings covering the same day,
// but nothing in the schema enforces that yet — pick the most recently
// started one rather than letting the query fail if it ever happens.
orderBy: { startDate: "desc" },
include: {
items: {
include: { recipe: { select: { id: true, name: true } } },
},
},
});
if (!planning) {
return null;
}
return {
id: planning.id,
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
items: planning.items.map((item) => ({
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
recipe: item.recipe,
})),
};
} }
/** /**
@ -123,24 +88,14 @@ export async function getPlanningForDate(
* scale rather than adding a migration + retry-on-conflict loop for it. * scale rather than adding a migration + retry-on-conflict loop for it.
*/ */
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) { async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
try { const startDate = weekStart.toJSDate();
const startDate = weekStart.toJSDate(); const existing = await prisma.planning.findFirst({ where: { houseId, startDate } });
const existing = await prisma.planning.findFirst({ if (existing) {
where: { houseId, startDate }, return existing;
});
if (existing) {
return existing;
}
return await prisma.planning.create({
data: {
houseId,
startDate,
finishDate: weekStart.plus({ days: 6 }).toJSDate(),
},
});
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
} }
return prisma.planning.create({
data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate() },
});
} }
/** /**
@ -164,52 +119,32 @@ export async function addPlanningItem(
date: DateTime, date: DateTime,
input: AddPlanningItemInput, input: AddPlanningItemInput,
): Promise<PlanningItemView> { ): Promise<PlanningItemView> {
try { if (houseId === null) {
if (houseId === null) { throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
// `assertRecipeVisible` already guaranteed the recipe exists and is
// visible — this second read just pulls its written yield, needed to
// turn "portions requested" into "portions to actually prepare"
// (a whole number of batches, see `computeBatchedPortions`). The
// `?? 1` never triggers in practice (the assert above threw otherwise);
// it only keeps TypeScript happy about `findUnique`'s `| null`.
const recipe = await prisma.recipe.findUnique({
where: { id: input.recipeId },
select: { portions: true },
});
const { portions } = computeBatchedPortions(recipe?.portions ?? 1, input.portionsRequested);
const weekStart = getWeekStart(toDateOnly(date));
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
const item = await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: input.weekDay,
meal: input.meal,
recipeId: input.recipeId,
portions,
portionsRequested: input.portionsRequested,
},
include: { recipe: { select: { id: true, name: true } } },
});
analytics.recordEvent("planning.item_added", {
actorId: viewerId,
context: {
recipeId: input.recipeId,
portions,
portionsRequested: input.portionsRequested,
},
});
return toPlanningItemView(item);
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
} }
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
const weekStart = getWeekStart(toDateOnly(date));
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
const item = await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: input.weekDay,
meal: input.meal,
recipeId: input.recipeId,
portions: input.portions,
},
include: { recipe: { select: { id: true, name: true } } },
});
return {
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
recipe: item.recipe,
};
} }
/** /**
@ -221,152 +156,12 @@ export async function addPlanningItem(
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere. * @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
*/ */
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> { export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
try {
const item = await prisma.planningItem.findUnique({
where: { id },
include: { planning: true },
});
if (!item || houseId === null || item.planning.houseId !== houseId) {
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
}
await prisma.planningItem.delete({ where: { id } });
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}
/**
* Loads a `PlanningItem` (scoped to `houseId`) with everything
* {@link computeAvailableLeftover} needs — its own portion fields plus how
* much of its pool has already been served elsewhere (`leftoverUses`) — or
* throws `404 PLANNING_ITEM_NOT_FOUND` (same "don't confirm what exists"
* posture as `removePlanningItem`). Shared by
* {@link assignLeftoverPlanningItem} and {@link discardLeftover}.
*/
async function loadItemWithLeftoverPool(id: number, houseId: number | null) {
const item = await prisma.planningItem.findUnique({ const item = await prisma.planningItem.findUnique({
where: { id }, where: { id },
include: { include: { planning: true },
planning: { select: { houseId: true } },
leftoverUses: { select: { portions: true } },
recipe: { select: { id: true, name: true } },
},
}); });
if (!item || houseId === null || item.planning.houseId !== houseId) { if (!item || houseId === null || item.planning.houseId !== houseId) {
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`); throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
} }
return item; await prisma.planningItem.delete({ where: { id } });
}
/**
* Serves `input.portions` of another item's leftover pool on the
* `(date, weekDay, meal)` slot — creates a "leftovers" `PlanningItem`
* (`sourcePlanningItemId` set) on the same recipe as its source. Nothing
* new is cooked for it, so `portions === portionsRequested` and it excludes
* itself from the shopping list / cooking session (see those services'
* `where: { sourcePlanningItemId: null }`).
*
* The source's `recipeId` is copied, never taken from the client (a
* leftovers item can only be its source's recipe). `input.portions` is
* re-checked against the pool actually still available
* ({@link computeAvailableLeftover}) — trusting a client value here would
* let a household conjure portions it never cooked.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null`.
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `sourceItemId` doesn't exist, belongs to another household, or is itself a leftovers item (no leftovers-on-leftovers).
* @throws {HttpError} `409 LEFTOVER_QUANTITY_EXCEEDED` if `portions` exceeds the source's still-available pool.
*/
export async function assignLeftoverPlanningItem(
houseId: number | null,
viewerId: number,
date: DateTime,
input: AssignLeftoverInput,
): Promise<PlanningItemView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const source = await loadItemWithLeftoverPool(input.sourceItemId, houseId);
if (source.sourcePlanningItemId !== null) {
// A leftovers item has no pool of its own — serving leftovers cooks
// nothing, so it can't be a source for more.
throw new HttpError(
404,
ErrorCode.PLANNING_ITEM_NOT_FOUND,
`Planning item ${input.sourceItemId} is not a leftovers source`,
);
}
const available = computeAvailableLeftover(source);
if (input.portions > available) {
throw new HttpError(
409,
ErrorCode.LEFTOVER_QUANTITY_EXCEEDED,
`Item ${source.id} has ${available} leftover portion(s) available, asked for ${input.portions}`,
);
}
const weekStart = getWeekStart(toDateOnly(date));
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
const item = await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: input.weekDay,
meal: input.meal,
recipeId: source.recipeId,
portions: input.portions,
portionsRequested: input.portions,
sourcePlanningItemId: source.id,
},
include: { recipe: { select: { id: true, name: true } } },
});
analytics.recordEvent("planning.leftover_assigned", {
actorId: viewerId,
context: { recipeId: source.recipeId, portions: input.portions, sourceItemId: source.id },
});
return toPlanningItemView(item);
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}
/**
* Throws away the whole still-available leftover pool of item `id`
* (`discardedPortions += available`) — the basket's "jeter" button.
* Idempotent: an item with nothing left to discard is returned unchanged,
* no event recorded.
*
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` — same guard as `removePlanningItem`.
*/
export async function discardLeftover(
id: number,
viewerId: number,
houseId: number | null,
): Promise<PlanningItemView> {
try {
const item = await loadItemWithLeftoverPool(id, houseId);
const available = computeAvailableLeftover(item);
if (available === 0) {
return toPlanningItemView(item);
}
const updated = await prisma.planningItem.update({
where: { id },
data: { discardedPortions: item.discardedPortions + available },
include: { recipe: { select: { id: true, name: true } } },
});
analytics.recordEvent("planning.leftover_discarded", {
actorId: viewerId,
context: { recipeId: item.recipe.id, portions: available },
});
return toPlanningItemView(updated);
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
} }

View file

@ -9,18 +9,8 @@ import { prisma } from "../../db/prisma.js";
* just to read it. * just to read it.
*/ */
export async function getPreferences(userProfileId: number): Promise<PreferencesView> { export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
try { const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
const preferences = await prisma.userPreference.findUnique({ return { theme: preferences?.theme ?? "SYSTEM" };
where: { userProfileId },
});
return { theme: preferences?.theme ?? "SYSTEM" };
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
} }
/** /**
@ -32,14 +22,10 @@ export async function updatePreferences(
userProfileId: number, userProfileId: number,
theme: ThemePreference, theme: ThemePreference,
): Promise<PreferencesView> { ): Promise<PreferencesView> {
try { const preferences = await prisma.userPreference.upsert({
const preferences = await prisma.userPreference.upsert({ where: { userProfileId },
where: { userProfileId }, create: { userProfileId, theme },
create: { userProfileId, theme }, update: { theme },
update: { theme }, });
}); return { theme: preferences.theme };
return { theme: preferences.theme };
} catch (err) {
throw err; // see getPreferences()'s catch comment above
}
} }

View file

@ -1,9 +1,5 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { import {
type CompletedToursView,
completeTourSchema,
ErrorCode,
updateAllergiesSchema, updateAllergiesSchema,
updateDietSchema, updateDietSchema,
updateDislikedIngredientsSchema, updateDislikedIngredientsSchema,
@ -12,9 +8,7 @@ import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { import {
getAllergyIds, getAllergyIds,
getCompletedTourKeys,
getDislikedIngredientIds, getDislikedIngredientIds,
markTourSeen,
updateAllergies, updateAllergies,
updateDiet, updateDiet,
updateDislikedIngredients, updateDislikedIngredients,
@ -23,14 +17,6 @@ import {
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */ /** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
export const profileRouter = Router(); export const profileRouter = Router();
/** Express types a route param `string | undefined` even for a segment that always matches when the route does — satisfies TS, unreachable in practice. */
function requireParam(value: string | undefined): string {
if (value === undefined) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Missing route parameter");
}
return value;
}
/** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */ /** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */
profileRouter.patch( profileRouter.patch(
"/diet", "/diet",
@ -84,29 +70,3 @@ profileRouter.patch(
res.status(200).json(ids); res.status(200).json(ids);
}), }),
); );
/** Which guided-tour scenarios this profile has already seen — the web tour engine reads this once per session (issues #42/#44). */
profileRouter.get(
"/tours",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
const completedTourKeys = await getCompletedTourKeys(res.locals.userProfile.id);
res.status(200).json({ completedTourKeys } satisfies CompletedToursView);
}),
);
/**
* Marks a guided-tour scenario as seen — idempotent. An empty body means
* "completed"; `{ "skipped": true }` means the user dismissed it (different
* analytics event, same "don't re-nag" effect). 404s on an unknown/disabled
* tour key.
*/
profileRouter.post(
"/tours/:tourKey/complete",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const { skipped } = completeTourSchema.parse(req.body ?? {});
await markTourSeen(res.locals.userProfile.id, requireParam(req.params.tourKey), skipped);
res.status(204).end();
}),
);

View file

@ -1,7 +1,6 @@
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared"; import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { toSafeProfile } from "../../lib/safe-profile.js"; import { toSafeProfile } from "../../lib/safe-profile.js";
/** /**
@ -15,39 +14,27 @@ export async function updateDiet(
userProfileId: number, userProfileId: number,
dietId: number | null, dietId: number | null,
): Promise<SafeUserProfile> { ): Promise<SafeUserProfile> {
try { if (dietId !== null) {
if (dietId !== null) { const diet = await prisma.diet.findUnique({ where: { id: dietId } });
const diet = await prisma.diet.findUnique({ where: { id: dietId } }); if (!diet) {
if (!diet) { throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
}
} }
const profile = await prisma.userProfile.update({
where: { id: userProfileId },
data: { dietId },
});
return toSafeProfile(profile);
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
} }
const profile = await prisma.userProfile.update({
where: { id: userProfileId },
data: { dietId },
});
return toSafeProfile(profile);
} }
/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */ /** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */
export async function getAllergyIds(userProfileId: number): Promise<number[]> { export async function getAllergyIds(userProfileId: number): Promise<number[]> {
try { const rows = await prisma.userProfileAllergy.findMany({
const rows = await prisma.userProfileAllergy.findMany({ where: { userProfileId },
where: { userProfileId }, select: { allergyId: true },
select: { allergyId: true }, });
}); return rows.map((row) => row.allergyId);
return rows.map((row) => row.allergyId);
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
} }
/** /**
@ -62,47 +49,39 @@ export async function updateAllergies(
userProfileId: number, userProfileId: number,
allergyIds: number[], allergyIds: number[],
): Promise<number[]> { ): Promise<number[]> {
try { if (allergyIds.length > 0) {
if (allergyIds.length > 0) { const found = await prisma.allergy.findMany({
const found = await prisma.allergy.findMany({ where: { id: { in: allergyIds } },
where: { id: { in: allergyIds } }, select: { id: true },
select: { id: true }, });
}); const foundIds = new Set(found.map((allergy) => allergy.id));
const foundIds = new Set(found.map((allergy) => allergy.id)); const missing = allergyIds.filter((id) => !foundIds.has(id));
const missing = allergyIds.filter((id) => !foundIds.has(id)); if (missing.length > 0) {
if (missing.length > 0) { throw new HttpError(
throw new HttpError( 404,
404, ErrorCode.ALLERGY_NOT_FOUND,
ErrorCode.ALLERGY_NOT_FOUND, `Unknown allergy id(s): ${missing.join(", ")}`,
`Unknown allergy id(s): ${missing.join(", ")}`, );
);
}
} }
await prisma.$transaction([
prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }),
prisma.userProfileAllergy.createMany({
data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })),
}),
]);
return allergyIds;
} catch (err) {
throw err; // see updateDiet()'s catch comment above
} }
await prisma.$transaction([
prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }),
prisma.userProfileAllergy.createMany({
data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })),
}),
]);
return allergyIds;
} }
/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */ /** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> { export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
try { const rows = await prisma.userProfileDislikedIngredient.findMany({
const rows = await prisma.userProfileDislikedIngredient.findMany({ where: { userProfileId },
where: { userProfileId }, select: { ingredientId: true },
select: { ingredientId: true }, });
}); return rows.map((row) => row.ingredientId);
return rows.map((row) => row.ingredientId);
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
} }
/** /**
@ -116,91 +95,28 @@ export async function updateDislikedIngredients(
userProfileId: number, userProfileId: number,
dislikedIngredientIds: number[], dislikedIngredientIds: number[],
): Promise<number[]> { ): Promise<number[]> {
try { if (dislikedIngredientIds.length > 0) {
if (dislikedIngredientIds.length > 0) { const found = await prisma.ingredient.findMany({
const found = await prisma.ingredient.findMany({ where: { id: { in: dislikedIngredientIds } },
where: { id: { in: dislikedIngredientIds } }, select: { id: true },
select: { id: true }, });
}); const foundIds = new Set(found.map((ingredient) => ingredient.id));
const foundIds = new Set(found.map((ingredient) => ingredient.id)); const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id)); if (missing.length > 0) {
if (missing.length > 0) { throw new HttpError(
throw new HttpError( 404,
404, ErrorCode.INGREDIENT_NOT_FOUND,
ErrorCode.INGREDIENT_NOT_FOUND, `Unknown ingredient id(s): ${missing.join(", ")}`,
`Unknown ingredient id(s): ${missing.join(", ")}`, );
);
}
} }
await prisma.$transaction([
prisma.userProfileDislikedIngredient.deleteMany({
where: { userProfileId },
}),
prisma.userProfileDislikedIngredient.createMany({
data: dislikedIngredientIds.map((ingredientId) => ({
userProfileId,
ingredientId,
})),
}),
]);
return dislikedIngredientIds;
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/**
* The `key`s of every guided-tour scenario this profile has already seen
* (completed or skipped — both leave a `TourProgress` row). The web tour
* engine reads this once per session to decide which tours to auto-start
* (issues #42/#44).
*/
export async function getCompletedTourKeys(userProfileId: number): Promise<string[]> {
try {
const rows = await prisma.tourProgress.findMany({
where: { userProfileId },
select: { scenario: { select: { key: true } } },
});
return rows.map((row) => row.scenario.key);
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/**
* Marks a guided-tour scenario as seen by this profile — idempotent upsert,
* safe to call again (e.g. the engine retries after a flaky network). Emits
* `tour.completed` or `tour.skipped` for the admin dashboard (issue #44's
* analytics — the dashboard renders any new `AnalyticsEvent` type
* generically, no frontend change needed).
*
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `tourKey` doesn't match an
* **enabled** `TourScenario` (a disabled tour can't be completed).
*/
export async function markTourSeen(
userProfileId: number,
tourKey: string,
skipped: boolean,
): Promise<void> {
try {
const scenario = await prisma.tourScenario.findUnique({ where: { key: tourKey } });
if (!scenario?.enabled) {
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No enabled tour with key ${tourKey}`);
}
await prisma.tourProgress.upsert({
where: { userProfileId_scenarioId: { userProfileId, scenarioId: scenario.id } },
update: {},
create: { userProfileId, scenarioId: scenario.id },
});
analytics.recordEvent(skipped ? "tour.skipped" : "tour.completed", {
actorId: userProfileId,
context: { tourKey },
});
} catch (err) {
throw err; // see updateDiet()'s catch comment above
} }
await prisma.$transaction([
prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId } }),
prisma.userProfileDislikedIngredient.createMany({
data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId })),
}),
]);
return dislikedIngredientIds;
} }

Some files were not shown because too many files have changed in this diff Show more