Compare commits

..

No commits in common. "main" and "fix/tech-step-correction-metadata-discoverability" have entirely different histories.

409 changed files with 2073 additions and 34809 deletions

View file

@ -15,43 +15,6 @@ 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;

View file

@ -12,40 +12,19 @@ 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 # Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
# exercise the success path (matching secret), not just the "unset" # exercise the success path (matching secret), not just the "unset"
# rejection every environment that doesn't set this gets by default. # rejection every environment that doesn't set this gets by default.
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+" 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 # Shared between the `test` job's own uvicorn step (below) and apps/api's
# IntentServiceClient — that step runs `uvicorn` as a plain background # IntentServiceClient — see the `test` job for why this can't be a
# process (not a `services:` block or even a container) because CI can # `services:` container like postgres above (GitHub Actions can only pull
# only pull a published image, not build # a published image, not build services/tech-step-intent-service/Dockerfile).
# services/tech-step-intent-service/Dockerfile from this checkout.
INTENT_SERVICE_BASE_URL: "http://localhost:8000" INTENT_SERVICE_BASE_URL: "http://localhost:8000"
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+" 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 # Five independent jobs, no needs: between them — each starts in parallel
@ -54,11 +33,11 @@ jobs:
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,119 +47,46 @@ 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 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true enable-cache: true
# A `services:` block can only pull an already-published image — it # `services:` (like the `postgres` container above) can only pull an
# can't build services/tech-step-intent-service/Dockerfile from this # already-published image — it can't build
# checkout. Running `uvicorn` as a plain background step instead: it # services/tech-step-intent-service/Dockerfile from this checkout.
# keeps running for the rest of this job (steps in one job share the # Running `uvicorn` as a plain background step instead: it keeps
# same process tree), and `pnpm --filter api test` below needs a real # running for the rest of this job (GitHub Actions steps in one job
# instance to talk to per this repo's "never mock an internal service" # share the same runner process tree), and `pnpm --filter api test`
# test convention — same "real dependency, started by the job itself" # below needs a real instance to talk to per this repo's "never mock
# posture as the Postgres step above, not a mock HTTP server. # an internal service" test convention — same reasoning as the real
# `postgres` container just above, not a mock HTTP server.
- name: Install services/tech-step-intent-service - name: Install services/tech-step-intent-service
working-directory: services/tech-step-intent-service working-directory: services/tech-step-intent-service
run: uv sync --frozen run: uv sync --frozen
@ -190,14 +96,11 @@ jobs:
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 & uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
# `/health` only returns 200 once this service has finished # `/health` only returns 200 once this service has finished
# training itself from scratch (no model ever persisted to disk — # training itself from scratch (no model ever persisted to disk —
# see its own README) — measured at ~3min combined (fr+en) against # see its own README) — measured at ~540s (fr) / ~390s (en),
# the current ~74-technique corpus, down from ~930s once # ~930s combined, against the current ~74-technique corpus (see
# `spacy.TextCatBOW.v3`'s `length` stopped being left at its # docker-compose.yml's healthcheck for the same reasoning and why
# 262144 default (see `_TEXTCAT_MODEL_LENGTH` in # this grew slightly from the original ~670s).
# `locale_pipeline.py`, and docker-compose.yml's healthcheck for timeout 1200 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
# 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
@ -206,11 +109,13 @@ jobs:
intent-service-test: intent-service-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- uses: https://github.com/astral-sh/setup-uv@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true enable-cache: true
- name: Install services/tech-step-intent-service - name: Install services/tech-step-intent-service
@ -223,11 +128,11 @@ jobs:
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 +143,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 +163,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

9
.gitignore vendored
View file

@ -70,7 +70,6 @@ web_modules/
.env.* .env.*
!.env.example !.env.example
!.env.test.example !.env.test.example
!.env.capacitor.example
# Python virtualenvs/caches for services/tech-step-intent-service (this repo # Python virtualenvs/caches for services/tech-step-intent-service (this repo
# is otherwise all-Node — see that service's own .gitignore for the rest; # is otherwise all-Node — see that service's own .gitignore for the rest;
@ -134,11 +133,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
@ -169,6 +163,3 @@ tmp-mockups/
apps/web/cypress/screenshots/ apps/web/cypress/screenshots/
apps/web/cypress/videos/ apps/web/cypress/videos/
apps/web/cypress/downloads/ apps/web/cypress/downloads/
apps/admin-web/cypress/screenshots/
apps/admin-web/cypress/videos/
apps/admin-web/cypress/downloads/

View file

@ -207,18 +207,10 @@ depuis la même origine. Les deux autres services du fichier
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port (`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
au host — voir leurs propres README pour leur rôle. au host — voir leurs propres README pour leur rôle.
**Registre d'image** : la CI de release (voir plus bas) construit et pousse les **Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
trois images de service — `batchcooking-app`, `batchcooking-intent-service`, production est reliée directement au dépôt Git et reconstruit elle-même
`batchcooking-llm-worker` — sur le **registre de conteneurs intégré à Forgejo** `docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
(`git.dev.kyuno.fr/kyuno/…`), taguées à la fois avec le tag de release (`vX.Y.Z`) donc aucune image nulle part.
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 +221,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)

View file

@ -11,7 +11,7 @@ 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 # INTENT_SERVICE_BASE_URL=http://localhost:8000
# Required — services/tech-step-intent-service must be running locally (see # Required — services/tech-step-intent-service must be running locally (see
@ -25,22 +25,3 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Generate your own the same way as JWT_SECRET above; must match the # Generate your own the same way as JWT_SECRET above; must match the
# worker's own INTERNAL_WORKER_SECRET. # worker's own INTERNAL_WORKER_SECRET.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # 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

@ -27,10 +27,3 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# success path (a request with a matching secret); every other test runs # success path (a request with a matching secret); every other test runs
# fine without it. Any value at least 32 chars works locally. # fine without it. Any value at least 32 chars works locally.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # 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

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

@ -49,6 +49,7 @@ model Diet {
key String @unique key String @unique
users UserProfile[] users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[] ingredients IngredientDiet[]
@@map("diet") @@map("diet")
@ -102,10 +103,6 @@ model UserProfile {
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 +121,10 @@ 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 /// Tech-step corrections this profile has submitted (any profile that can
/// view a recipe may correct its tech-step matches, not just its author — /// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`). /// see `StepTechStepCorrection.correctorId`).
techStepCorrections StepTechStepCorrection[] 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 +192,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 +205,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")
} }
@ -365,10 +319,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 +327,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 +348,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,38 +514,6 @@ 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[]
@ -590,10 +524,7 @@ model Ingredient {
/// Mentions of this ingredient detected in a step's free text alongside a /// Mentions of this ingredient detected in a step's free text alongside a
/// technique — see `StepTechStepIngredient`. /// technique — see `StepTechStepIngredient`.
stepTechSteps 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")
} }
@ -686,17 +617,12 @@ model Unit {
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")
@ -721,33 +647,6 @@ model TechStep {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique key String @unique
/// `true` = a "placeholder" technique: a free-text technique a user typed
/// while correcting a step, because nothing in the seeded catalog matched
/// (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[] steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong) /// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`. /// match — see `StepTechStepCorrection.previousTechStepId`.
@ -758,10 +657,7 @@ model TechStep {
/// Training-corpus suggestions targeting this technique — see /// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`. /// `TechStepTrainingSuggestion`.
trainingSuggestions 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")
} }
@ -860,9 +756,6 @@ model StepTechStep {
/// Utensils mentioned in the same clause as this technique occurrence — /// Utensils mentioned in the same clause as this technique occurrence —
/// see `StepTechStepUtensil`. /// see `StepTechStepUtensil`.
utensils StepTechStepUtensil[] utensils StepTechStepUtensil[]
/// Temperatures mentioned in the same clause as this technique occurrence
/// — see `StepTechStepTemperature`.
temperatures StepTechStepTemperature[]
@@id([stepId, order]) @@id([stepId, order])
@@map("step_tech_step") @@map("step_tech_step")
@ -921,38 +814,6 @@ model StepTechStepUtensil {
@@map("step_tech_step_utensil") @@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 — /// One user-submitted correction to a `Step`'s detected techniques —
/// captures ADD (a missing technique the classifier didn't find), /// captures ADD (a missing technique the classifier didn't find),
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape: /// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
@ -1017,7 +878,7 @@ model StepTechStepCorrection {
/// ///
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays /// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
/// (`String[]`), not a join table — unlike this schema's other list-shaped /// (`String[]`), not a join table — unlike this schema's other list-shaped
/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text /// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
/// proposed once for a human to read, not ids referencing another catalog /// proposed once for a human to read, not ids referencing another catalog
/// table, so there's nothing for a join table to normalize against. /// table, so there's nothing for a join table to normalize against.
model TechStepTrainingSuggestion { model TechStepTrainingSuggestion {
@ -1036,223 +897,3 @@ model TechStepTrainingSuggestion {
@@map("tech_step_training_suggestion") @@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

@ -5,13 +5,9 @@ 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 { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-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 { 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";
@ -36,11 +32,6 @@ export function createServer(): ExpressServer {
// pipeline (its "finish" listener still fires for a request that never // pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route). // makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger); 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,40 +39,19 @@ 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 // Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own // `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers // doc comment), never `requireAuth`. Mounted alongside the other routers
// rather than nested under one of them since it isn't scoped to a single // 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. // recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter); 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("/shopping-list", shoppingListRouter);
server.mountRouter("/cooking-session", cookingSessionRouter);
server.mountRouter("/sources", sourcesRouter); server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see // Serves the built frontend (production Docker image only — see

View file

@ -13,26 +13,12 @@ import { z } from "zod";
// separate test database this now requires. // separate test database this now requires.
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" }); 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 +34,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
@ -138,45 +90,6 @@ export const envSchema = z.object({
* every technique detection request failing one at a time. * 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"), 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

@ -191,114 +191,6 @@ export const UTENSILS: string[] = [
"mandoline", "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
// businesses to declare — a standard, defensible reference list rather than // businesses to declare — a standard, defensible reference list rather than
// an invented one. Split into ALLERGY (classic IgE-mediated immune // an invented one. Split into ALLERGY (classic IgE-mediated immune
@ -1524,47 +1416,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,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

@ -427,11 +427,6 @@ export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMa
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {}; const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {}; const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
const ingredients = await prisma.ingredient.findMany({ 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 }, select: { id: true, key: true },
}); });
const catalog: IngredientMatchEntry[] = []; const catalog: IngredientMatchEntry[] = [];

View file

@ -25,28 +25,11 @@ export interface IntentServiceEntity {
kind: "technique" | "utensil"; kind: "technique" | "utensil";
} }
/** /** 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). */
* 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 { export interface IntentServiceProcessResult {
entities: IntentServiceEntity[]; entities: IntentServiceEntity[];
intent: string | null; intent: string | null;
score: number; score: number;
temperatures: IntentServiceTemperature[];
} }
/** /**
@ -105,26 +88,10 @@ export class IntentServiceClient {
*/ */
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> { public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
try { try {
// The wire shape uses `gas_mark` (Python field name); everything else return await this._request("/v1/process", {
// 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", method: "POST",
body: JSON.stringify({ locale, text }), 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) { } catch (err) {
throw err; throw err;
} }

View file

@ -5,7 +5,7 @@ import {
loadIngredientCatalog, loadIngredientCatalog,
loadUnitCatalog, loadUnitCatalog,
} from "./ingredient-matcher.js"; } from "./ingredient-matcher.js";
import { type IntentServiceTemperature, intentServiceClient } from "./intent-service-client.js"; import { intentServiceClient } from "./intent-service-client.js";
/** /**
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe * Auto-detects which cooking techniques (`TechStep`) a free-text recipe
@ -114,7 +114,6 @@ export interface TechStepMatch {
contextEnd: number; contextEnd: number;
ingredients: IngredientMention[]; ingredients: IngredientMention[];
utensils: UtensilMention[]; utensils: UtensilMention[];
temperatures: TemperatureMention[];
} }
/** /**
@ -130,15 +129,6 @@ export interface UtensilMention {
end: 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. */ /** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
export interface TechniqueCandidate { 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. */ /** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
@ -418,7 +408,6 @@ export class TechStepClassifierService {
.filter((entity) => entity.kind === "technique") .filter((entity) => entity.kind === "technique")
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end })); .map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil"); const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
const temperatureMentions = nerResult.temperatures;
const clauses = splitIntoClauses(description, candidates); const clauses = splitIntoClauses(description, candidates);
const matches: TechStepMatch[] = []; const matches: TechStepMatch[] = [];
@ -453,12 +442,6 @@ export class TechStepClassifierService {
: [{ utensilId, start: entity.start, end: entity.end }]; : [{ 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({ matches.push({
techStepId, techStepId,
start: span.start, start: span.start,
@ -467,7 +450,6 @@ export class TechStepClassifierService {
contextEnd: clause.end, contextEnd: clause.end,
ingredients, ingredients,
utensils, utensils,
temperatures,
}); });
} }

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

@ -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,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,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,10 +1,5 @@
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 type { CookieOptions, Response } from "express";
import { Router } from "express"; import { Router } from "express";
import { env } from "../../config/env.js"; import { env } from "../../config/env.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";
@ -59,8 +58,6 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
}, },
}); });
analytics.recordEvent("user.signup", { actorId: profile.id });
const token = signAuthToken({ const token = signAuthToken({
userProfileId: profile.id, userProfileId: profile.id,
tokenVersion: profile.tokenVersion, tokenVersion: profile.tokenVersion,

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

@ -3,14 +3,12 @@ import {
auditBatchQuerySchema, auditBatchQuerySchema,
submitTrainingSuggestionsSchema, submitTrainingSuggestionsSchema,
workerBatchQuerySchema, workerBatchQuerySchema,
workerHeartbeatSchema,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js"; import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
import { import {
getAuditBatch, getAuditBatch,
getPendingCorrections, getPendingCorrections,
recordWorkerHeartbeat,
submitTrainingSuggestions, submitTrainingSuggestions,
} from "./tech-step-worker.service.js"; } from "./tech-step-worker.service.js";
@ -49,19 +47,3 @@ techStepWorkerRouter.post(
res.status(201).json(await submitTrainingSuggestions(input)); 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

@ -4,9 +4,7 @@ import {
type PendingTechStepCorrectionView, type PendingTechStepCorrectionView,
type SubmitTrainingSuggestionsInput, type SubmitTrainingSuggestionsInput,
type TechStepAuditClauseView, type TechStepAuditClauseView,
type WorkerHeartbeatInput,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { import {
CONFIDENCE_THRESHOLD, CONFIDENCE_THRESHOLD,
@ -201,44 +199,3 @@ export async function submitTrainingSuggestions(
throw err; // see recipe.service.ts's equivalent catch comment 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 { addPlanningItemSchema, ErrorCode, 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
@ -96,7 +63,13 @@ export async function getPlanningForDate(
id: planning.id, id: planning.id,
startDate: planning.startDate.toISOString(), startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(), finishDate: planning.finishDate.toISOString(),
items: planning.items.map(toPlanningItemView), items: planning.items.map((item) => ({
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
recipe: item.recipe,
})),
}; };
} catch (err) { } catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
@ -170,18 +143,6 @@ export async function addPlanningItem(
} }
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId); 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 weekStart = getWeekStart(toDateOnly(date));
const planning = await findOrCreatePlanningForWeek(houseId, weekStart); const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
@ -191,22 +152,18 @@ export async function addPlanningItem(
weekDay: input.weekDay, weekDay: input.weekDay,
meal: input.meal, meal: input.meal,
recipeId: input.recipeId, recipeId: input.recipeId,
portions, portions: input.portions,
portionsRequested: input.portionsRequested,
}, },
include: { recipe: { select: { id: true, name: true } } }, include: { recipe: { select: { id: true, name: true } } },
}); });
analytics.recordEvent("planning.item_added", { return {
actorId: viewerId, id: item.id,
context: { weekDay: item.weekDay,
recipeId: input.recipeId, meal: item.meal,
portions, portions: item.portions,
portionsRequested: input.portionsRequested, recipe: item.recipe,
}, };
});
return toPlanningItemView(item);
} catch (err) { } catch (err) {
throw err; // see getPlanningForDate()'s catch comment above throw err; // see getPlanningForDate()'s catch comment above
} }
@ -234,139 +191,3 @@ export async function removePlanningItem(id: number, houseId: number | null): Pr
throw err; // see getPlanningForDate()'s catch comment above 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({
where: { id },
include: {
planning: { select: { houseId: true } },
leftoverUses: { select: { portions: true } },
recipe: { select: { id: true, name: true } },
},
});
if (!item || houseId === null || item.planning.houseId !== houseId) {
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
}
return item;
}
/**
* 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

@ -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";
/** /**
@ -150,57 +149,3 @@ export async function updateDislikedIngredients(
throw err; // see updateDiet()'s catch comment above 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
}
}

View file

@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { import {
ErrorCode, ErrorCode,
@ -8,7 +7,6 @@ import {
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js"; import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
/** /**
@ -33,24 +31,22 @@ const correctionInclude = {
} satisfies Prisma.StepTechStepCorrectionInclude; } satisfies Prisma.StepTechStepCorrectionInclude;
/** /**
* Loads `stepId`'s current `description` (a correction needs its full text * Loads `stepId`'s current `description` length (the only thing a
* now, not just its length — see {@link submitTechStepCorrection}'s * correction needs from the step itself), or throws — `404 STEP_NOT_FOUND`
* `raw` derivation for a manual temperature mention), or throws — * if no such step exists, or if it exists but doesn't belong to `recipeId`
* `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't * (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a
* belong to `recipeId` (the route's own `:id`/`:stepId` nesting is * request naming a real step under the wrong recipe should look identical
* meaningless otherwise — a request naming a real step under the wrong * to naming one that doesn't exist, same "don't leak which part was wrong"
* recipe should look identical to naming one that doesn't exist, same * posture `assertRecipeVisible` already has for visibility). Otherwise
* "don't leak which part was wrong" posture `assertRecipeVisible` already * whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`,
* has for visibility). Otherwise whatever {@link assertRecipeVisible} * never `403`) if the recipe exists but isn't visible to the viewer.
* throws (`404 RECIPE_NOT_FOUND`, never `403`) if the recipe exists but
* isn't visible to the viewer.
*/ */
async function loadVisibleStepOrThrow( async function loadVisibleStepOrThrow(
recipeId: number, recipeId: number,
stepId: number, stepId: number,
viewerId: number, viewerId: number,
viewerHouseId: number | null, viewerHouseId: number | null,
): Promise<{ id: number; description: string }> { ): Promise<{ id: number; descriptionLength: number }> {
try { try {
const step = await prisma.step.findUnique({ const step = await prisma.step.findUnique({
where: { id: stepId }, where: { id: stepId },
@ -60,7 +56,7 @@ async function loadVisibleStepOrThrow(
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`); throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
} }
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId); await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
return { id: step.id, description: step.description }; return { id: step.id, descriptionLength: step.description.length };
} catch (err) { } catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment throw err; // see recipe.service.ts's equivalent catch comment
} }
@ -211,21 +207,6 @@ interface ManualUtensilMention {
start: number; start: number;
end: number; end: number;
} }
/**
* Same as {@link ManualIngredientMention}, for a temperature — `raw` is
* already resolved by the caller ({@link submitTechStepCorrection}, which
* has the step's full `description` text to slice) rather than trusted
* from the client; see `manualStepTechStepTemperatureInputSchema`'s own
* doc comment (`packages/shared`) for why.
*/
interface ManualTemperatureMention {
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
raw: string;
start: number;
end: number;
}
/** /**
* Applies a correction's *effect* on `stepId`'s real `StepTechStep` * Applies a correction's *effect* on `stepId`'s real `StepTechStep`
@ -251,16 +232,16 @@ interface ManualTemperatureMention {
* *
* `metadata`, when given (only ever alongside a real `correctedTechStepId` * `metadata`, when given (only ever alongside a real `correctedTechStepId`
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here), * — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/ * replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
* `StepTechStepTemperature` row on this occurrence — `source: "auto"` (the * this occurrence — `source: "auto"` (the classifier's own detection) and
* classifier's own detection) and any earlier `"manual"` set alike — with * any earlier `"manual"` set alike — with the newly-submitted one. This is
* the newly-submitted one. This is "le manuel remplace tout" (confirmed * "le manuel remplace tout" (confirmed with the user): the resolved
* with the user): the resolved `order` this technique ends up at * `order` this technique ends up at (whichever branch above produced it)
* (whichever branch above produced it) is the same `techStepOrder` all * is the same `techStepOrder` both metadata tables key on, so the same
* three metadata tables key on, so the same `deleteMany` + `createMany` * `deleteMany` + `createMany` pair below is correct whether this call just
* pair below is correct whether this call just updated an existing row * updated an existing row (which may already carry auto-detected
* (which may already carry auto-detected metadata) or created a brand new * metadata) or created a brand new one (nothing to delete yet — a no-op
* one (nothing to delete yet — a no-op `deleteMany`, not a special case). * `deleteMany`, not a special case).
* *
* Runs inside the same transaction {@link submitTechStepCorrection} uses * Runs inside the same transaction {@link submitTechStepCorrection} uses
* for the audit-trail insert, so a request never leaves any of these * for the audit-trail insert, so a request never leaves any of these
@ -273,11 +254,7 @@ async function applyManualCorrection(
span: { start: number; end: number }, span: { start: number; end: number },
previousTechStepId: number | null, previousTechStepId: number | null,
correctedTechStepId: number | null, correctedTechStepId: number | null,
metadata?: { metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
ingredients: ManualIngredientMention[];
utensils: ManualUtensilMention[];
temperatures: ManualTemperatureMention[];
},
): Promise<void> { ): Promise<void> {
const existing = await tx.stepTechStep.findMany({ where: { stepId } }); const existing = await tx.stepTechStep.findMany({ where: { stepId } });
@ -351,22 +328,6 @@ async function applyManualCorrection(
})), })),
}); });
} }
await tx.stepTechStepTemperature.deleteMany({ where: { stepId, techStepOrder: order } });
if (metadata.temperatures.length > 0) {
await tx.stepTechStepTemperature.createMany({
data: metadata.temperatures.map((temperature) => ({
stepId,
techStepOrder: order,
celsius: temperature.celsius,
gasMark: temperature.gasMark,
qualitative: temperature.qualitative,
raw: temperature.raw,
start: temperature.start,
end: temperature.end,
source: "manual",
})),
});
}
} }
} else if (target) { } else if (target) {
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } }); await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
@ -381,20 +342,10 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
start: correction.start, start: correction.start,
end: correction.end, end: correction.end,
previousTechStep: correction.previousTechStep previousTechStep: correction.previousTechStep
? { ? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
id: correction.previousTechStep.id,
key: correction.previousTechStep.key,
isPlaceholder: correction.previousTechStep.isPlaceholder,
displayName: correction.previousTechStep.displayName,
}
: null, : null,
correctedTechStep: correction.correctedTechStep correctedTechStep: correction.correctedTechStep
? { ? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
id: correction.correctedTechStep.id,
key: correction.correctedTechStep.key,
isPlaceholder: correction.correctedTechStep.isPlaceholder,
displayName: correction.correctedTechStep.displayName,
}
: null, : null,
createdAt: correction.createdAt.toISOString(), createdAt: correction.createdAt.toISOString(),
}; };
@ -414,11 +365,10 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see * @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if * {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
* `start`/`end` (the correction's own span, or any of * `start`/`end` (the correction's own span, or any of
* `input.ingredients`/`input.utensils`/`input.temperatures`' own spans) * `input.ingredients`/`input.utensils`' own spans) fall outside the
* fall outside the step's current `description` (it may have been * step's current `description` (it may have been edited since the user
* edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`/ * last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
* `404 INGREDIENT_NOT_FOUND`/`404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` * `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
* if any referenced id
* doesn't exist. * doesn't exist.
*/ */
export async function submitTechStepCorrection( export async function submitTechStepCorrection(
@ -430,20 +380,18 @@ export async function submitTechStepCorrection(
): Promise<SubmitTechStepCorrectionResult> { ): Promise<SubmitTechStepCorrectionResult> {
try { try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId); const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
const descriptionLength = step.description.length;
const spans = [ const spans = [
{ start: input.start, end: input.end }, { start: input.start, end: input.end },
...(input.ingredients ?? []), ...(input.ingredients ?? []),
...(input.utensils ?? []), ...(input.utensils ?? []),
...(input.temperatures ?? []),
]; ];
for (const span of spans) { for (const span of spans) {
if (span.start >= descriptionLength || span.end > descriptionLength) { if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
throw new HttpError( throw new HttpError(
400, 400,
ErrorCode.INVALID_CORRECTION_SPAN, ErrorCode.INVALID_CORRECTION_SPAN,
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${descriptionLength})`, `Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
); );
} }
} }
@ -460,32 +408,7 @@ export async function submitTechStepCorrection(
); );
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId)); await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
const { correction, techSteps, placeholderTechStepId } = await prisma.$transaction( const { correction, techSteps } = await prisma.$transaction(async (tx) => {
async (tx) => {
// A technique the catalog doesn't have (issue #130) — mint a
// placeholder `TechStep` for it, in the same transaction as the
// correction that references it, then proceed exactly as if its id had
// been sent as `correctedTechStepId`. Same shape as
// `recipe.service.ts`'s `resolveIngredientLines` does for a
// free-text ingredient.
const placeholderTechStepId =
input.correctedTechStepName !== undefined
? (
await tx.techStep.create({
data: {
key: `placeholder:${randomUUID()}`,
isPlaceholder: true,
displayName: input.correctedTechStepName,
createdById: correctorId,
createdAt: new Date(),
},
select: { id: true },
})
).id
: null;
const effectiveCorrectedTechStepId =
placeholderTechStepId ?? input.correctedTechStepId ?? null;
const createdCorrection = await tx.stepTechStepCorrection.create({ const createdCorrection = await tx.stepTechStepCorrection.create({
data: { data: {
stepId: step.id, stepId: step.id,
@ -493,7 +416,7 @@ export async function submitTechStepCorrection(
start: input.start, start: input.start,
end: input.end, end: input.end,
previousTechStepId: input.previousTechStepId ?? null, previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: effectiveCorrectedTechStepId, correctedTechStepId: input.correctedTechStepId ?? null,
}, },
include: correctionInclude, include: correctionInclude,
}); });
@ -503,10 +426,8 @@ export async function submitTechStepCorrection(
step.id, step.id,
{ start: input.start, end: input.end }, { start: input.start, end: input.end },
input.previousTechStepId ?? null, input.previousTechStepId ?? null,
effectiveCorrectedTechStepId, input.correctedTechStepId ?? null,
input.ingredients === undefined && input.ingredients === undefined && input.utensils === undefined
input.utensils === undefined &&
input.temperatures === undefined
? undefined ? undefined
: { : {
ingredients: (input.ingredients ?? []).map((ingredient) => ({ ingredients: (input.ingredients ?? []).map((ingredient) => ({
@ -521,24 +442,13 @@ export async function submitTechStepCorrection(
start: utensil.start, start: utensil.start,
end: utensil.end, end: utensil.end,
})), })),
// `raw` is derived here (not sent by the client) — see
// `ManualTemperatureMention`'s own doc comment.
temperatures: (input.temperatures ?? []).map((temperature) => ({
celsius: temperature.celsius ?? null,
gasMark: temperature.gasMark ?? null,
qualitative: temperature.qualitative ?? null,
raw: step.description.slice(temperature.start, temperature.end),
start: temperature.start,
end: temperature.end,
})),
}, },
); );
// Same nested `ingredients`/`utensils`/`temperatures` include as // Same nested `ingredients`/`utensils` include as `recipe.service.ts`'s
// `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews` // `recipeInclude` — `toStepTechStepViews` (reused below) expects it,
// (reused below) expects it, so the fresh sequence read right after a // so the fresh sequence read right after a manual correction resolves
// manual correction resolves exactly the same way a normal // exactly the same way a normal `GET /recipes/:id` would.
// `GET /recipes/:id` would.
const freshTechSteps = await tx.stepTechStep.findMany({ const freshTechSteps = await tx.stepTechStep.findMany({
where: { stepId: step.id }, where: { stepId: step.id },
orderBy: { order: "asc" }, orderBy: { order: "asc" },
@ -556,34 +466,11 @@ export async function submitTechStepCorrection(
}, },
}, },
utensils: { include: { utensil: true } }, utensils: { include: { utensil: true } },
temperatures: true,
}, },
}); });
return { correction: createdCorrection, techSteps: freshTechSteps, placeholderTechStepId }; return { correction: createdCorrection, techSteps: freshTechSteps };
},
);
analytics.recordEvent("tech_step.correction_submitted", {
actorId: correctorId,
context: {
recipeId,
stepId,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: correction.correctedTechStepId,
},
}); });
if (placeholderTechStepId !== null) {
analytics.recordEvent("tech_step.placeholder_proposed", {
actorId: correctorId,
context: {
recipeId,
stepId,
techStepId: placeholderTechStepId,
name: input.correctedTechStepName ?? "",
},
});
}
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) }; return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
} catch (err) { } catch (err) {

View file

@ -1,9 +1,7 @@
import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { import {
type AllergyView, type AllergyView,
type CreateRecipeInput, type CreateRecipeInput,
computeRecipeDietViews,
type DietView, type DietView,
ErrorCode, ErrorCode,
type IngredientView, type IngredientView,
@ -16,12 +14,10 @@ import {
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { import {
type TechStepMatch, type TechStepMatch,
techStepClassifier, techStepClassifier,
} from "../../lib/recipe-matching/tech-step-matcher.js"; } from "../../lib/recipe-matching/tech-step-matcher.js";
import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-persistence.js";
// No user-language preference exists anywhere in the app yet (a single // No user-language preference exists anywhere in the app yet (a single
// "fr" translation file, no locale field on User/UserProfile) — steps are // "fr" translation file, no locale field on User/UserProfile) — steps are
@ -30,7 +26,7 @@ import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-
// is a parameter rather than baked into that module. // is a parameter rather than baked into that module.
const DEFAULT_TECH_STEP_LOCALE = "fr"; const DEFAULT_TECH_STEP_LOCALE = "fr";
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data (name, allergens, diet compatibilities), steps in order, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. A recipe's own regimes are no longer stored (issue #131 dropped `RecipeDiet`) — they're computed from the ingredient views by {@link computeRecipeDietViews}. */ /** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
function recipeInclude(viewerId: number) { function recipeInclude(viewerId: number) {
return { return {
ingredients: { ingredients: {
@ -70,43 +66,22 @@ function recipeInclude(viewerId: number) {
}, },
}, },
utensils: { include: { utensil: true } }, utensils: { include: { utensil: true } },
temperatures: true,
}, },
}, },
}, },
}, },
diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } }, favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude; } satisfies Prisma.RecipeInclude;
} }
/**
* Every reference `Diet` row (`{ id, key }`), loaded once and memoized —
* `Diet` is seeded reference data that never changes at runtime, so caching
* it for the process's lifetime is safe. Used to attach a real `DietView` to
* the derived gluten-free regime (see {@link loadGlutenFreeDiet}) and to
* resolve the planning picker's `dietIds` filter / a household's members'
* regimes for the post-filter in {@link listRecipes}.
*/
let referenceDietsPromise: Promise<DietView[]> | null = null;
function loadReferenceDiets(): Promise<DietView[]> {
if (referenceDietsPromise === null) {
referenceDietsPromise = prisma.diet.findMany({ select: { id: true, key: true } });
}
return referenceDietsPromise;
}
/** The reference `glutenFree` {@link DietView} — {@link computeRecipeDietViews} needs it to give the derived gluten-free regime a real `{ id, key }` (no `IngredientDiet` row references it, unlike the other regimes). `undefined` only if the seed somehow lacks the row, in which case gluten-free is simply omitted. */
async function loadGlutenFreeDiet(): Promise<DietView | undefined> {
return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree");
}
type RecipeWithDetails = Prisma.RecipeGetPayload<{ type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>; include: ReturnType<typeof recipeInclude>;
}>; }>;
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */ /** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. `NonNullable` since a `RecipeIngredient.unit` is now `null` on a "selon le goût" line — callers guard that before calling {@link toUnitView}. */ /** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
export type UnitWithDetails = NonNullable<RecipeWithDetails["ingredients"][number]["unit"]>; export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */ /** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
export function toUnitView(unit: UnitWithDetails): UnitView { export function toUnitView(unit: UnitWithDetails): UnitView {
@ -133,15 +108,13 @@ export function toIngredientView(ingredient: IngredientWithDetails): IngredientV
kind: allergy.category.kind, kind: allergy.category.kind,
})), })),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })), diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
// A placeholder line round-trips as a normal `Ingredient` with a real
// id — the frontend tells it apart by this flag (badge, no
// allergen/diet info) and shows `displayName` verbatim instead of
// looking up an i18n label that doesn't exist for a `placeholder:` key.
isPlaceholder: ingredient.isPlaceholder,
displayName: ingredient.displayName,
}; };
} }
function toDietView(diet: { id: number; key: string }): DietView {
return { id: diet.id, key: diet.key };
}
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */ /** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] { function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
const byId = new Map<number, AllergyView>(); const byId = new Map<number, AllergyView>();
@ -153,13 +126,10 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
return [...byId.values()]; return [...byId.values()];
} }
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. `glutenFreeDiet` (see {@link loadGlutenFreeDiet}) is threaded through only so {@link computeRecipeDietViews} can attach a real `DietView` to the derived gluten-free regime. */ /** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
function toRecipeSummaryView( function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
recipe: RecipeWithDetails, const allergens = aggregateAllergens(
glutenFreeDiet: DietView | undefined, recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
): RecipeSummaryView {
const ingredientViews = recipe.ingredients.map((recipeIngredient) =>
toIngredientView(recipeIngredient.ingredient),
); );
return { return {
id: recipe.id, id: recipe.id,
@ -169,9 +139,8 @@ function toRecipeSummaryView(
portions: recipe.portions, portions: recipe.portions,
authorId: recipe.authorId, authorId: recipe.authorId,
visibility: recipe.visibility, visibility: recipe.visibility,
allergens: aggregateAllergens(ingredientViews), allergens,
// Computed from the ingredients, not stored — see `RecipeSummaryView.diets`. diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet),
isFavorite: recipe.favoritedBy.length > 0, isFavorite: recipe.favoritedBy.length > 0,
}; };
} }
@ -198,25 +167,11 @@ export function toStepTechStepViews(
): StepTechStepView[] { ): StepTechStepView[] {
const views: StepTechStepView[] = []; const views: StepTechStepView[] = [];
for (const stepTechStep of techSteps) { for (const stepTechStep of techSteps) {
const { const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } =
start, stepTechStep;
end,
contextStart,
contextEnd,
techStep,
source,
ingredients,
utensils,
temperatures,
} = stepTechStep;
if (start === null || end === null) continue; if (start === null || end === null) continue;
views.push({ views.push({
techStep: { techStep: { id: techStep.id, key: techStep.key },
id: techStep.id,
key: techStep.key,
isPlaceholder: techStep.isPlaceholder,
displayName: techStep.displayName,
},
start, start,
end, end,
// `source` is a plain DB `String`, not a Prisma enum (see // `source` is a plain DB `String`, not a Prisma enum (see
@ -243,39 +198,20 @@ export function toStepTechStepViews(
end: stepTechStepUtensil.end, end: stepTechStepUtensil.end,
source: stepTechStepUtensil.source === "manual" ? "manual" : "auto", source: stepTechStepUtensil.source === "manual" ? "manual" : "auto",
})), })),
temperatures: temperatures.map((stepTechStepTemperature) => ({
// `celsius` comes back as a Prisma `Decimal` — plain `number` here,
// same treatment as `StepTechStepIngredient.quantity` above.
celsius:
stepTechStepTemperature.celsius === null ? null : Number(stepTechStepTemperature.celsius),
gasMark: stepTechStepTemperature.gasMark,
qualitative:
stepTechStepTemperature.qualitative === "low" ||
stepTechStepTemperature.qualitative === "medium" ||
stepTechStepTemperature.qualitative === "high"
? stepTechStepTemperature.qualitative
: null,
raw: stepTechStepTemperature.raw,
start: stepTechStepTemperature.start,
end: stepTechStepTemperature.end,
// Same narrowing posture as the technique's own `source` above.
source: stepTechStepTemperature.source === "manual" ? "manual" : "auto",
})),
}); });
} }
return views; return views;
} }
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */ /** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView { function toRecipeView(recipe: RecipeWithDetails): RecipeView {
const ingredients = recipe.ingredients.map((recipeIngredient) => ({ const ingredients = recipe.ingredients.map((recipeIngredient) => ({
ingredient: toIngredientView(recipeIngredient.ingredient), ingredient: toIngredientView(recipeIngredient.ingredient),
// Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`. quantity: Number(recipeIngredient.quantity),
quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity), unit: toUnitView(recipeIngredient.unit),
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
})); }));
return { return {
...toRecipeSummaryView(recipe, glutenFreeDiet), ...toRecipeSummaryView(recipe),
ingredients, ingredients,
steps: recipe.steps.map((step) => ({ steps: recipe.steps.map((step) => ({
id: step.id, id: step.id,
@ -328,57 +264,54 @@ function visibleToViewerWhere(
} }
/** /**
* The two halves of the planning recipe picker's "convient à tout le foyer" * `Recipe` rows that avoid every member of `houseId`'s declared allergens
* toggle (`suitableForHousehold` on `listRecipes`): * and, for every member with a declared regime, are tagged with that
* - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that * regime — the planning recipe picker's "convient à tout le foyer" toggle
* contains an allergen declared by a member (still expressible in SQL). * (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
* - `requiredDietKeys` — every regime declared by a member (`omnivore` * extra query to gather the household's members' allergy/regime ids)
* dropped: it constrains nothing). A recipe must satisfy *all* of them — * rather than exposed to the client as raw per-member data: a member's
* a recipe suiting a vegetarian member doesn't automatically suit a * allergies/regime are private the same way visibility already keeps a
* gluten-free one. Applied as a post-filter in {@link listRecipes} on the * recipe's existence private (404, never 403) — nothing here should let
* *computed* `RecipeSummaryView.diets`, since regimes are no longer a * one member infer another's medical/dietary info from the shape of a
* stored column to query (issue #131). * filtered list. Deliberately excludes `UserProfileDislikedIngredient` —
* * the schema already treats disliked ingredients as a taste preference,
* Computed server-side (a small extra query for the members' allergy/regime * not a safety constraint (see that model's doc comment), so it doesn't
* data) rather than exposed to the client as raw per-member data: a * belong in a filter framed around what's safe/appropriate to serve.
* member's allergies/regime are private the same way visibility already
* keeps a recipe's existence private. Deliberately excludes
* `UserProfileDislikedIngredient` — a taste preference, not a safety
* constraint (see that model's doc comment).
*/ */
async function suitableForHousehold( async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
houseId: number,
): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> {
try { try {
const members = await prisma.userProfile.findMany({ const members = await prisma.userProfile.findMany({
where: { houseId }, where: { houseId },
select: { select: { dietId: true, allergies: { select: { allergyId: true } } },
diet: { select: { key: true } },
allergies: { select: { allergyId: true } },
},
}); });
const requiredDietKeys = [ const requiredDietIds = [
...new Set( ...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
members
.map((member) => member.diet?.key)
.filter((key): key is string => key !== undefined && key !== "omnivore"),
),
]; ];
const excludedAllergyIds = [ const excludedAllergyIds = [
...new Set(members.flatMap((member) => member.allergies.map((allergy) => allergy.allergyId))), ...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))),
]; ];
const allergenWhere: Prisma.RecipeWhereInput = const conditions: Prisma.RecipeWhereInput[] = [];
excludedAllergyIds.length > 0 if (requiredDietIds.length > 0) {
? { // Every diet declared by a member must be among this recipe's tags —
// not "at least one", since a recipe suiting a vegetarian member
// doesn't automatically suit a gluten-free one too.
conditions.push({
AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
if (excludedAllergyIds.length > 0) {
conditions.push({
ingredients: { ingredients: {
none: { none: {
ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } }, ingredient: {
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
}, },
}, },
},
});
} }
: {}; return { AND: conditions };
return { allergenWhere, requiredDietKeys };
} catch (err) { } catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it, // already logs it, see `error-logger.ts`) is what actually handles it,
@ -412,7 +345,7 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
).map((row) => row.sourceId); ).map((row) => row.sourceId);
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] }; return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -426,11 +359,11 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
export interface ListRecipesFilters { export interface ListRecipesFilters {
/** Case-insensitive name substring. */ /** Case-insensitive name substring. */
search?: string; search?: string;
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHousehold}. */ /** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
suitableForHousehold?: boolean; suitableForHousehold?: boolean;
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */ /** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
ingredientIds?: number[]; ingredientIds?: number[];
/** Recipe's *computed* regimes must include *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. Applied as a post-filter now that regimes aren't a stored column (issue #131). */ /** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
dietIds?: number[]; dietIds?: number[];
} }
@ -450,32 +383,31 @@ export async function listRecipes(
filters: ListRecipesFilters = {}, filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> { ): Promise<RecipeSummaryView[]> {
try { try {
const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters; const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)]; const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
if (search) { if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } }); conditions.push({ name: { contains: search, mode: "insensitive" } });
} }
// Regimes are computed from ingredients, not a stored column (issue
// #131), so the household-regime and `dietIds` filters can't be SQL
// anymore — they run as post-filters on the mapped summaries below.
// Everything that still *can* be a `where` stays one.
let requiredDietKeys: string[] = [];
// No-op without a household — nothing to filter against, same posture as // No-op without a household — nothing to filter against, same posture as
// the `foyer` tab returning everything it can rather than throwing. // the `foyer` tab returning everything it can rather than throwing.
if (suitable && viewerHouseId !== null) { if (suitableForHousehold && viewerHouseId !== null) {
const household = await suitableForHousehold(viewerHouseId); conditions.push(await suitableForHouseholdWhere(viewerHouseId));
conditions.push(household.allergenWhere);
requiredDietKeys = household.requiredDietKeys;
} }
if (ingredientIds && ingredientIds.length > 0) { if (ingredientIds && ingredientIds.length > 0) {
// One condition per required id (AND) — a recipe must carry all of // One condition per required id (AND) — a recipe must carry all of
// them, not just one. // them, not just one, same "every one, not any one" posture as
// suitableForHouseholdWhere's requiredDietIds.
conditions.push({ conditions.push({
AND: ingredientIds.map((ingredientId) => ({ AND: ingredientIds.map((ingredientId) => ({
ingredients: { some: { ingredientId } }, ingredients: { some: { ingredientId } },
})), })),
}); });
} }
if (dietIds && dietIds.length > 0) {
conditions.push({
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
switch (tab) { switch (tab) {
case "favoris": case "favoris":
@ -495,38 +427,14 @@ export async function listRecipes(
break; break;
} }
const [recipes, referenceDiets] = await Promise.all([ const recipes = await prisma.recipe.findMany({
prisma.recipe.findMany({
where: { AND: conditions }, where: { AND: conditions },
include: recipeInclude(viewerId), include: recipeInclude(viewerId),
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}), });
loadReferenceDiets(), return recipes.map(toRecipeSummaryView);
]);
const glutenFreeDiet = referenceDiets.find((diet) => diet.key === "glutenFree");
let summaries = recipes.map((recipe) => toRecipeSummaryView(recipe, glutenFreeDiet));
// Post-filters on the *computed* `diets` (see the comment where the SQL
// conditions are built). "Every one, not any one" for both.
if (requiredDietKeys.length > 0) {
summaries = summaries.filter((summary) =>
requiredDietKeys.every((key) => summary.diets.some((diet) => diet.key === key)),
);
}
if (dietIds && dietIds.length > 0) {
// `omnivore` in the filter would match nothing (it's never emitted) —
// treat it as "no constraint" rather than silently returning an empty list.
const requiredIds = dietIds.filter(
(id) => referenceDiets.find((diet) => diet.id === id)?.key !== "omnivore",
);
summaries = summaries.filter((summary) =>
requiredIds.every((id) => summary.diets.some((diet) => diet.id === id)),
);
}
return summaries;
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -545,23 +453,23 @@ export async function getRecipe(
if (!canView(recipe, viewerId, viewerHouseId)) { if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
} }
return toRecipeView(recipe, await loadGlutenFreeDiet()); return toRecipeView(recipe);
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
/** /**
* Creates a recipe with its ingredients and ordered steps in one go — * Creates a recipe with its ingredients, ordered steps and diet tags in one
* steps' `order` is derived from their position in `input.steps`, * go — steps' `order` is derived from their position in `input.steps`,
* ingredients reference existing reference `Ingredient` rows by id (see * ingredients reference existing reference `Ingredient` rows by id (see
* `GET /reference/ingredients`; there's no way to create one here). Its * `GET /reference/ingredients`; there's no way to create one here).
* regimes aren't stored — they're computed from the ingredients on read * `authorId`/`authorHouseId` are fixed at creation and never change on
* (issue #131). `authorId`/`authorHouseId` are fixed at creation and never * later edits (see {@link updateRecipe}).
* change on later edits (see {@link updateRecipe}).
* *
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/ */
export async function createRecipe( export async function createRecipe(
input: CreateRecipeInput, input: CreateRecipeInput,
@ -571,7 +479,7 @@ export async function createRecipe(
try { try {
return await createRecipeInternal(input, authorId, authorHouseId, null); return await createRecipeInternal(input, authorId, authorHouseId, null);
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -588,6 +496,7 @@ export async function createRecipe(
* *
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/ */
export async function createImportedRecipe( export async function createImportedRecipe(
input: CreateRecipeInput, input: CreateRecipeInput,
@ -598,7 +507,7 @@ export async function createImportedRecipe(
try { try {
return await createRecipeInternal(input, authorId, authorHouseId, source); return await createRecipeInternal(input, authorId, authorHouseId, source);
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -632,73 +541,6 @@ async function matchStepsTechSteps<T extends { description: string }>(
); );
} }
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. `quantity`/`unitId` are both `null` for a "selon le goût" seasoning line. */
interface ResolvedIngredientLine {
ingredientId: number;
quantity: number | null;
unitId: number | null;
}
/**
* Turns each `input.ingredients` line into a {@link ResolvedIngredientLine}:
* a line that already carries an `ingredientId` (a catalog pick, or an
* existing placeholder round-tripping through an edit) passes through
* unchanged; a `placeholderName` line gets a fresh placeholder `Ingredient`
* row (`isPlaceholder: true`, a generated `placeholder:<uuid>` key, the
* typed text as `displayName`, stamped with `authorId`/now) created via
* `tx` — so it rolls back together with the recipe if anything later in the
* same transaction fails, never leaving an orphan behind.
*
* Returns the created placeholders separately so the caller can emit one
* `ingredient.placeholder_created` analytics event per row *after* the
* transaction commits (the recipe id it wants in the event context doesn't
* exist yet in here).
*/
async function resolveIngredientLines(
lines: CreateRecipeInput["ingredients"],
authorId: number,
tx: Prisma.TransactionClient,
): Promise<{
resolved: ResolvedIngredientLine[];
createdPlaceholders: { id: number; name: string }[];
}> {
try {
const resolved: ResolvedIngredientLine[] = [];
const createdPlaceholders: { id: number; name: string }[] = [];
for (const line of lines) {
if (line.ingredientId !== undefined) {
resolved.push({
ingredientId: line.ingredientId,
quantity: line.quantity ?? null,
unitId: line.unitId ?? null,
});
continue;
}
// `recipeIngredientInputSchema`'s refine guarantees the other branch.
const name = (line.placeholderName ?? "").trim();
const placeholder = await tx.ingredient.create({
data: {
key: `placeholder:${randomUUID()}`,
isPlaceholder: true,
displayName: name,
createdById: authorId,
createdAt: new Date(),
},
select: { id: true },
});
resolved.push({
ingredientId: placeholder.id,
quantity: line.quantity ?? null,
unitId: line.unitId ?? null,
});
createdPlaceholders.push({ id: placeholder.id, name });
}
return { resolved, createdPlaceholders };
} catch (err) {
throw err; // see suitableForHousehold()'s catch comment above
}
}
async function createRecipeInternal( async function createRecipeInternal(
input: CreateRecipeInput, input: CreateRecipeInput,
authorId: number, authorId: number,
@ -706,12 +548,9 @@ async function createRecipeInternal(
source: { sourceId: number; externalId: string; locale: string } | null, source: { sourceId: number; externalId: string; locale: string } | null,
): Promise<RecipeView> { ): Promise<RecipeView> {
try { try {
await assertIngredientsExist( await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined), await assertUnitsExist(input.ingredients.map((i) => i.unitId));
); await assertDietsExist(input.dietIds);
await assertUnitsExist(
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
);
// Matched up front (one call per step, in parallel) rather than inline // Matched up front (one call per step, in parallel) rather than inline
// inside the `steps.create` map below — `techStepClassifier` is async // inside the `steps.create` map below — `techStepClassifier` is async
// (a trained model, not a pure regex test), so its result has to // (a trained model, not a pure regex test), so its result has to
@ -722,16 +561,7 @@ async function createRecipeInternal(
source?.locale ?? DEFAULT_TECH_STEP_LOCALE, source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
); );
// One transaction so the free-text placeholder `Ingredient` rows and the const created = await prisma.recipe.create({
// recipe that references them commit together — a failed recipe create
// must never leave orphan placeholders behind.
const { created, createdPlaceholders } = await prisma.$transaction(async (tx) => {
const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients,
authorId,
tx,
);
const created = await tx.recipe.create({
data: { data: {
name: input.name, name: input.name,
description: input.description ?? null, description: input.description ?? null,
@ -743,10 +573,10 @@ async function createRecipeInternal(
sourceId: source?.sourceId ?? null, sourceId: source?.sourceId ?? null,
externalId: source?.externalId ?? null, externalId: source?.externalId ?? null,
ingredients: { ingredients: {
create: resolved.map((line) => ({ create: input.ingredients.map((ingredient) => ({
ingredientId: line.ingredientId, ingredientId: ingredient.ingredientId,
quantity: line.quantity, quantity: ingredient.quantity,
unitId: line.unitId, unitId: ingredient.unitId,
})), })),
}, },
steps: { steps: {
@ -754,49 +584,56 @@ async function createRecipeInternal(
description: step.description, description: step.description,
picture: step.picture ?? null, picture: step.picture ?? null,
order: index, order: index,
// Technique rows + their clause metadata (ingredients / techSteps: {
// utensils / temperatures) — see `toStepTechStepCreateInputs`. create: matches.map((match, order) => ({
// `updateRecipe` below builds the exact same payload from the techStepId: match.techStepId,
// same helper so a resave never drops what a create persisted. start: match.start,
techSteps: { create: toStepTechStepCreateInputs(matches) }, 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,
})),
},
})),
},
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
}, },
include: recipeInclude(authorId), include: recipeInclude(authorId),
}); });
return { created, createdPlaceholders }; return toRecipeView(created);
});
analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", {
actorId: authorId,
context: { recipeId: created.id, sourceId: source?.sourceId ?? null },
});
for (const placeholder of createdPlaceholders) {
analytics.recordEvent("ingredient.placeholder_created", {
actorId: authorId,
context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: created.id },
});
}
return toRecipeView(created, await loadGlutenFreeDiet());
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
/** /**
* Replaces a recipe's whole content — name/description/picture/visibility * Replaces a recipe's whole content — name/description/picture/visibility
* and the complete ingredient/step lists (not a partial merge: a line * and the complete ingredient/step/diet lists (not a partial merge: a line
* missing from `input` is removed, same contract as `PATCH * missing from `input` is removed, same contract as `PATCH
* /profile/allergies`). Regimes aren't part of the payload — they're * /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
* recomputed from the ingredients on the next read (issue #131). * never transfers ownership.
* `authorId`/`authorHouseId` are untouched — editing never transfers
* ownership.
* *
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`. * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author. * @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/ */
export async function updateRecipe( export async function updateRecipe(
id: number, id: number,
@ -806,30 +643,16 @@ export async function updateRecipe(
): Promise<RecipeView> { ): Promise<RecipeView> {
try { try {
await assertIsAuthor(id, viewerId, viewerHouseId); await assertIsAuthor(id, viewerId, viewerHouseId);
await assertIngredientsExist( await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
input.ingredients await assertUnitsExist(input.ingredients.map((i) => i.unitId));
.map((i) => i.ingredientId) await assertDietsExist(input.dietIds);
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
);
await assertUnitsExist(
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE); const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
// Interactive transaction (not the array form) so any new free-text await prisma.$transaction([
// placeholder rows are created in the same atomic unit as the prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
// delete+recreate of the recipe's content. An *existing* placeholder prisma.step.deleteMany({ where: { recipeId: id } }),
// line round-trips by its real `ingredientId` and is left untouched; prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
// only a brand-new `placeholderName` line creates a row here. prisma.recipe.update({
const createdPlaceholders = await prisma.$transaction(async (tx) => {
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
await tx.step.deleteMany({ where: { recipeId: id } });
const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients,
viewerId,
tx,
);
await tx.recipe.update({
where: { id }, where: { id },
data: { data: {
name: input.name, name: input.name,
@ -838,10 +661,10 @@ export async function updateRecipe(
portions: input.portions, portions: input.portions,
visibility: input.visibility, visibility: input.visibility,
ingredients: { ingredients: {
create: resolved.map((line) => ({ create: input.ingredients.map((ingredient) => ({
ingredientId: line.ingredientId, ingredientId: ingredient.ingredientId,
quantity: line.quantity, quantity: ingredient.quantity,
unitId: line.unitId, unitId: ingredient.unitId,
})), })),
}, },
steps: { steps: {
@ -849,30 +672,26 @@ export async function updateRecipe(
description: step.description, description: step.description,
picture: step.picture ?? null, picture: step.picture ?? null,
order: index, order: index,
// Same builder as `createRecipeInternal` — a resave must techSteps: {
// persist the clause metadata (ingredients / utensils / create: matches.map((match, order) => ({
// temperatures), not just the technique span. Omitting it techStepId: match.techStepId,
// here is what left every edited recipe with temperature-less start: match.start,
// `preheat` steps, so `cooking-optimizer.ts` could never spot end: match.end,
// an oven-temperature conflict between two of them. contextStart: match.contextStart,
techSteps: { create: toStepTechStepCreateInputs(matches) }, contextEnd: match.contextEnd,
order,
})), })),
}, },
})),
}, },
}); diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
return createdPlaceholders; },
}); }),
]);
for (const placeholder of createdPlaceholders) { return toRecipeView(await findRecipeOrThrow(id, viewerId));
analytics.recordEvent("ingredient.placeholder_created", {
actorId: viewerId,
context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: id },
});
}
return toRecipeView(await findRecipeOrThrow(id, viewerId), await loadGlutenFreeDiet());
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -905,7 +724,7 @@ export async function deleteRecipe(
await prisma.recipe.delete({ where: { id } }); await prisma.recipe.delete({ where: { id } });
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -933,7 +752,7 @@ export async function addFavorite(
create: { userProfileId: viewerId, recipeId: id }, create: { userProfileId: viewerId, recipeId: id },
}); });
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -944,7 +763,7 @@ export async function removeFavorite(id: number, viewerId: number): Promise<void
where: { userProfileId: viewerId, recipeId: id }, where: { userProfileId: viewerId, recipeId: id },
}); });
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -967,7 +786,7 @@ export async function assertRecipeVisible(
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
} }
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -983,7 +802,7 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
} }
return recipe; return recipe;
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -1002,7 +821,7 @@ async function assertIsAuthor(
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this"); throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
} }
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -1024,7 +843,7 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
); );
} }
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }
@ -1046,6 +865,29 @@ async function assertUnitsExist(unitIds: number[]): Promise<void> {
); );
} }
} catch (err) { } catch (err) {
throw err; // see suitableForHousehold()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
async function assertDietsExist(dietIds: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(dietIds)];
if (uniqueIds.length === 0) return;
const found = await prisma.diet.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
if (found.length !== uniqueIds.length) {
const foundIds = new Set(found.map((diet) => diet.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
throw new HttpError(
404,
ErrorCode.DIET_NOT_FOUND,
`Diet(s) not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
} }
} }

View file

@ -6,7 +6,6 @@ import {
getIngredients, getIngredients,
getSources, getSources,
getTechSteps, getTechSteps,
getTours,
getUnits, getUnits,
getUtensils, getUtensils,
} from "./reference.service.js"; } from "./reference.service.js";
@ -70,14 +69,3 @@ referenceRouter.get(
res.status(200).json(await getSources()); res.status(200).json(await getSources());
}), }),
); );
// Enabled guided-tour scenarios + their steps — read by the web tour
// engine (issue #42). Public like every route here: the general tour can
// start right after signup, before onboarding finishes. Admin CRUD of
// these lives under `/admin/api/tours` (issue #44).
referenceRouter.get(
"/tours",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getTours());
}),
);

View file

@ -4,7 +4,6 @@ import type {
IngredientView, IngredientView,
SourceView, SourceView,
TechStepView, TechStepView,
TourScenarioView,
UnitView, UnitView,
UtensilView, UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
@ -77,21 +76,11 @@ export async function getUnits(): Promise<UnitView[]> {
/** /**
* All reference cooking techniques, ordered by key (see {@link getDiets} * All reference cooking techniques, ordered by key (see {@link getDiets}
* for why) — small, static list (see `reference-seed-data.ts`'s * for why) — small, static list (see `reference-seed-data.ts`'s
* `TECH_STEPS`). * `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
*
* Excludes placeholder rows (`TechStep.isPlaceholder` — the free-text
* techniques users propose when the catalog falls short, issue #130): this
* is the *browsable* catalog, and a placeholder is a per-correction
* stand-in, not a real entry anyone should pick again.
*/ */
export async function getTechSteps(): Promise<TechStepView[]> { export async function getTechSteps(): Promise<TechStepView[]> {
try { try {
const techSteps = await prisma.techStep.findMany({ return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
where: { isPlaceholder: false },
select: { id: true, key: true },
orderBy: { key: "asc" },
});
return techSteps.map((techStep) => ({ ...techStep, isPlaceholder: false, displayName: null }));
} catch (err) { } catch (err) {
throw err; // see getDiets()'s catch comment above throw err; // see getDiets()'s catch comment above
} }
@ -144,16 +133,10 @@ export async function getSources(): Promise<SourceView[]> {
* and compatible diet regimes (see `IngredientDiet`) — same flattening * and compatible diet regimes (see `IngredientDiet`) — same flattening
* approach as {@link getAllergies}. Ingredients with no linked * approach as {@link getAllergies}. Ingredients with no linked
* allergen/diet come back with `allergens: []`/`diets: []`. * allergen/diet come back with `allergens: []`/`diets: []`.
*
* Excludes placeholder rows (`Ingredient.isPlaceholder` — the free-text
* ingredients users type when the catalog falls short): this is the
* *browsable* catalog, and a placeholder is a per-recipe-line stand-in, not
* a real entry anyone should be able to pick again.
*/ */
export async function getIngredients(): Promise<IngredientView[]> { export async function getIngredients(): Promise<IngredientView[]> {
try { try {
const ingredients = await prisma.ingredient.findMany({ const ingredients = await prisma.ingredient.findMany({
where: { isPlaceholder: false },
include: { include: {
allergies: { include: { allergy: { include: { category: true } } } }, allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } }, diets: { include: { diet: true } },
@ -176,38 +159,6 @@ export async function getIngredients(): Promise<IngredientView[]> {
id: diet.id, id: diet.id,
key: diet.key, key: diet.key,
})), })),
// Always a real catalog row here (placeholders are filtered out above).
isPlaceholder: false,
displayName: null,
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* Every **enabled** guided-tour scenario with its ordered steps, for the
* web tour engine (issues #42/#44). A disabled scenario is hidden here
* without being deleted (an admin can toggle it back on). Steps come back
* ordered; `route`/`title`/`content` pass straight through.
*/
export async function getTours(): Promise<TourScenarioView[]> {
try {
const scenarios = await prisma.tourScenario.findMany({
where: { enabled: true },
orderBy: { key: "asc" },
include: { steps: { orderBy: { order: "asc" } } },
});
return scenarios.map((scenario) => ({
key: scenario.key,
name: scenario.name,
steps: scenario.steps.map((step) => ({
order: step.order,
route: step.route,
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
})); }));
} catch (err) { } catch (err) {
throw err; // see getDiets()'s catch comment above throw err; // see getDiets()'s catch comment above

View file

@ -3,7 +3,6 @@ import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared"; import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import { analytics } from "../../lib/analytics.service.js";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getShoppingListForDate } from "./shopping-list.service.js"; import { getShoppingListForDate } from "./shopping-list.service.js";
@ -32,7 +31,6 @@ shoppingListRouter.get(
} }
const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date); const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date);
analytics.recordEvent("shopping_list.viewed", { actorId: res.locals.userProfile.id });
res.status(200).json(shoppingList); res.status(200).json(shoppingList);
}), }),
); );

View file

@ -13,11 +13,6 @@ import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
function shoppingListPlanningInclude() { function shoppingListPlanningInclude() {
return { return {
items: { items: {
// "Leftovers" items (`sourcePlanningItemId` set) reserve a portion of
// an already-cooked batch — nothing new to buy for them. Excluded
// here so they never reach `aggregateShoppingList`; counting them
// would double up the source recipe's ingredients (issue #34).
where: { sourcePlanningItemId: null },
include: { include: {
recipe: { recipe: {
select: { select: {
@ -44,11 +39,11 @@ type PlanningWithIngredients = Prisma.PlanningGetPayload<{
include: ReturnType<typeof shoppingListPlanningInclude>; include: ReturnType<typeof shoppingListPlanningInclude>;
}>; }>;
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. `unit`/`quantity` are both `null` for a "selon le goût" seasoning line: nothing to sum, surfaced once. */ /** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
interface RunningTotal { interface RunningTotal {
ingredient: IngredientView; ingredient: IngredientView;
unit: UnitView | null; unit: UnitView;
quantity: number | null; quantity: number;
} }
/** /**
@ -70,31 +65,16 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin
for (const item of items) { for (const item of items) {
const scale = item.portions / item.recipe.portions; const scale = item.portions / item.recipe.portions;
for (const recipeIngredient of item.recipe.ingredients) { for (const recipeIngredient of item.recipe.ingredients) {
// A "selon le goût" seasoning line (no quantity/unit) — surfaced once
// per ingredient, never scaled or summed. Its key can't collide with a
// real `(ingredient, unit)` line for the same ingredient.
if (recipeIngredient.quantity === null) {
const seasoningKey = `${recipeIngredient.ingredientId}:toTaste`;
if (!totals.has(seasoningKey)) {
totals.set(seasoningKey, {
ingredient: toIngredientView(recipeIngredient.ingredient),
unit: null,
quantity: null,
});
}
continue;
}
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`; const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
const addedQuantity = Number(recipeIngredient.quantity) * scale; const addedQuantity = Number(recipeIngredient.quantity) * scale;
const existing = totals.get(key); const existing = totals.get(key);
if (existing && existing.quantity !== null) { if (existing) {
existing.quantity += addedQuantity; existing.quantity += addedQuantity;
} else { } else {
totals.set(key, { totals.set(key, {
ingredient: toIngredientView(recipeIngredient.ingredient), ingredient: toIngredientView(recipeIngredient.ingredient),
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit), unit: toUnitView(recipeIngredient.unit),
quantity: addedQuantity, quantity: addedQuantity,
}); });
} }

View file

@ -180,20 +180,9 @@ export async function previewSourceItem(
), ),
loadIngredientCatalog(adapter.locale), loadIngredientCatalog(adapter.locale),
loadUnitCatalog(adapter.locale), loadUnitCatalog(adapter.locale),
// Real catalog techniques only — a draft preview is never a saved prisma.techStep.findMany({ select: { id: true, key: true } }),
// recipe, so it can't carry a user-proposed placeholder technique
// (issue #130); `isPlaceholder`/`displayName` are the neutral values.
prisma.techStep.findMany({
where: { isPlaceholder: false },
select: { id: true, key: true },
}),
]); ]);
const techStepById = new Map( const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
techStepsByKey.map((techStep) => [
techStep.id,
{ ...techStep, isPlaceholder: false, displayName: null },
]),
);
const translatedIngredients = translateRecipeIngredients( const translatedIngredients = translateRecipeIngredients(
parsed.ingredients, parsed.ingredients,
@ -271,12 +260,6 @@ export async function previewSourceItem(
? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }] ? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
: []; : [];
}), }),
// Regex-extracted, no id to resolve — carried through as-is
// (same `source: "auto"` reasoning as this match above).
temperatures: match.temperatures.map((mention) => ({
...mention,
source: "auto" as const,
})),
}, },
] ]
: []; : [];

View file

@ -1,72 +0,0 @@
import { prisma } from "../db/prisma.js";
import { backfillTechSteps } from "./backfill-tech-steps.js";
/**
* `backfill_state.key` for the tech-step clause-metadata backfill. Opaque,
* dated so a future metadata rework can add a second key rather than
* re-using this one.
*
* Why it's needed: `updateRecipe` and `backfill-tech-steps.ts` used to
* persist only a detected technique's span, never its ingredient / utensil /
* temperature children (only `createRecipeInternal` did). So every recipe
* ever edited through the UI, and every recipe imported before the create
* path learned temperatures, is missing the clause metadata that
* `cooking-optimizer.ts` needs to spot an oven-temperature conflict between
* two `preheat` steps. {@link backfillTechSteps} recomputes the whole
* `StepTechStep` sequence with the now-complete writer.
*/
const TECH_STEP_METADATA_BACKFILL_KEY = "step_tech_step_metadata_20260904";
/**
* One-time data backfills for the production image — run via
* `node dist/scripts/backfill-runtime.js` in apps/api/Dockerfile's CMD,
* after `seed-runtime` and before the server starts.
*
* Unlike `seed-runtime` (idempotent by construction — it only upserts, so
* repeating it every container start is free), a backfill here *rewrites*
* existing rows and would be wasteful-to-harmful to repeat: the tech-step
* one re-hits `services/tech-step-intent-service` once per `Step`. So each
* backfill gates itself on a `backfill_state` row — present means "done,
* skip"; absent means "run it, then record it".
*
* A backfill failing must **not** keep the API down: it's a data-quality
* catch-up, not a correctness prerequisite (contrast `seed-runtime`, whose
* failure genuinely means missing reference data). So a failure here is
* logged, the marker is left unwritten (the next container start retries),
* and the process still exits 0 so the CMD chain reaches `node dist/server.js`.
* Only a failure to even read `backfill_state` propagates — at that point
* the database is unreachable and the server wouldn't start anyway.
*/
async function runOneTimeBackfills(): Promise<void> {
const alreadyDone = await prisma.backfillState.findUnique({
where: { key: TECH_STEP_METADATA_BACKFILL_KEY },
});
if (alreadyDone !== null) {
console.info(
`Backfill "${TECH_STEP_METADATA_BACKFILL_KEY}" already completed at ${alreadyDone.completedAt.toISOString()} — skipping.`,
);
return;
}
console.info(`Running one-time backfill "${TECH_STEP_METADATA_BACKFILL_KEY}"...`);
try {
const { total, changed } = await backfillTechSteps();
await prisma.backfillState.create({ data: { key: TECH_STEP_METADATA_BACKFILL_KEY } });
console.info(
`Backfill "${TECH_STEP_METADATA_BACKFILL_KEY}" done — ${changed}/${total} step(s) recomputed.`,
);
} catch (err) {
console.error(
`Backfill "${TECH_STEP_METADATA_BACKFILL_KEY}" failed — leaving it unrecorded so the next start retries. The API will still start.`,
err,
);
}
}
runOneTimeBackfills()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -1,27 +1,23 @@
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { prisma } from "../db/prisma.js"; import { prisma } from "../db/prisma.js";
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js"; import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
import { toStepTechStepCreateInputs } from "../lib/recipe-matching/tech-step-persistence.js";
import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js"; import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js";
/** /**
* Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep` * Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
* entries — technique spans **and** their clause metadata (ingredients, * entries against the *current* classifier
* utensils, temperatures) — against the *current* classifier
* (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`), * (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`),
* the same way `updateRecipe` does when a user resaves a recipe through the * the same way `updateRecipe` does when a user resaves a recipe through the UI —
* UI: both build the row payload from the shared * always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's
* {@link toStepTechStepCreateInputs}. Always `"fr"` * no persisted per-recipe locale to recover for a step that already
* (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's no persisted * exists, so this matches real resave behavior exactly rather than
* per-recipe locale to recover for a step that already exists, so this * guessing).
* matches real resave behavior exactly rather than guessing).
* *
* Needed because tech-step detection only ever runs at create/update time * Needed because tech-step detection only ever runs at create/update time
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a * (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a
* step saved before a classifier/corpus change (new vocabulary, or the * step saved before a classifier/corpus change (new vocabulary, or the
* `contextStart`/`contextEnd` columns a previous session added), or before * `contextStart`/`contextEnd` columns a previous session added) keeps
* the clause-metadata writers were wired into the *update* path at all, * whatever it was matched with at the time until it's next resaved.
* keeps whatever it was matched with at the time until it's next resaved.
* *
* `"manual"`-sourced entries (a viewer's correction, applied immediately — * `"manual"`-sourced entries (a viewer's correction, applied immediately —
* see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`) * see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`)
@ -76,20 +72,19 @@ export async function backfillTechSteps(): Promise<{ total: number; changed: num
// into a clean 0..N-1 sequence right after — these just need to // into a clean 0..N-1 sequence right after — these just need to
// not collide with `@@id([stepId, order])` for this insert). // not collide with `@@id([stepId, order])` for this insert).
const startOrder = manualRows.reduce((max, row) => Math.max(max, row.order), -1) + 1; const startOrder = manualRows.reduce((max, row) => Math.max(max, row.order), -1) + 1;
// One `create` per row rather than a single `createMany`: await tx.stepTechStep.createMany({
// `createMany` cannot write the nested `ingredients` / `utensils` / data: nonOverlappingMatches.map((match, index) => ({
// `temperatures` children, and silently dropping those is exactly stepId: step.id,
// the bug this script shared with the old `updateRecipe` — see techStepId: match.techStepId,
// `toStepTechStepCreateInputs`. A backfill that leaves recipes order: startOrder + index,
// without their clause temperatures defeats the whole point of start: match.start,
// running it (`cooking-optimizer.ts` needs them). end: match.end,
const inputs = toStepTechStepCreateInputs(nonOverlappingMatches); contextStart: match.contextStart,
for (const [index, input] of inputs.entries()) { contextEnd: match.contextEnd,
await tx.stepTechStep.create({ source: "auto",
data: { ...input, stepId: step.id, order: startOrder + index, source: "auto" }, })),
}); });
} }
}
await renumberStepTechSteps(tx, step.id); await renumberStepTechSteps(tx, step.id);
}); });

View file

@ -1,56 +0,0 @@
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { hashAdminPassword } from "../modules/admin/admin-auth.service.js";
/**
* Creates the first (or an additional) `AdminUser` for the admin
* application — there is no self-service admin signup, on purpose (see the
* `AdminUser` model doc comment in schema.prisma).
*
* pnpm --filter api exec tsx src/scripts/create-admin.ts \
* --email=ops@example.com --password='...' --name='Ops'
*
* Each flag falls back to the matching `ADMIN_INITIAL_*` env var when
* omitted, so a deployment can bake the first admin's credentials into its
* environment and run this once from the container without passing args.
* Refuses (exit 1) if an `AdminUser` with that email already exists —
* changing an existing admin's password is a manual DB operation for now,
* not something this script does.
*/
function flag(name: string): string | undefined {
const prefix = `--${name}=`;
const arg = process.argv.find((value) => value.startsWith(prefix));
return arg === undefined ? undefined : arg.slice(prefix.length);
}
async function createAdmin(): Promise<void> {
const email = (flag("email") ?? env.ADMIN_INITIAL_EMAIL)?.trim().toLowerCase();
const password = flag("password") ?? env.ADMIN_INITIAL_PASSWORD;
const name = (flag("name") ?? env.ADMIN_INITIAL_NAME)?.trim();
if (!email || !password || !name) {
throw new Error(
"Missing required input. Provide --email, --password and --name (or set ADMIN_INITIAL_EMAIL / ADMIN_INITIAL_PASSWORD / ADMIN_INITIAL_NAME).",
);
}
if (password.length < 8) {
throw new Error("Password must be at least 8 characters.");
}
const existing = await prisma.adminUser.findUnique({ where: { email } });
if (existing) {
throw new Error(`An admin with email "${email}" already exists (id ${existing.id}).`);
}
const passwordHash = await hashAdminPassword(password);
const admin = await prisma.adminUser.create({ data: { email, name, passwordHash } });
console.info(`Created admin #${admin.id} <${admin.email}> ("${admin.name}").`);
}
createAdmin()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err instanceof Error ? err.message : err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -1,27 +0,0 @@
import { prisma } from "../db/prisma.js";
import { pruneOrphanPlaceholders } from "../modules/admin/admin-catalog.service.js";
/**
* Deletes every placeholder `Ingredient` row (`Ingredient.isPlaceholder`)
* that no recipe references any more — the debris left behind when a recipe
* edit drops a placeholder line (the `RecipeIngredient` row goes, the
* `Ingredient` row stays). The admin catalog view has a button for this
* too; this script is the same operation for a cron / one-off cleanup:
*
* pnpm --filter api exec tsx src/scripts/prune-orphan-placeholders.ts
*
* Delegates to `admin-catalog.service.ts` so the "what counts as an orphan"
* rule lives in exactly one place.
*/
async function main(): Promise<void> {
const { deleted } = await pruneOrphanPlaceholders();
console.info(`Pruned ${deleted} orphan placeholder ingredient(s).`);
}
main()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -1,8 +1,6 @@
import { Server as SocketIoServer } from "socket.io";
import { createServer } from "./app.js"; import { createServer } from "./app.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { logger } from "./lib/logger.service.js"; import { logger } from "./lib/logger.service.js";
import { MonitoringBroadcastService } from "./lib/monitoring-broadcast.service.js";
import { techStepClassifier } from "./lib/recipe-matching/tech-step-matcher.js"; import { techStepClassifier } from "./lib/recipe-matching/tech-step-matcher.js";
import { registerAllRecipeSources } from "./sources/index.js"; import { registerAllRecipeSources } from "./sources/index.js";
@ -52,17 +50,6 @@ await warmUpTechStepClassifier();
const server = createServer(); const server = createServer();
// Attaches to the same underlying `http.Server` `server.listen()` below
// will start — `socket.io` binds to the HTTP server itself, not to
// Express or a port, hence `ExpressServer.httpServer` (packages/express-
// tools) rather than going through `server.instance` (the Express app).
// Own CORS config: the WebSocket handshake is a separate HTTP upgrade
// request that never runs through `setupCore`'s `cors()` middleware.
const io = new SocketIoServer(server.httpServer, {
cors: { origin: env.CORS_ORIGIN, credentials: true },
});
new MonitoringBroadcastService(io).start();
server.listen(env.PORT, () => { server.listen(env.PORT, () => {
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV }); logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
}); });

View file

@ -2,17 +2,14 @@ import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-regist
import { sevenFiftyGAdapter } from "./750g.js"; import { sevenFiftyGAdapter } from "./750g.js";
import { mangerBougerAdapter } from "./manger-bouger.js"; import { mangerBougerAdapter } from "./manger-bouger.js";
import { marmitonAdapter } from "./marmiton.js"; import { marmitonAdapter } from "./marmiton.js";
import { ohMonBentoAdapter } from "./ohmonbento.js";
import { quitoqueAdapter } from "./quitoque.js";
import { theMealDbAdapter } from "./the-meal-db.js"; import { theMealDbAdapter } from "./the-meal-db.js";
/** /**
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app * Registers every concrete, *browsable* `RecipeSourceAdapter` this app
* ships with into the shared in-memory registry (`recipe-source-registry.ts`) * ships with into the shared in-memory registry (`recipe-source-registry.ts`)
* — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter`, * — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
* `mangerBougerAdapter`, `quitoqueAdapter` and `ohMonBentoAdapter`. Called * `mangerBougerAdapter`. Called once, explicitly, by the two real entry
* once, explicitly, by the two real entry points that need the registry * points that need the registry populated:
* populated:
* *
* - `server.ts` — the running API process, before it starts listening. * - `server.ts` — the running API process, before it starts listening.
* - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into * - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into
@ -42,6 +39,4 @@ export function registerAllRecipeSources(): void {
registerRecipeSource(marmitonAdapter); registerRecipeSource(marmitonAdapter);
registerRecipeSource(sevenFiftyGAdapter); registerRecipeSource(sevenFiftyGAdapter);
registerRecipeSource(mangerBougerAdapter); registerRecipeSource(mangerBougerAdapter);
registerRecipeSource(quitoqueAdapter);
registerRecipeSource(ohMonBentoAdapter);
} }

View file

@ -18,13 +18,11 @@ const SOURCE_KEY = "mangerBouger";
// all (unlike marmiton.ts's search page) — but it's server-rendered, and a // all (unlike marmiton.ts's search page) — but it's server-rendered, and a
// plain GET carries the exact same Redux state the client hydrates from as // plain GET carries the exact same Redux state the client hydrates from as
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already // a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
// has everything `list()` needs. Verified live for the *search* path // has everything `list()` needs. Verified live: `?query=<free text>` really
// (`?diet=ALL&page=N&query=<free text>`): `query` really filters // filters server-side (not just a client-side URL update over an
// server-side (not just a client-side URL update over an already-fetched // already-fetched page), and `page`/`hasMorePages` behave as real,
// page), and `page`/`hasMorePages` behave as real, consistent pagination. // consistent pagination — the best-behaved of this adapter family's three
// The *catalogue browse* path (no `query` term) must be the bare // sources on that front.
// `?diet=ALL&page=N` — a trailing empty `&query=` made the deployed server
// repeat page 1 for every page (issue #78); `list()` below now omits it.
const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes"; const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes";
const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/"; const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/";
@ -44,7 +42,7 @@ interface MangerBougerListPageData {
initialState?: { initialState?: {
recipes?: { recipes?: {
list?: MangerBougerListEntry[]; list?: MangerBougerListEntry[];
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live on the `query`-term path: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). The catalogue-browse path (no `query`) only paginates when the request URL omits `&query=` entirely — see `LIST_URL` and issue #78. */ /** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). */
hasMorePages?: boolean; hasMorePages?: boolean;
}; };
}; };
@ -286,18 +284,8 @@ export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: strin
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> { async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try { try {
const page = params.cursor ? Number(params.cursor) : 1; const page = params.cursor ? Number(params.cursor) : 1;
// Only append `&query=` when there's an actual search term. A trailing const query = params.query ?? "";
// empty `&query=` on the "browse everything" path (filter absent) made const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`;
// the *deployed* server serve page 1 for every `page=N` — the
// infinite scroll then stopped after 9 recipes (issue #38's
// "0 new externalIds ⇒ end of list" guard cutting in). The bare
// `?diet=ALL&page=N` catalogue request paginates correctly. The
// sibling adapters (marmiton/quitoque/ohmonbento) already make their
// search param conditional; this one didn't.
const query = (params.query ?? "").trim();
const listUrl = `${LIST_URL}?diet=ALL&page=${page}${
query ? `&query=${encodeURIComponent(query)}` : ""
}`;
let response: Response; let response: Response;
try { try {

View file

@ -1,170 +0,0 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "ohMonBento";
const SITE_ORIGIN = "https://ohmonbento.com";
/** One recipe card scraped off an ohmonbento.com search page — see {@link extractRecipeCards}. */
interface OhMonBentoCard {
url: string;
title: string;
image: string | null;
}
/**
* Scrapes every recipe card out of an ohmonbento.com search results page.
* Each card is a `<div class="entry-thumb">` wrapping a single
* `<a href="/<slug>/">` with one `<img>` inside — no `ItemList` JSON-LD
* (unlike marmiton.ts) and no lazy-load `data-src` dance (unlike
* quitoque.ts): the real image is just the `<img>`'s `src`. `href` is a
* root-relative path, resolved against {@link SITE_ORIGIN} here.
* Attribute-order-agnostic.
*/
function extractRecipeCards(html: string): OhMonBentoCard[] {
const cards: OhMonBentoCard[] = [];
const thumbPattern = /<div\s+[^>]*class="[^"]*\bentry-thumb\b[^"]*"[^>]*>([\s\S]*?)<\/div>/g;
for (const match of html.matchAll(thumbPattern)) {
const block = match[1] ?? "";
const href = /<a\s+[^>]*\bhref="([^"]+)"/.exec(block)?.[1];
if (href === undefined) continue;
const title = /\balt="([^"]*)"/.exec(block)?.[1] ?? "";
if (title.length === 0) continue;
const url = href.startsWith("http") ? href : `${SITE_ORIGIN}${href}`;
cards.push({ url, title, image: /\bsrc="([^"]+)"/.exec(block)?.[1] ?? null });
}
return cards;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as this
* adapter's (`sourceKey` `"ohMonBento"`) — same reasoning as marmiton.ts's
* identically-named helper. (Not factored out into a shared helper —
* marmiton/750g/manger-bouger/quitoque each keep their own copy; the
* near-identical five-liner isn't worth a module.)
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* ohmonbento.com — a French Japanese-cuisine / bento cooking blog
* (WordPress). Unofficial (`official: false`, same reasoning as every
* other scraped adapter): no published API, this reads ordinary pages and
* the schema.org JSON-LD they embed. `robots.txt` allows everything except
* `/wp-admin/`.
*
* The simplest of the scraped adapters: like marmiton.ts, `fetchDetail`/
* `parse` delegate straight to {@link jsonLdRecipeAdapter} — a recipe page
* carries a WP Recipe Maker `Recipe` inside a Yoast `@graph`, which
* `findRecipeNode` already handles (recurses into the array). Only `list()`
* is site-specific: WordPress search at `/?s={query}&paged={page}` — the
* **same URL whether `query` is empty (whole catalogue) or not** (no
* branching, unlike 750g.ts) — with a `<div class="entry-thumb">` card
* scrape (see {@link extractRecipeCards}). A page past the last one 404s,
* the same clean end-of-list signal as marmiton.ts.
*/
export const ohMonBentoAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Oh mon Bento",
official: false,
iconUrl: `${SITE_ORIGIN}/favicon.ico`,
// Le contenu d'ohmonbento.com (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
// Curseur opaque = numéro de page suivant ; WordPress pagine via
// `&paged=N` (page 1 implicite quand le paramètre est absent). Le
// paramètre `?s=` est toujours présent, même vide — c'est ce qui
// liste tout le catalogue (vérifié sur le ticket).
const page = params.cursor ? Number(params.cursor) : 1;
const query = params.query ?? "";
const listUrl = `${SITE_ORIGIN}/?s=${encodeURIComponent(query)}${
page > 1 ? `&paged=${page}` : ""
}`;
let response: Response;
try {
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error listing Oh mon Bento recipes (${listUrl})`,
{ cause },
);
}
// Une page au-delà de la dernière renvoie une vraie 404 — pas un
// échec, juste « il n'y a plus rien » (même signal que marmiton.ts).
if (response.status === 404) {
return { items: [], nextCursor: null };
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Oh mon Bento responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
const items: RecipeSourceListItem[] = extractRecipeCards(html).map((card) => ({
externalId: card.url,
title: card.title,
picture: card.image,
url: card.url,
}));
// La 404 ci-dessus est le vrai signal de fin ; on ne propose une page
// suivante que si celle-ci a renvoyé au moins une carte (même logique
// que marmiton.ts).
const nextCursor = items.length > 0 ? String(page + 1) : null;
return { items, nextCursor };
} catch (err) {
// Rethrown as-is (already keyed "ohMonBento" by whichever branch
// above threw it) — same reasoning as `marmiton.ts`/`quitoque.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// ohmonbento.com (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement.
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// `rekeySourceError` est le traitement utile de l'erreur à ce point
// d'appel (relabelliser sa `sourceKey`), pas un simple re-throw.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
return jsonLdRecipeAdapter.parse(raw);
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -1,188 +0,0 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "quitoque";
const SITE_ORIGIN = "https://www.quitoque.fr";
const LIST_URL = `${SITE_ORIGIN}/recettes`;
/** One recipe card scraped off a quitoque.fr `/recettes` listing page — see {@link extractRecipeCards}. */
interface QuitoqueCard {
url: string;
title: string;
image: string | null;
}
/**
* Scrapes every recipe card out of a quitoque.fr `/recettes` listing page.
* There's no `ItemList` JSON-LD to lean on (unlike marmiton.ts) — but each
* card is a single `<a class="card__content_header" href="/recettes/…">`
* wrapping its own `<img>`, so no "nearest preceding image" pairing is
* needed (unlike 750g.ts). The real image URL is the `<img>`'s `data-src`
* (a lazy-load attribute); its `src` is only a placeholder. `href` is a
* root-relative path, resolved against {@link SITE_ORIGIN} here so callers
* get a canonical absolute URL.
*
* Attribute-order-agnostic: matches every `<a>…</a>`, keeps the ones whose
* `class` contains `card__content_header`, and reads `href`/`alt`/`data-src`
* wherever they sit.
*/
function extractRecipeCards(html: string): QuitoqueCard[] {
const cards: QuitoqueCard[] = [];
const anchorPattern = /<a\s+([^>]*?)>([\s\S]*?)<\/a>/g;
for (const match of html.matchAll(anchorPattern)) {
const attrs = match[1] ?? "";
if (!/class="[^"]*\bcard__content_header\b[^"]*"/.test(attrs)) continue;
const href = /\bhref="(\/recettes\/[^"]+)"/.exec(attrs)?.[1];
if (href === undefined) continue;
const inner = match[2] ?? "";
const title = /\balt="([^"]*)"/.exec(inner)?.[1] ?? "";
if (title.length === 0) continue;
cards.push({
url: `${SITE_ORIGIN}${href}`,
title,
image: /\bdata-src="([^"]+)"/.exec(inner)?.[1] ?? null,
});
}
return cards;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter (`sourceKey` `"quitoque"`) — same reasoning as
* marmiton.ts's identically-named helper: `fetchDetail`/`parse` are thin
* wrappers around the generic adapter, but a caller reading `.sourceKey`
* off a caught `RecipeSourceError` should see the source it asked about.
* (Not factored out into a shared helper — 750g/manger-bouger each keep
* their own copy too; three near-identical five-liners aren't worth a
* module.)
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* quitoque.fr — a French meal-kit company whose site publishes a large
* browsable recipe catalogue (`/recettes`, ~2700 recipes). Unofficial
* (`official: false`, same reasoning as jsonLdRecipeAdapter/marmiton/750g/
* mangerBouger): no published API, this reads ordinary pages and the
* schema.org JSON-LD they embed for search engines. `robots.txt` allows
* `/recettes` (and lists `ClaudeBot`/`GPTBot`/`Google-Extended` explicitly
* under `allow: /`).
*
* The thinnest kind of wrapper — like marmiton.ts, `fetchDetail`/`parse`
* delegate straight to {@link jsonLdRecipeAdapter} (a quitoque.fr recipe
* page's JSON-LD is a plain schema.org `Recipe`, the sole element of a
* top-level array, which `findRecipeNode` already handles). Only `list()`
* is site-specific: `/recettes?page=N` with a card scrape (see
* {@link extractRecipeCards}). The site has no real free-text search, so a
* `query` is a best-effort substring filter over each page's card titles
* (same accepted gap 750g.ts documents for its own search).
*/
export const quitoqueAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Quitoque",
official: false,
iconUrl: `${SITE_ORIGIN}/favicon.ico`,
// Le contenu de quitoque.fr (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
// Curseur opaque = numéro de page suivant ; quitoque.fr pagine via
// `?page=N` (page 1 implicite quand le paramètre est absent), même
// esprit que marmiton.ts/750g.ts.
const page = params.cursor ? Number(params.cursor) : 1;
const query = (params.query ?? "").trim().toLowerCase();
const listUrl = `${LIST_URL}${page > 1 ? `?page=${page}` : ""}`;
let response: Response;
try {
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error listing Quitoque recipes (${listUrl})`,
{ cause },
);
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Quitoque responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
const rawCards = extractRecipeCards(html);
const cards =
query.length > 0
? rawCards.filter((card) => card.title.toLowerCase().includes(query))
: rawCards;
const items: RecipeSourceListItem[] = cards.map((card) => ({
externalId: card.url,
title: card.title,
picture: card.image,
url: card.url,
}));
// Une page qui ne renvoie **aucune** carte est le signal de fin
// (même logique que marmiton.ts). Basé sur le nombre de cartes
// brutes, pas filtrées : une page qui a des cartes mais dont aucune
// ne matche `query` peut quand même avoir une suite qui matche.
const nextCursor = rawCards.length > 0 ? String(page + 1) : null;
return { items, nextCursor };
} catch (err) {
// Rethrown as-is (already keyed "quitoque" by whichever branch above
// threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `marmiton.ts`/`750g.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// quitoque.fr (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement.
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
return jsonLdRecipeAdapter.parse(raw);
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -66,91 +66,20 @@ function detailUrl(idMeal: string): string {
return `https://www.themealdb.com/meal/${idMeal}`; return `https://www.themealdb.com/meal/${idMeal}`;
} }
/** Category-list response shape (`/list.php?c=list`) — same `meals` envelope as everything else, but each row only carries `strCategory`. */
interface TheMealDbCategoryListResponse {
meals: { strCategory: string | null }[] | null;
}
/** Narrows to meals that actually carry a name — a titleless row isn't worth surfacing in a browse list. */
function hasName(meal: TheMealDbMeal): meal is TheMealDbMeal & { strMeal: string } {
return Boolean(meal.strMeal);
}
/** Maps one raw meal (from `/search.php` or `/filter.php`) into the generic list-item shape. */
function toListItem(meal: TheMealDbMeal & { strMeal: string }) {
return {
externalId: meal.idMeal,
title: meal.strMeal,
picture: meal.strMealThumb,
url: detailUrl(meal.idMeal),
};
}
/**
* Name search (`/search.php?s=`) — the only mode TheMealDB's free tier
* offers for a non-empty query. One response holds every match, so there's
* never a next page.
*/
async function searchByName(query: string): Promise<RecipeSourceListResult> {
try {
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/search.php?s=${encodeURIComponent(query)}`,
);
return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor: null };
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
}
/**
* "Browse everything" (an empty/omitted query). TheMealDB's free tier has
* no catalog-wide listing endpoint — `/search.php?s=` only answers with a
* fixed ~25-meal sample — so this walks the category list instead:
* `/list.php?c=list` for the categories, then `/filter.php?c={category}`
* for one category at a time, exposing **one category per cursor step** so
* the infinite scroll keeps pulling real, distinct pages instead of
* stalling on that sample (issue #38). The category list is re-fetched on
* each step rather than cached in module state — it's a single small
* request, it keeps this adapter stateless like the others, and the walk
* is paced one step per scroll anyway.
*/
async function listByCategory(cursor: string | null | undefined): Promise<RecipeSourceListResult> {
try {
const parsed = Number.parseInt(cursor ?? "0", 10);
const startAt = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
const categoryList = await fetchTheMealDb<TheMealDbCategoryListResponse>("/list.php?c=list");
const categories = (categoryList.meals ?? [])
.map((row) => row.strCategory)
.filter((name): name is string => Boolean(name));
const category = categories[startAt];
if (category === undefined) {
// Cursor has walked past the last category — the clean end of the list.
return { items: [], nextCursor: null };
}
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/filter.php?c=${encodeURIComponent(category)}`,
);
const nextCursor = startAt + 1 < categories.length ? String(startAt + 1) : null;
return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor };
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
}
/** /**
* TheMealDB (themealdb.com) — a free, public recipe API (no scraping: the * TheMealDB (themealdb.com) — a free, public recipe API (no scraping: the
* publisher's own structured JSON, hence `official: true`). The first real * publisher's own structured JSON, hence `official: true`). The first real
* `RecipeSourceAdapter` implementation, proving the generic contract * `RecipeSourceAdapter` implementation, proving the generic contract
* (recipe-source-adapter.ts) end to end against a live source. * (recipe-source-adapter.ts) end to end against a live source.
* *
* `list()` has two modes (see the helpers above): a non-empty `query` is a * `list()` is search-only — TheMealDB has no dedicated "browse everything"
* name search (`/search.php?s=`, single unpaginated response, * endpoint on its free tier. An omitted `query` searches for an empty
* `nextCursor: null`); an empty/omitted `query` — this contract's "browse * string, which TheMealDB happens to answer with a small default sample
* everything" — walks the category list one category per cursor step, * (~25 meals) rather than nothing — close enough to this contract's
* since the free tier has no real catalog-wide endpoint. * "omitted `query` means browse everything" convention
* (`RecipeSourceListParams.query`) to lean on as-is, though it's a fixed
* sample, not the whole catalog. Search isn't paginated either — one
* response holds every match, so `nextCursor` is always `null`.
*/ */
export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = { export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
key: SOURCE_KEY, key: SOURCE_KEY,
@ -164,8 +93,22 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> { async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try { try {
const query = (params.query ?? "").trim(); const query = params.query ?? "";
return query.length > 0 ? await searchByName(query) : await listByCategory(params.cursor); const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/search.php?s=${encodeURIComponent(query)}`,
);
const meals = data.meals ?? [];
return {
items: meals
.filter((meal): meal is TheMealDbMeal & { strMeal: string } => Boolean(meal.strMeal))
.map((meal) => ({
externalId: meal.idMeal,
title: meal.strMeal,
picture: meal.strMealThumb,
url: detailUrl(meal.idMeal),
})),
nextCursor: null,
};
} catch (err) { } catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above throw err; // see fetchTheMealDb()'s catch comment above
} }

View file

@ -47,10 +47,7 @@ export async function resetDatabase() {
"planning_item", "planning", "planning_item", "planning",
"recipe_ingredient", "step_tech_step", "step", "tech_step", "recipe_ingredient", "step_tech_step", "step", "tech_step",
"recipe", "ingredients", "sources", "unit", "recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house", "user_profiles", "diet", "house"
"tour_progress", "tour_scenario_step", "tour_scenario", "tour_target",
"contact_message",
"admin_users", "analytics_events", "worker_heartbeats", "backfill_state"
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;
`); `);
await seedReferenceData(prisma); await seedReferenceData(prisma);

View file

@ -1,149 +0,0 @@
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** Inserts an `AdminUser` straight into the DB (no signup route exists) and returns its plaintext password. */
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/** The admin login/verify path signs a JWT — self-skip those cases when no `ADMIN_JWT_SECRET` is configured (same posture as `tech-step-worker.routes.test.ts` with `INTERNAL_WORKER_SECRET`). */
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
describe("Admin auth", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /admin/api/auth/login", () => {
it("rejects a missing body with 400 VALIDATION_ERROR", async () => {
const res = await request(app).post("/admin/api/auth/login").send({});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app)
.post("/admin/api/auth/login")
.send({ email: "nobody@example.com", password: "whatever" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
});
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
const { email } = await seedAdmin();
const res = await request(app)
.post("/admin/api/auth/login")
.send({ email, password: "not-the-password" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
});
it("logs in with correct credentials, sets the admin cookie, stamps lastLoginAt, never leaks the hash", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here).
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const res = await request(app).post("/admin/api/auth/login").send({ email, password });
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(email);
expect(res.body).to.not.have.property("passwordHash");
expect(res.body).to.not.have.property("tokenVersion");
expect(res.body.lastLoginAt).to.be.a("string");
const setCookie = res.headers["set-cookie"];
expect(Array.isArray(setCookie) ? setCookie.join(";") : String(setCookie)).to.include(
env.ADMIN_COOKIE_NAME,
);
const stored = await prisma.adminUser.findUniqueOrThrow({ where: { email } });
expect(stored.lastLoginAt).to.not.equal(null);
});
});
describe("GET /admin/api/auth/me", () => {
it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/admin/api/auth/me");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("returns the admin behind a valid admin session", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
const res = await agent.get("/admin/api/auth/me");
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(email);
});
it("stops returning the admin after logout", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
const logoutRes = await agent.post("/admin/api/auth/logout");
expect(logoutRes.status).to.equal(204);
const meRes = await agent.get("/admin/api/auth/me");
expect(meRes.status).to.equal(401);
});
it("does not accept an end-user session cookie as an admin session", async () => {
// An ordinary user logs in (sets the `session` cookie), then tries the
// admin surface with that same agent — `requireAdmin` reads a
// different cookie entirely, so this must 401 regardless of whether
// ADMIN_JWT_SECRET is configured.
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/admin/api/auth/me");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
});
});

View file

@ -1,32 +0,0 @@
import { expect } from "chai";
import { normalizePlaceholderName } from "../src/modules/admin/admin-catalog.service.js";
/**
* Pure unit tests for {@link normalizePlaceholderName} — the grouping key
* that collapses near-duplicate placeholder spellings into one catalog gap.
* No database, so this file can run standalone (`mocha --no-config`) as
* well as inside the full suite.
*/
describe("normalizePlaceholderName", () => {
it("lower-cases, strips accents and collapses whitespace", () => {
expect(normalizePlaceholderName(" Piment d'Espelette ")).to.equal("piment d espelette");
expect(normalizePlaceholderName("PIMENT D’ESPELETTE")).to.equal("piment d espelette");
expect(normalizePlaceholderName("piment d espelette")).to.equal("piment d espelette");
});
it("neutralises punctuation to a single space", () => {
expect(normalizePlaceholderName("sel & poivre")).to.equal("sel poivre");
expect(normalizePlaceholderName("sel, poivre")).to.equal("sel poivre");
expect(normalizePlaceholderName("fleur-de-sel")).to.equal("fleur de sel");
});
it("keeps digits (a quantity baked into the name still distinguishes it)", () => {
expect(normalizePlaceholderName("Chocolat 70%")).to.equal("chocolat 70");
});
it("maps an all-punctuation / empty string to an empty key", () => {
expect(normalizePlaceholderName("")).to.equal("");
expect(normalizePlaceholderName(" ")).to.equal("");
expect(normalizePlaceholderName("--- ///")).to.equal("");
});
});

View file

@ -1,256 +0,0 @@
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/** A recipe with one placeholder ingredient line whose `displayName` is `name`. Returns the placeholder `Ingredient` id. */
async function seedPlaceholderRecipe(name: string): Promise<number> {
const author = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
const unit = await prisma.unit.findFirstOrThrow({ where: { key: "piece" } });
const placeholder = await prisma.ingredient.create({
data: {
key: `placeholder:${faker.string.uuid()}`,
isPlaceholder: true,
displayName: name,
createdById: author.id,
createdAt: new Date(),
},
});
await prisma.recipe.create({
data: {
name: faker.lorem.words(3),
authorId: author.id,
portions: 2,
ingredients: { create: [{ ingredientId: placeholder.id, quantity: 1, unitId: unit.id }] },
},
});
return placeholder.id;
}
/**
* `/admin/api/catalog/*` — the off-catalog ingredient review. Every route is
* behind `requireAdmin`; the list groups placeholder rows by normalized
* name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows
* no recipe references any more.
*/
describe("Admin catalog — off-catalog ingredients", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
const get = await request(app).get("/admin/api/catalog/placeholders");
expect(get.status).to.equal(401);
const patch = await request(app)
.patch("/admin/api/catalog/placeholders/mark-reviewed")
.send({ ingredientIds: [1] });
expect(patch.status).to.equal(401);
const post = await request(app).post("/admin/api/catalog/placeholders/prune-orphans");
expect(post.status).to.equal(401);
});
it("groups two spellings of the same missing ingredient into one row", async () => {
await seedPlaceholderRecipe("Piment d'Espelette");
await seedPlaceholderRecipe("piment d espelette");
await seedPlaceholderRecipe("Sumac");
const agent = await adminAgent();
const res = await agent.get("/admin/api/catalog/placeholders");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
const espelette = res.body.find(
(g: { normalizedName: string }) => g.normalizedName === "piment d espelette",
);
expect(espelette.recipeCount).to.equal(2);
expect(espelette.ingredientIds).to.have.length(2);
expect(espelette.displayNames).to.have.members(["Piment d'Espelette", "piment d espelette"]);
// Impact-ordered: the 2-recipe gap before the 1-recipe one.
expect(res.body[0].normalizedName).to.equal("piment d espelette");
});
it("mark-reviewed stamps reviewedAt and moves the group out of the default list", async () => {
const id = await seedPlaceholderRecipe("Galanga");
const agent = await adminAgent();
const patched = await agent
.patch("/admin/api/catalog/placeholders/mark-reviewed")
.send({ ingredientIds: [id] });
expect(patched.status).to.equal(200);
expect(patched.body.reviewed).to.equal(1);
expect(
(await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt,
).to.be.an.instanceOf(Date);
const pending = await agent.get("/admin/api/catalog/placeholders");
expect(pending.body).to.have.length(0);
const reviewed = await agent.get("/admin/api/catalog/placeholders").query({ reviewed: "true" });
expect(reviewed.body).to.have.length(1);
expect(reviewed.body[0].allReviewed).to.equal(true);
});
it("prune-orphans deletes only placeholder rows with no recipe left", async () => {
await seedPlaceholderRecipe("Encore utilisé");
await prisma.ingredient.create({
data: {
key: `placeholder:${faker.string.uuid()}`,
isPlaceholder: true,
displayName: "Orphelin",
createdAt: new Date(),
},
});
const agent = await adminAgent();
const res = await agent.post("/admin/api/catalog/placeholders/prune-orphans");
expect(res.status).to.equal(200);
expect(res.body.deleted).to.equal(1);
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
});
});
/**
* `/admin/api/catalog/tech-step-placeholders` — the missing-technique review
* (issue #130). Flat list, `mark-reviewed` stamps `reviewedAt`; every route
* behind `requireAdmin`.
*/
describe("Admin catalog — missing techniques", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
/** A `PUBLIC` recipe whose single step carries a placeholder technique named `name`. Returns the placeholder `TechStep` id. */
async function seedPlaceholderTechnique(name: string): Promise<number> {
const author = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
const techStep = await prisma.techStep.create({
data: {
key: `placeholder:${faker.string.uuid()}`,
isPlaceholder: true,
displayName: name,
createdById: author.id,
createdAt: new Date(),
},
});
await prisma.recipe.create({
data: {
name: faker.lorem.words(3),
authorId: author.id,
portions: 2,
visibility: "PUBLIC",
steps: {
create: [
{
description: "Une étape.",
order: 0,
techSteps: {
create: [{ techStepId: techStep.id, order: 0, start: 0, end: 4, source: "manual" }],
},
},
],
},
},
});
return techStep.id;
}
it("rejects both routes without an admin session", async () => {
expect((await request(app).get("/admin/api/catalog/tech-step-placeholders")).status).to.equal(
401,
);
expect(
(
await request(app)
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
.send({ techStepIds: [1] })
).status,
).to.equal(401);
});
it("lists proposed techniques with their recipe count, impact-ordered", async () => {
await seedPlaceholderTechnique("Flamber au chalumeau");
await seedPlaceholderTechnique("Confire à basse température");
const agent = await adminAgent();
const res = await agent.get("/admin/api/catalog/tech-step-placeholders");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
expect(res.body.map((r: { displayName: string }) => r.displayName)).to.have.members([
"Flamber au chalumeau",
"Confire à basse température",
]);
expect(res.body[0].recipeCount).to.equal(1);
expect(res.body[0].reviewedAt).to.equal(null);
});
it("mark-reviewed stamps reviewedAt and moves the row to the reviewed list", async () => {
const id = await seedPlaceholderTechnique("Snacker à la plancha");
const agent = await adminAgent();
const patched = await agent
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
.send({ techStepIds: [id] });
expect(patched.status).to.equal(200);
expect(patched.body.reviewed).to.equal(1);
expect(
(await prisma.techStep.findUniqueOrThrow({ where: { id } })).reviewedAt,
).to.be.an.instanceOf(Date);
expect((await agent.get("/admin/api/catalog/tech-step-placeholders")).body).to.have.length(0);
const reviewed = await agent
.get("/admin/api/catalog/tech-step-placeholders")
.query({ reviewed: "true" });
expect(reviewed.body).to.have.length(1);
});
});

View file

@ -1,346 +0,0 @@
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/** Creates a `UserProfile` to attribute contact messages to. */
async function seedUser() {
return prisma.userProfile.create({
data: {
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
}
/** One `ContactMessage`, `createdAt` overridable so ordering is deterministic. */
async function seedMessage(overrides: {
authorId: number | null;
type?: string;
message?: string;
createdAt?: Date;
}) {
return prisma.contactMessage.create({
data: {
authorId: overrides.authorId,
type: overrides.type ?? "bug",
message: overrides.message ?? faker.lorem.sentence(),
pageUrl: "/",
userAgent: "test-agent/1.0",
appVersion: "0.2.1",
...(overrides.createdAt ? { createdAt: overrides.createdAt } : {}),
},
});
}
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
const forgejoConfigured =
env.FORGEJO_BASE_URL !== undefined &&
env.FORGEJO_TOKEN !== undefined &&
env.FORGEJO_REPO !== undefined;
describe("Admin contact messages", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
it("rejects the route without an admin session", async () => {
const res = await request(app).get("/admin/api/contact");
expect(res.status).to.equal(401);
});
it("returns messages newest first, with the author resolved", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
await seedMessage({
authorId: user.id,
message: "le plus ancien",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
});
await seedMessage({
authorId: user.id,
message: "le plus récent",
createdAt: new Date("2026-06-01T00:00:00.000Z"),
});
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact");
expect(res.status).to.equal(200);
expect(res.body.map((row: { message: string }) => row.message)).to.deep.equal([
"le plus récent",
"le plus ancien",
]);
expect(res.body[0].author).to.deep.equal({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
});
});
it("filters by type", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
await seedMessage({ authorId: user.id, type: "bug" });
await seedMessage({ authorId: user.id, type: "suggestion" });
await seedMessage({ authorId: user.id, type: "suggestion" });
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact").query({ type: "suggestion" });
expect(res.status).to.equal(200);
expect(res.body).to.have.lengthOf(2);
expect(res.body.every((row: { type: string }) => row.type === "suggestion")).to.equal(true);
});
it("rejects an unknown type filter with 400", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact").query({ type: "complaint" });
expect(res.status).to.equal(400);
});
it("caps the page at 200 rows", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
await prisma.contactMessage.createMany({
data: Array.from({ length: 205 }, () => ({
authorId: user.id,
type: "bug",
message: faker.lorem.sentence(),
})),
});
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact");
expect(res.status).to.equal(200);
expect(res.body).to.have.lengthOf(200);
});
it("reports author: null once the sender's account is deleted", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
await seedMessage({ authorId: user.id, message: "signalé puis compte supprimé" });
await prisma.userProfile.delete({ where: { id: user.id } });
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact");
expect(res.status).to.equal(200);
expect(res.body).to.have.lengthOf(1);
expect(res.body[0].message).to.equal("signalé puis compte supprimé");
expect(res.body[0].author).to.equal(null);
});
describe("Forgejo issue creation (#81)", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
/** Makes `forgejo.createIssue`'s `fetch` return `body` with `status`. */
function stubForgejoFetch(body: unknown, status = 201) {
globalThis.fetch = (async () =>
new Response(JSON.stringify(body), { status })) as typeof fetch;
}
it("returns 401 without an admin session", async () => {
const res = await request(app).post("/admin/api/contact/1/forgejo-issue").send({
title: "t",
body: "b",
});
expect(res.status).to.equal(401);
});
it("exposes the integration status", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const agent = await adminAgent();
const res = await agent.get("/admin/api/contact/forgejo/status");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal({ enabled: forgejoConfigured });
});
it("responds 503 FORGEJO_NOT_CONFIGURED when the env isn't set", async function () {
if (!adminSecretConfigured || forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
const message = await seedMessage({ authorId: user.id });
const agent = await adminAgent();
const res = await agent
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
.send({ title: "Titre", body: "Corps du ticket" });
expect(res.status).to.equal(503);
expect(res.body.code).to.equal(ErrorCode.FORGEJO_NOT_CONFIGURED);
});
it("creates the issue and records the link on the message", async function () {
if (!adminSecretConfigured || !forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
stubForgejoFetch({ number: 77, html_url: "https://forge.example/ci/repo/issues/77" });
const user = await seedUser();
const message = await seedMessage({ authorId: user.id, message: "un bug précis" });
const agent = await adminAgent();
const res = await agent
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
.send({ title: "[Bug] un bug précis", body: "un bug précis\n\n---\ncontexte" });
expect(res.status).to.equal(200);
expect(res.body.forgejoIssueNumber).to.equal(77);
expect(res.body.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77");
const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } });
expect(row.forgejoIssueNumber).to.equal(77);
expect(row.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77");
});
it("responds 404 for an unknown message id", async function () {
if (!adminSecretConfigured || !forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
stubForgejoFetch({ number: 1, html_url: "x" });
const agent = await adminAgent();
const res = await agent
.post("/admin/api/contact/999999/forgejo-issue")
.send({ title: "Titre", body: "Corps" });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_NOT_FOUND);
});
it("responds 409 when the message already links to an issue", async function () {
if (!adminSecretConfigured || !forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
stubForgejoFetch({ number: 2, html_url: "x" });
const user = await seedUser();
const message = await seedMessage({ authorId: user.id });
await prisma.contactMessage.update({
where: { id: message.id },
data: { forgejoIssueNumber: 5, forgejoIssueUrl: "https://forge.example/ci/repo/issues/5" },
});
const agent = await adminAgent();
const res = await agent
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
.send({ title: "Titre", body: "Corps" });
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED);
});
it("leaves the message untouched when the Forgejo call fails (502)", async function () {
if (!adminSecretConfigured || !forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
stubForgejoFetch("upstream boom", 500);
const user = await seedUser();
const message = await seedMessage({ authorId: user.id });
const agent = await adminAgent();
const res = await agent
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
.send({ title: "Titre", body: "Corps" });
expect(res.status).to.equal(502);
expect(res.body.code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } });
expect(row.forgejoIssueNumber).to.equal(null);
expect(row.forgejoIssueUrl).to.equal(null);
});
it("rejects an empty title with 400", async function () {
if (!adminSecretConfigured || !forgejoConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const user = await seedUser();
const message = await seedMessage({ authorId: user.id });
const agent = await adminAgent();
const res = await agent
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
.send({ title: " ", body: "Corps" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
});

View file

@ -1,149 +0,0 @@
import type { SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { bucketByDay } from "../src/modules/admin/admin-metrics.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/** Retries `check` until it stops throwing or `timeoutMs` elapses — `analytics.recordEvent` writes its row fire-and-forget, so a test observing it has to poll briefly. */
async function eventually(check: () => Promise<void>, timeoutMs = 2000): Promise<void> {
const start = Date.now();
for (;;) {
try {
await check();
return;
} catch (err) {
if (Date.now() - start > timeoutMs) throw err;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
}
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
describe("Admin metrics", () => {
describe("bucketByDay (pure)", () => {
const since = new Date("2026-08-01T00:00:00.000Z");
it("returns one zero-filled bucket per day, in date order", () => {
const result = bucketByDay([], since, 3);
expect(result).to.deep.equal([
{ date: "2026-08-01", count: 0 },
{ date: "2026-08-02", count: 0 },
{ date: "2026-08-03", count: 0 },
]);
});
it("counts dates into their UTC day and ignores dates outside the window", () => {
const result = bucketByDay(
[
new Date("2026-08-01T09:00:00Z"),
new Date("2026-08-01T23:30:00Z"),
new Date("2026-08-03T00:00:00Z"),
new Date("2026-07-31T23:59:59Z"), // before the window
new Date("2026-08-10T00:00:00Z"), // after the window
],
since,
3,
);
expect(result).to.deep.equal([
{ date: "2026-08-01", count: 2 },
{ date: "2026-08-02", count: 0 },
{ date: "2026-08-03", count: 1 },
]);
});
});
describe("GET /admin/api/metrics", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("rejects a request with no admin session with 401", async () => {
const res = await request(app).get("/admin/api/metrics");
expect(res.status).to.equal(401);
});
it("returns a snapshot reflecting seeded data, plus zero-filled series", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
// Two end users sign up (also emits `user.signup` analytics events).
const userA = request.agent(app);
const userB = request.agent(app);
await userA.post("/auth/signup").send(buildSignupPayload());
await userB.post("/auth/signup").send(buildSignupPayload());
const adminAgent = request.agent(app);
await adminAgent.post("/admin/api/auth/login").send({ email, password });
const res = await adminAgent.get("/admin/api/metrics").query({ days: 14 });
expect(res.status).to.equal(200);
expect(res.body.rangeDays).to.equal(14);
expect(res.body.snapshot.users).to.equal(2);
expect(res.body.snapshot.admins).to.equal(1);
expect(res.body.snapshot.recipes).to.equal(0);
// 14 daily buckets, each series zero-filled to that length.
expect(res.body.series.signups).to.have.length(14);
expect(
res.body.series.signups.every((b: { count: number }) => typeof b.count === "number"),
).to.equal(true);
// Two signups today → the last bucket counts them.
const signupTotal = res.body.series.signups.reduce(
(sum: number, b: { count: number }) => sum + b.count,
0,
);
expect(signupTotal).to.equal(2);
});
it("records a user.signup analytics event (fire-and-forget, never blocks signup)", async () => {
const agent = request.agent(app);
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
expect(signupRes.status).to.equal(201);
// `>= 1`, not `=== 1`: `recordEvent` is fire-and-forget, so an insert
// from an earlier test's signup could in principle land in this
// window too — the point here is that the instrumentation fires and
// the signup itself was never blocked by it.
await eventually(async () => {
const count = await prisma.analyticsEvent.count({ where: { type: "user.signup" } });
expect(count).to.be.greaterThan(0);
});
});
});
});

View file

@ -1,174 +0,0 @@
import type { AddressInfo } from "node:net";
import type { MonitoringView } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import { Server as SocketIoServer } from "socket.io";
import { type Socket as ClientSocket, io as ioClient } from "socket.io-client";
import { createServer } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { signAdminToken } from "../src/lib/admin-jwt.js";
import { MonitoringBroadcastService } from "../src/lib/monitoring-broadcast.service.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
const VALID_STATUSES = ["up", "degraded", "down", "unknown"];
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
async function seedAdmin(): Promise<{ adminUserId: number; tokenVersion: number }> {
const admin = await prisma.adminUser.create({
data: {
email: faker.internet.email().toLowerCase(),
name: faker.person.fullName(),
passwordHash: await hashAdminPassword(faker.internet.password({ length: 16 })),
},
});
return { adminUserId: admin.id, tokenVersion: admin.tokenVersion };
}
/**
* Waits for `event` on `socket`, rejecting after `timeoutMs` (default
* tuned for the auth-rejection tests, which never touch a real probe and
* so resolve almost immediately — the probe-touching tests below pass a
* larger value explicitly) — every real WebSocket round-trip in this file
* goes through this instead of a bare `socket.once`, so a broken push
* fails the test with a clear message instead of hanging until Mocha's
* own default test timeout.
*/
function waitForEvent<T>(socket: ClientSocket, event: string, timeoutMs = 1500): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Timed out waiting for "${event}"`)),
timeoutMs,
);
socket.once(event, (payload: T) => {
clearTimeout(timer);
resolve(payload);
});
});
}
describe("Admin monitoring — WebSocket push", () => {
let baseUrl: string;
let broadcast: MonitoringBroadcastService;
// Hoisted to suite scope (not a local in `before`) so `after` can shut
// it down. `broadcast.stop()` alone only clears the probe interval — the
// socket.io server keeps engine.io's ping timers and the listening
// http.Server (port 0) alive, and Mocha runs without `--exit`
// (`.mocharc.json`), so that one leaked handle stops the whole process
// from ever draining once the last test passes. That is exactly what
// stalled CI: the `test` job printed "N passing" and then hung for
// hours until the runner was killed.
let io: SocketIoServer;
const clientSockets: ClientSocket[] = [];
// A real listening server, not just createApp()'s bare Express instance
// (supertest's usual approach elsewhere in this suite) — socket.io needs
// an actual http.Server to attach to, and a real client (socket.io-
// client) needs a real port to connect to. Port 0 lets the OS pick a
// free one, so this suite never collides with anything else listening.
before(async () => {
const httpServer = createServer().httpServer;
io = new SocketIoServer(httpServer, { cors: { origin: "*" } });
broadcast = new MonitoringBroadcastService(io);
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
const { port } = httpServer.address() as AddressInfo;
baseUrl = `http://localhost:${port}`;
broadcast.start();
});
after(async () => {
broadcast.stop();
// Disconnects any socket still open, stops engine.io's timers, and
// closes the http.Server socket.io was attached to — without this the
// suite never lets Node's event loop drain (see the `io` field's note).
await new Promise<void>((resolve, reject) => {
io.close((err) => (err ? reject(err) : resolve()));
});
await prisma.$disconnect();
});
beforeEach(async () => {
await resetDatabase();
});
afterEach(() => {
for (const socket of clientSockets.splice(0)) {
socket.disconnect();
}
});
/** Connects a client with the given cookie header (or none), tracked for automatic teardown in `afterEach`. */
function connect(cookie?: string): ClientSocket {
const socket = ioClient(baseUrl, {
extraHeaders: cookie ? { Cookie: cookie } : undefined,
// This suite creates several short-lived connections in a row —
// socket.io-client's default reconnection would otherwise keep
// retrying a deliberately-rejected connection in the background
// after its own test has already moved on.
reconnection: false,
forceNew: true,
});
clientSockets.push(socket);
return socket;
}
it("rejects a connection with no admin cookie at all", async () => {
const socket = connect();
const err = await waitForEvent<Error>(socket, "connect_error");
expect(err.message).to.equal("Not authenticated");
});
it("rejects a connection carrying an end-user session cookie, not an admin one", async () => {
const socket = connect("session=whatever-not-an-admin-token");
const err = await waitForEvent<Error>(socket, "connect_error");
expect(err.message).to.equal("Not authenticated");
});
it("accepts a valid admin session and pushes a monitoring:update covering all four targets", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
// Longer than Mocha's 2000ms default: the very first probe tick can
// take close to PROBE_TIMEOUT_MS (2s) if e.g. the intent-service isn't
// reachable from this test environment (a probe timing out is a valid,
// expected outcome here — this test doesn't assert *which* status
// comes back, only that the shape is right).
this.timeout(8000);
const { adminUserId, tokenVersion } = await seedAdmin();
const token = signAdminToken({ adminUserId, tokenVersion });
const socket = connect(`${env.ADMIN_COOKIE_NAME}=${token}`);
const snapshot = await waitForEvent<MonitoringView>(socket, "monitoring:update", 6000);
expect(typeof snapshot.generatedAt).to.equal("string");
const keys = snapshot.services.map((service) => service.key);
expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]);
for (const service of snapshot.services) {
expect(VALID_STATUSES).to.include(service.status);
}
});
it("broadcasts the same update to every connected admin, not one probe per socket", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
this.timeout(8000); // see the previous test's comment
const { adminUserId, tokenVersion } = await seedAdmin();
const token = signAdminToken({ adminUserId, tokenVersion });
const cookie = `${env.ADMIN_COOKIE_NAME}=${token}`;
const first = connect(cookie);
const second = connect(cookie);
const [firstSnapshot, secondSnapshot] = await Promise.all([
waitForEvent<MonitoringView>(first, "monitoring:update", 6000),
waitForEvent<MonitoringView>(second, "monitoring:update", 6000),
]);
expect(firstSnapshot.generatedAt).to.equal(secondSnapshot.generatedAt);
});
});

View file

@ -1,308 +0,0 @@
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
async function techStepId(key: string): Promise<number> {
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
}
/** A recipe + one step + one correction on it, optionally already turned into a suggestion. */
async function seedCorrectionAndSuggestion(options: {
clause: string;
correctedKey: string | null;
withSuggestion?: { status: string; synonyms: string[] };
}) {
const author = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
const recipe = await prisma.recipe.create({
data: {
name: "R",
authorId: author.id,
portions: 4,
steps: { create: [{ description: options.clause, order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected a step");
const correctedKey = options.correctedKey;
const correction = await prisma.stepTechStepCorrection.create({
data: {
stepId: step.id,
correctorId: author.id,
start: 0,
end: options.clause.length,
previousTechStepId: null,
correctedTechStepId: correctedKey === null ? null : await techStepId(correctedKey),
},
});
let suggestion: { id: number } | null = null;
if (options.withSuggestion && correctedKey !== null) {
suggestion = await prisma.techStepTrainingSuggestion.create({
data: {
techStepId: await techStepId(correctedKey),
locale: "fr",
suggestedSynonyms: options.withSuggestion.synonyms,
suggestedUtterances: [],
sourceType: "correction",
sourceCorrectionId: correction.id,
status: options.withSuggestion.status,
},
});
}
return { recipeId: recipe.id, stepId: step.id, correctionId: correction.id, suggestion };
}
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
describe("Admin tech-steps triage", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
for (const path of [
"/admin/api/tech-steps/suggestions",
"/admin/api/tech-steps/corrections",
"/admin/api/tech-steps/training-data-snippet?techStepKey=simmer",
]) {
const res = await request(app).get(path);
expect(res.status, path).to.equal(401);
}
const post = await request(app).post("/admin/api/tech-steps/retrain").send({});
expect(post.status).to.equal(401);
});
describe("GET /suggestions", () => {
it("groups suggestions by technique and filters by status", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
await seedCorrectionAndSuggestion({
clause: "Faire mijoter",
correctedKey: "simmer",
withSuggestion: { status: "pending", synonyms: ["laisser frémir"] },
});
await seedCorrectionAndSuggestion({
clause: "Émincer les oignons",
correctedKey: "chop",
withSuggestion: { status: "applied", synonyms: ["ciseler"] },
});
const agent = await adminAgent();
const all = await agent.get("/admin/api/tech-steps/suggestions");
expect(all.status).to.equal(200);
expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
"chop",
"simmer",
]);
const simmerGroup = all.body.find((g: { techStepKey: string }) => g.techStepKey === "simmer");
expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");
const pendingOnly = await agent
.get("/admin/api/tech-steps/suggestions")
.query({ status: "pending" });
expect(pendingOnly.body).to.have.length(1);
expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
});
});
describe("PATCH /suggestions/:id", () => {
it("rejects an empty body with 400", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { suggestion } = await seedCorrectionAndSuggestion({
clause: "Faire mijoter",
correctedKey: "simmer",
withSuggestion: { status: "pending", synonyms: ["x"] },
});
const agent = await adminAgent();
const res = await agent.patch(`/admin/api/tech-steps/suggestions/${suggestion?.id}`).send({});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("404s an unknown id", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const agent = await adminAgent();
const res = await agent
.patch("/admin/api/tech-steps/suggestions/999999")
.send({ status: "applied" });
expect(res.status).to.equal(404);
});
it("flips the status and edits the synonyms, reflected in a later GET", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { suggestion } = await seedCorrectionAndSuggestion({
clause: "Faire mijoter",
correctedKey: "simmer",
withSuggestion: { status: "pending", synonyms: ["frémir"] },
});
const agent = await adminAgent();
const patched = await agent
.patch(`/admin/api/tech-steps/suggestions/${suggestion?.id}`)
.send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] });
expect(patched.status).to.equal(200);
expect(patched.body.status).to.equal("applied");
expect(patched.body.suggestedSynonyms).to.deep.equal(["frémir", "mijoter doucement"]);
const stored = await prisma.techStepTrainingSuggestion.findUniqueOrThrow({
where: { id: suggestion?.id },
});
expect(stored.status).to.equal("applied");
});
});
describe("GET /corrections", () => {
it("includes the 'no technique here' removals", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
await seedCorrectionAndSuggestion({ clause: "Rien ici", correctedKey: null });
await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });
const agent = await adminAgent();
const res = await agent.get("/admin/api/tech-steps/corrections");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
const removals = await agent
.get("/admin/api/tech-steps/corrections")
.query({ hasCorrectedTechStep: "false" });
expect(removals.body).to.have.length(1);
expect(removals.body[0].clauseText).to.equal("Rien ici");
expect(removals.body[0].correctedTechStepKey).to.equal(null);
});
});
describe("GET /training-data-snippet", () => {
it("aggregates the applied suggestions' synonyms into a paste-ready block", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
await seedCorrectionAndSuggestion({
clause: "Faire mijoter",
correctedKey: "simmer",
withSuggestion: { status: "applied", synonyms: ["frémir", "réduire à feu doux"] },
});
const agent = await adminAgent();
const res = await agent
.get("/admin/api/tech-steps/training-data-snippet")
.query({ techStepKey: "simmer", locale: "fr", status: "applied" });
expect(res.status).to.equal(200);
expect(res.body.suggestionCount).to.equal(1);
expect(res.body.synonyms).to.deep.equal(["frémir", "réduire à feu doux"]);
expect(res.body.snippet).to.include('"frémir"');
expect(res.body.snippet).to.include('"synonyms": [');
});
});
describe("POST /retrain", () => {
it("runs the F1 gate and returns its result shape (needs tech-step-intent-service)", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
this.timeout(60000);
const agent = await adminAgent();
const res = await agent.post("/admin/api/tech-steps/retrain").send({});
expect(res.status).to.equal(200);
expect(res.body).to.have.keys([
"f1",
"precision",
"recall",
"minF1",
"gatePassed",
"backfilled",
"marked",
]);
expect(res.body.minF1).to.equal(0.8);
if (res.body.gatePassed) {
expect(res.body.backfilled).to.have.keys(["total", "changed"]);
}
});
it("returns 409 RETRAIN_ALREADY_RUNNING while one is in flight", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
this.timeout(60000);
const agent = await adminAgent();
// supertest requests are lazy — they only dispatch when awaited/then'd.
// Attaching the `.then` here is what actually fires the first request,
// so its handler acquires the process-wide lock before the second one
// (100ms later) checks it. Swallow its result/rejection — this test
// only asserts on the second request.
const first = agent
.post("/admin/api/tech-steps/retrain")
.send({})
.then(
(res) => res,
(err) => err,
);
await new Promise((resolve) => setTimeout(resolve, 100));
const second = await agent.post("/admin/api/tech-steps/retrain").send({});
expect(second.status).to.equal(409);
expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
await first;
});
});
});

View file

@ -1,215 +0,0 @@
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/**
* `/admin/api/tours/*` — CRUD over the guided-tour scenarios that
* `GET /reference/tours` serves to the web tour engine (issues #42/#44).
* Every route behind `requireAdmin`. The DB starts seeded with the two
* default scenarios (`general`, `techStepMetadata`) and the `TourTarget`
* catalogue (see `reference-seed-data.ts`).
*/
describe("Admin tours — scenario CRUD", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
expect((await request(app).get("/admin/api/tours")).status).to.equal(401);
expect((await request(app).get("/admin/api/tours/targets")).status).to.equal(401);
expect(
(await request(app).post("/admin/api/tours").send({ key: "x", name: "X" })).status,
).to.equal(401);
expect((await request(app).patch("/admin/api/tours/1").send({})).status).to.equal(401);
expect((await request(app).delete("/admin/api/tours/1")).status).to.equal(401);
});
it("lists the seeded scenarios (enabled and disabled) with their ordered steps", async () => {
const agent = await adminAgent();
await prisma.tourScenario.update({
where: { key: "techStepMetadata" },
data: { enabled: false },
});
const res = await agent.get("/admin/api/tours");
expect(res.status).to.equal(200);
const byKey = (key: string) =>
res.body.find((scenario: { key: string }) => scenario.key === key);
expect(byKey("general").enabled).to.equal(true);
expect(byKey("techStepMetadata").enabled).to.equal(false);
expect(byKey("general").steps.length).to.be.greaterThan(0);
expect(byKey("general").steps[0]).to.have.keys([
"order",
"route",
"targetKey",
"title",
"content",
]);
});
it("lists the highlight-target catalogue", async () => {
const agent = await adminAgent();
const res = await agent.get("/admin/api/tours/targets");
expect(res.status).to.equal(200);
expect(res.body.map((t: { key: string }) => t.key)).to.include("planning-grid");
expect(res.body[0]).to.have.keys(["key", "description"]);
});
it("creates an empty scenario, then fills its steps with a block PATCH", async () => {
const agent = await adminAgent();
const created = await agent
.post("/admin/api/tours")
.send({ key: "welcomeBack", name: "Retour utilisateur" });
expect(created.status).to.equal(201);
expect(created.body).to.include({
key: "welcomeBack",
name: "Retour utilisateur",
enabled: true,
});
expect(created.body.steps).to.deep.equal([]);
const patched = await agent.patch(`/admin/api/tours/${created.body.id}`).send({
name: "Retour utilisateur",
enabled: true,
steps: [
{ route: "/", targetKey: "planning-grid", title: "Le planning", content: "Votre semaine." },
{
route: null,
targetKey: "planning-cook-button",
title: "Cuisiner",
content: "Le bouton pour lancer la séance.",
},
],
});
expect(patched.status).to.equal(200);
expect(patched.body.steps.map((s: { order: number }) => s.order)).to.deep.equal([0, 1]);
expect(patched.body.steps[1].targetKey).to.equal("planning-cook-button");
expect(patched.body.steps[1].route).to.equal(null);
});
it("replaces the whole step list on PATCH (delete-all then recreate)", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const res = await agent.patch(`/admin/api/tours/${general.id}`).send({
name: "Tour d'orientation",
enabled: true,
steps: [
{ route: null, targetKey: "nav-recipes", title: "Recettes", content: "Une seule étape." },
],
});
expect(res.status).to.equal(200);
expect(res.body.steps).to.have.length(1);
expect(await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } })).to.equal(1);
});
it("rejects a duplicate key with 409 TOUR_KEY_ALREADY_IN_USE", async () => {
const agent = await adminAgent();
const res = await agent.post("/admin/api/tours").send({ key: "general", name: "Doublon" });
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.TOUR_KEY_ALREADY_IN_USE);
});
it("rejects a PATCH whose step names an unknown target with 404 TOUR_TARGET_NOT_FOUND", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const res = await agent.patch(`/admin/api/tours/${general.id}`).send({
name: "x",
enabled: true,
steps: [{ route: null, targetKey: "not-a-real-target", title: "t", content: "c" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TOUR_TARGET_NOT_FOUND);
// The scenario's real steps are untouched when the PATCH is rejected.
expect(
await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } }),
).to.be.greaterThan(1);
});
it("404s a PATCH/DELETE against an unknown scenario id", async () => {
const agent = await adminAgent();
const patch = await agent
.patch("/admin/api/tours/999999")
.send({ name: "x", enabled: true, steps: [] });
expect(patch.status).to.equal(404);
expect(patch.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
const del = await agent.delete("/admin/api/tours/999999");
expect(del.status).to.equal(404);
});
it("deletes a scenario and cascades its steps and users' progress", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const user = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
await prisma.tourProgress.create({
data: { userProfileId: user.id, scenarioId: general.id },
});
const res = await agent.delete(`/admin/api/tours/${general.id}`);
expect(res.status).to.equal(204);
expect(await prisma.tourScenario.findUnique({ where: { id: general.id } })).to.equal(null);
expect(await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } })).to.equal(0);
expect(await prisma.tourProgress.count({ where: { scenarioId: general.id } })).to.equal(0);
});
it("hides a disabled scenario from the public GET /reference/tours but keeps it in the admin list", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
await agent
.patch(`/admin/api/tours/${general.id}`)
.send({ name: "Tour d'orientation", enabled: false, steps: [] });
const publicRes = await request(app).get("/reference/tours");
expect(publicRes.body.map((t: { key: string }) => t.key)).to.not.include("general");
const adminRes = await agent.get("/admin/api/tours");
expect(adminRes.body.map((t: { key: string }) => t.key)).to.include("general");
});
});

View file

@ -1,160 +0,0 @@
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/** A fresh, fake (never real-looking) create-user payload — same rationale as `auth.test.ts`'s `buildSignupPayload`. */
function buildCreateUserPayload() {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
};
}
/**
* `/admin/api/users/*` — the admin surface for provisioning end-user
* accounts by hand (`/admin/utilisateurs` page). Every route behind
* `requireAdmin`. Creation generates a one-time temporary password and
* returns it in the response; the account starts with no household, exactly
* like a self-service signup.
*/
describe("Admin users — account provisioning", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
expect((await request(app).get("/admin/api/users")).status).to.equal(401);
expect(
(await request(app).post("/admin/api/users").send(buildCreateUserPayload())).status,
).to.equal(401);
});
it("creates a profile with no household and returns a one-time temporary password", async () => {
const agent = await adminAgent();
const payload = buildCreateUserPayload();
const res = await agent.post("/admin/api/users").send(payload);
expect(res.status).to.equal(201);
expect(res.body.user).to.include({
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
householdName: null,
});
expect(res.body.user).to.not.have.property("passwordHash");
expect(res.body.user).to.not.have.property("tokenVersion");
expect(res.body.temporaryPassword).to.be.a("string").with.length.greaterThan(8);
const stored = await prisma.userProfile.findUnique({ where: { email: payload.email } });
expect(stored?.houseId).to.equal(null);
expect(stored?.dietId).to.equal(null);
});
it("issues a password the new account can actually log in with", async () => {
const agent = await adminAgent();
const payload = buildCreateUserPayload();
const created = await agent.post("/admin/api/users").send(payload);
const login = await request(app)
.post("/auth/login")
.send({ email: payload.email, password: created.body.temporaryPassword });
expect(login.status).to.equal(200);
expect(login.body.email).to.equal(payload.email);
});
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
const agent = await adminAgent();
const payload = buildCreateUserPayload();
await agent.post("/admin/api/users").send(payload);
const res = await agent
.post("/admin/api/users")
.send({ ...buildCreateUserPayload(), email: payload.email });
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE);
});
it("rejects an invalid body with 400 VALIDATION_ERROR", async () => {
const agent = await adminAgent();
const badEmail = await agent
.post("/admin/api/users")
.send({ ...buildCreateUserPayload(), email: "not-an-email" });
expect(badEmail.status).to.equal(400);
expect(badEmail.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
const noFirstName = await agent
.post("/admin/api/users")
.send({ ...buildCreateUserPayload(), firstName: "" });
expect(noFirstName.status).to.equal(400);
});
it("lists accounts newest-first with their household name resolved, never the password", async () => {
const agent = await adminAgent();
const first = buildCreateUserPayload();
const second = buildCreateUserPayload();
const firstRes = await agent.post("/admin/api/users").send(first);
await agent.post("/admin/api/users").send(second);
// Put the first account in a household so `householdName` has something to
// resolve — `House.adminId` is required, so the account is its own admin.
const house = await prisma.house.create({
data: {
name: "Le Foyer Test",
adminId: firstRes.body.user.id,
inviteCode: faker.string.alphanumeric(8).toUpperCase(),
},
});
await prisma.userProfile.update({
where: { email: first.email },
data: { houseId: house.id },
});
const res = await agent.get("/admin/api/users");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
// Newest first: `second` was created after `first`.
expect(res.body[0].email).to.equal(second.email);
expect(res.body[1].email).to.equal(first.email);
expect(res.body[1].householdName).to.equal("Le Foyer Test");
expect(res.body[0].householdName).to.equal(null);
for (const row of res.body) {
expect(row).to.not.have.property("temporaryPassword");
expect(row).to.not.have.property("passwordHash");
}
});
});

View file

@ -55,17 +55,6 @@ describe("Auth", () => {
expect(res.headers["set-cookie"]?.[0]).to.include("session="); expect(res.headers["set-cookie"]?.[0]).to.include("session=");
}); });
it("also echoes the session JWT in the response body (for cookie-less clients)", async () => {
const res = await request(app).post("/auth/signup").send(buildSignupPayload());
expect(res.status).to.equal(201);
// Same token that's set as the cookie — see `AuthResponse`
// (packages/shared). Lets a Capacitor WebView, whose origin can't
// receive the SameSite cookie, store it and send it as a Bearer.
expect(res.body.token).to.be.a("string").with.length.greaterThan(0);
expect(res.headers["set-cookie"]?.[0]).to.include(`session=${res.body.token}`);
});
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => { it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
const payload = buildSignupPayload(); const payload = buildSignupPayload();
await request(app).post("/auth/signup").send(payload); await request(app).post("/auth/signup").send(payload);
@ -106,16 +95,6 @@ describe("Auth", () => {
expect(res.body.email).to.equal(payload.email); expect(res.body.email).to.equal(payload.email);
}); });
it("echoes the session JWT in the response body too", async () => {
const res = await request(app)
.post("/auth/login")
.send({ email: payload.email, password: payload.password });
expect(res.status).to.equal(200);
expect(res.body.token).to.be.a("string").with.length.greaterThan(0);
expect(res.headers["set-cookie"]?.[0]).to.include(`session=${res.body.token}`);
});
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => { it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app) const res = await request(app)
.post("/auth/login") .post("/auth/login")
@ -155,41 +134,6 @@ describe("Auth", () => {
}); });
}); });
// The Bearer path exists for clients whose origin can't carry the
// `SameSite=Lax` session cookie (a Capacitor WebView — issue #39). The
// cookie stays the primary mechanism; the header is only consulted when
// no cookie is present. `request(app)` (no `.agent`) sends no cookie, so
// these requests exercise the fallback in isolation.
describe("Authorization: Bearer fallback (cookie-less clients)", () => {
it("authenticates a protected route with a valid Bearer token and no cookie", async () => {
const payload = buildSignupPayload();
const signupRes = await request(app).post("/auth/signup").send(payload);
const res = await request(app)
.get("/auth/me")
.set("Authorization", `Bearer ${signupRes.body.token}`);
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(payload.email);
});
it("rejects a malformed Bearer token with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/auth/me").set("Authorization", "Bearer not-a-real-jwt");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("ignores a non-Bearer Authorization scheme and 401s", async () => {
const res = await request(app)
.get("/auth/me")
.set("Authorization", "Basic dXNlcjpwYXNzd29yZA==");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
});
describe("DELETE /auth/me", () => { describe("DELETE /auth/me", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).delete("/auth/me").send({ password: "whatever" }); const res = await request(app).delete("/auth/me").send({ password: "whatever" });

View file

@ -1,143 +0,0 @@
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** A valid `POST /contact` body — message comfortably over the 10-char minimum. */
function buildContactPayload() {
return {
type: "bug" as const,
message: "Le bouton « Envoyer » ne réagit pas sur la page planning.",
pageUrl: "/",
appVersion: "0.2.1",
};
}
describe("Contact", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /contact", () => {
it("rejects requests without a session with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).post("/contact").send(buildContactPayload());
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
expect(await prisma.contactMessage.count()).to.equal(0);
});
it("stores the message with its fields, attributed to the caller, and captures the user-agent from the header", async () => {
const agent = request.agent(app);
const signup = buildSignupPayload();
await agent.post("/auth/signup").send(signup);
const author = await prisma.userProfile.findFirstOrThrow({ where: { email: signup.email } });
const res = await agent
.post("/contact")
.set("User-Agent", "BatchCookingApp/0.2.1 (Android 14)")
.send(buildContactPayload());
expect(res.status).to.equal(204);
const rows = await prisma.contactMessage.findMany();
expect(rows).to.have.lengthOf(1);
const [row] = rows;
expect(row.authorId).to.equal(author.id);
expect(row.type).to.equal("bug");
expect(row.message).to.equal(buildContactPayload().message);
expect(row.pageUrl).to.equal("/");
expect(row.appVersion).to.equal("0.2.1");
// Read from the request header, never the body.
expect(row.userAgent).to.equal("BatchCookingApp/0.2.1 (Android 14)");
});
it("ignores any user-agent sent in the body — the header is the only source", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent
.post("/contact")
.set("User-Agent", "real-agent/1.0")
.send({ ...buildContactPayload(), userAgent: "spoofed-agent/9.9" });
expect(res.status).to.equal(204);
const row = await prisma.contactMessage.findFirstOrThrow();
expect(row.userAgent).to.equal("real-agent/1.0");
});
it("keeps pageUrl / appVersion null when the client omits them", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.post("/contact").send({
type: "suggestion",
message: "Ajoutez un mode hors-ligne pour la liste de courses.",
});
expect(res.status).to.equal(204);
const row = await prisma.contactMessage.findFirstOrThrow();
expect(row.type).to.equal("suggestion");
expect(row.pageUrl).to.equal(null);
expect(row.appVersion).to.equal(null);
});
it("rejects a message shorter than 10 characters with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
// 5 chars — and the schema trims before counting, so leading/trailing
// whitespace can't pad it to the minimum.
const res = await agent.post("/contact").send({ type: "bug", message: " court " });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(await prisma.contactMessage.count()).to.equal(0);
});
it("rejects a message longer than 5000 characters with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.post("/contact").send({ type: "bug", message: "a".repeat(5001) });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(await prisma.contactMessage.count()).to.equal(0);
});
it("rejects an unknown type with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent
.post("/contact")
.send({ type: "complaint", message: "Ceci est un message de test suffisamment long." });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(await prisma.contactMessage.count()).to.equal(0);
});
});
});

View file

@ -1,369 +0,0 @@
import type { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */
function isoDate(date: DateTime): string {
const iso = date.toISODate();
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
return iso;
}
/** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */
function today(): string {
return isoDate(TEST_REFERENCE_DATE);
}
/** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */
async function ingredientId(key: string): Promise<number> {
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
}
async function unitId(key: string): Promise<number> {
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
}
async function techStepId(key: string): Promise<number> {
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
}
describe("Cooking session", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /cooking-session", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/cooking-session").query({ date: "not-a-date" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty plan when the profile has no household", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes).to.deep.equal([]);
expect(res.body.phases).to.deep.equal([]);
});
it("returns an empty plan when no planning covers that week", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await agent.post("/house").send({ name: "Chez moi" });
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.phases).to.deep.equal([]);
});
it("pools an identical prep step from two planned recipes into one merged-prep task", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const onionId = await ingredientId("onion");
const pieceId = await unitId("piece");
const chopId = await techStepId("chop");
const simmerId = await techStepId("simmer");
const mixId = await techStepId("mix");
/**
* A recipe: one pure-prep "chop onion" step, then (optionally) an
* active "mix" step, then one simmer step. The `withActiveStep` recipe
* is still doing hands-on work in the phase after its simmer starts, so
* the other recipe's simmer floats into that phase as `background`.
*/
async function makeRecipe(name: string, onionQty: number, withActiveStep = false) {
const chopStep = {
order: 0,
description: "Émincer les oignons",
techSteps: {
create: [
{
techStepId: chopId,
order: 0,
ingredients: {
create: [
{
ingredientId: onionId,
quantity: onionQty,
unitId: pieceId,
start: 0,
end: 1,
},
],
},
},
],
},
};
const activeStep = {
order: 1,
description: "Mélanger l'appareil",
techSteps: { create: [{ techStepId: mixId, order: 0 }] },
};
const simmerStep = {
order: withActiveStep ? 2 : 1,
description: "Faire mijoter",
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
};
return prisma.recipe.create({
data: {
name,
authorId,
portions: 4,
steps: {
create: withActiveStep ? [chopStep, activeStep, simmerStep] : [chopStep, simmerStep],
},
},
});
}
const soupe = await makeRecipe("Soupe", 2);
const tarte = await makeRecipe("Tarte", 3, true);
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: soupe.id,
portions: 4,
portionsRequested: 4,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: tarte.id,
portions: 4,
portionsRequested: 4,
},
],
});
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([
"Soupe",
"Tarte",
]);
const mise = res.body.phases[0];
expect(mise.kind).to.equal("mise-en-place");
const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep");
expect(merged).to.have.length(1);
expect(merged[0].technique.key).to.equal("chop");
expect(merged[0].ingredients[0].ingredient.key).to.equal("onion");
expect(merged[0].ingredients[0].quantity).to.equal(5);
expect(merged[0].sourceRecipes).to.have.length(2);
// The simmer steps land in a later phase, and one shows as background.
const later = res.body.phases.slice(1);
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
expect(backgrounds.length).to.be.greaterThan(0);
});
it("pools two preheats at the same temperature, and flags a conflict at different temperatures", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const preheatId = await techStepId("preheat");
const bakeId = await techStepId("bake");
/** A recipe whose first step is "préchauffer le four" carrying a °C temperature, then a bake. */
async function makeOvenRecipe(name: string, description: string, celsius: number) {
return prisma.recipe.create({
data: {
name,
authorId,
portions: 4,
steps: {
create: [
{
order: 0,
description,
techSteps: {
create: [
{
techStepId: preheatId,
order: 0,
temperatures: {
create: [
{
start: 0,
end: description.length,
celsius,
gasMark: null,
qualitative: null,
raw: `${celsius}°C`,
},
],
},
},
],
},
},
{
order: 1,
description: "Enfourner",
techSteps: { create: [{ techStepId: bakeId, order: 0 }] },
},
],
},
},
});
}
async function planWeek(recipeIds: number[]) {
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: recipeIds.map((recipeId, i) => ({
planningId: planning.id,
weekDay: i === 0 ? "lundi" : "mardi",
meal: i === 0 ? "dejeuner" : "diner",
recipeId,
portions: 4,
portionsRequested: 4,
})),
});
}
// Same temperature → one pooled merged-setup task, no conflict note.
const gratin = await makeOvenRecipe("Gratin", "Préchauffer le four à 180°C", 180);
const tarte = await makeOvenRecipe("Tarte", "Préchauffer à 180 degrés", 180);
await planWeek([gratin.id, tarte.id]);
let res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
let mise = res.body.phases[0];
const setupMerges = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-setup");
expect(setupMerges).to.have.length(1);
expect(setupMerges[0].technique.key).to.equal("preheat");
expect(setupMerges[0].temperatures).to.deep.equal([
{ celsius: 180, gasMark: null, qualitative: null },
]);
expect(mise.notes).to.deep.equal([]);
// Re-plan the same week with a second temperature → not merged, conflict note.
await prisma.planning.deleteMany({ where: { houseId } });
const roti = await makeOvenRecipe("Rôti", "Préchauffer le four à 210°C", 210);
await planWeek([gratin.id, roti.id]);
res = await agent.get("/cooking-session").query({ date: today() });
mise = res.body.phases[0];
expect(mise.tasks.filter((t: { kind: string }) => t.kind === "merged-setup")).to.have.length(
0,
);
const preheats = mise.tasks.filter(
(t: { technique: { key: string } | null }) => t.technique?.key === "preheat",
);
expect(preheats.map((t: { description: string }) => t.description)).to.deep.equal([
"Préchauffer le four à 180°C",
"Préchauffer le four à 210°C",
]);
const note = mise.notes.find(
(n: { kind: string }) => n.kind === "appliance-temperature-conflict",
);
expect(note, "a conflict note").to.not.equal(undefined);
expect(note.temperatures.map((tp: { celsius: number }) => tp.celsius)).to.deep.equal([
180, 210,
]);
});
it("leaves a leftovers item out of the cooking plan — it has nothing to cook (issue #34)", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const recipe = await prisma.recipe.create({
data: {
name: "Soupe",
authorId: houseRes.body.adminId,
portions: 4,
steps: { create: [{ order: 0, description: "Faire mijoter la soupe." }] },
},
});
// One real slot (12 prepared for 10 requested), then a leftovers slot
// reusing 2 of it — the plan must still contain only "Soupe" once.
const source = await agent.post("/planning/items").send({
date: today(),
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portionsRequested: 10,
});
await agent.post("/planning/items/leftover").send({
date: today(),
weekDay: "mardi",
meal: "dejeuner",
sourceItemId: source.body.id,
portions: 2,
});
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.deep.equal(["Soupe"]);
});
});
});

View file

@ -1,59 +0,0 @@
import { expect } from "chai";
import { envSchema } from "../src/config/env.js";
/**
* Pure schema test — no DB, no `process.env`. Covers `blankAsUnset` on the
* `FORGEJO_*` trio: `docker-compose.yml` forwards every optional var as
* `${FOO:-}`, which the container receives as `""` (not an absent key) when
* the stack's `.env` leaves it blank. Those three must read a blank value
* as "not configured" and still boot, rather than trip `FORGEJO_BASE_URL`'s
* `.url()` / `FORGEJO_REPO`'s regex and abort `parse()` at startup.
*/
describe("envSchema — FORGEJO_* blank handling", () => {
// The only two vars with no default and no `.optional()`.
const base = {
JWT_SECRET: "x".repeat(32),
INTENT_SERVICE_SECRET: "y".repeat(32),
};
it("treats blank FORGEJO_* values as unset instead of failing validation", () => {
const parsed = envSchema.parse({
...base,
FORGEJO_BASE_URL: "",
FORGEJO_TOKEN: "",
FORGEJO_REPO: "",
});
expect(parsed.FORGEJO_BASE_URL).to.equal(undefined);
expect(parsed.FORGEJO_TOKEN).to.equal(undefined);
expect(parsed.FORGEJO_REPO).to.equal(undefined);
});
it("treats a fully absent FORGEJO_* trio the same way", () => {
const parsed = envSchema.parse({ ...base });
expect(parsed.FORGEJO_BASE_URL).to.equal(undefined);
expect(parsed.FORGEJO_TOKEN).to.equal(undefined);
expect(parsed.FORGEJO_REPO).to.equal(undefined);
});
it("still passes through and validates non-blank FORGEJO_* values", () => {
const parsed = envSchema.parse({
...base,
FORGEJO_BASE_URL: "https://git.example.com",
FORGEJO_TOKEN: "a-token",
FORGEJO_REPO: "owner/repo",
});
expect(parsed.FORGEJO_BASE_URL).to.equal("https://git.example.com");
expect(parsed.FORGEJO_TOKEN).to.equal("a-token");
expect(parsed.FORGEJO_REPO).to.equal("owner/repo");
});
it("still rejects a non-blank but malformed FORGEJO_BASE_URL", () => {
expect(() => envSchema.parse({ ...base, FORGEJO_BASE_URL: "git.example.com" })).to.throw();
});
it("still rejects a non-blank but malformed FORGEJO_REPO", () => {
expect(() => envSchema.parse({ ...base, FORGEJO_REPO: "not-a-repo" })).to.throw();
});
});

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