Compare commits
No commits in common. "main" and "feat/themealdb-source-icon" have entirely different histories.
main
...
feat/theme
539 changed files with 4099 additions and 65593 deletions
|
|
@ -6,12 +6,6 @@
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "pnpm",
|
||||||
"runtimeArgs": ["--filter", "web", "dev"],
|
"runtimeArgs": ["--filter", "web", "dev"],
|
||||||
"port": 5173
|
"port": 5173
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "api",
|
|
||||||
"runtimeExecutable": "pnpm",
|
|
||||||
"runtimeArgs": ["--filter", "api", "dev"],
|
|
||||||
"port": 3000
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
.env.example
57
.env.example
|
|
@ -15,66 +15,9 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# (docker-compose.yml), serving both the API and the built frontend.
|
# (docker-compose.yml), serving both the API and the built frontend.
|
||||||
# APP_PORT=3000
|
# APP_PORT=3000
|
||||||
|
|
||||||
# Optional — tag of the prebuilt images the release CI pushes to the Forgejo
|
|
||||||
# container registry (git.dev.kyuno.fr/kyuno/batchcooking-{app,intent-service,
|
|
||||||
# llm-worker}). Used by `docker compose pull` and a pull-based Portainer
|
|
||||||
# stack; leave it `latest` or pin a release tag (e.g. v0.2.4). Ignored when
|
|
||||||
# building locally instead (`docker compose build`, or `up` with no image
|
|
||||||
# present) — see docker-compose.yml's `image:`/`build:` comment.
|
|
||||||
# IMAGE_TAG=latest
|
|
||||||
|
|
||||||
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
|
|
||||||
# All optional: an instance that doesn't run the admin app needs none of
|
|
||||||
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
|
||||||
# leaving it out simply disables every /admin/* route. The admin UI is
|
|
||||||
# served by the same app/container as the rest of the frontend — no
|
|
||||||
# separate origin, so no CORS entry of its own.
|
|
||||||
#
|
|
||||||
# Secret for the admin session JWT — MUST be different from JWT_SECRET so an
|
|
||||||
# end-user token can never be replayed against /admin/*. Generate your own
|
|
||||||
# the same way as JWT_SECRET above.
|
|
||||||
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
# Optional — read only by `src/scripts/create-admin.ts` when its --email /
|
|
||||||
# --password / --name flags are omitted (e.g. to bootstrap the first admin
|
|
||||||
# from inside the container). Never read by the running server.
|
|
||||||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
|
||||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
|
||||||
# ADMIN_INITIAL_NAME=Ops
|
|
||||||
#
|
|
||||||
# Optional — lets an admin open a Forgejo ticket from a contact message
|
|
||||||
# (apps/web's /admin/messages, issue #81). All three needed together: the
|
|
||||||
# button stays hidden and the endpoint answers 503 unless all are set to a
|
|
||||||
# non-empty value. docker-compose.yml forwards these into the "app"
|
|
||||||
# container, so setting them here is enough (a blank value counts as
|
|
||||||
# unset). FORGEJO_TOKEN is a personal access token with the write:issue
|
|
||||||
# scope — generate it in Forgejo, never commit the real value.
|
|
||||||
# FORGEJO_BASE_URL=https://git.example.com
|
|
||||||
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
|
|
||||||
# FORGEJO_REPO=owner/repo
|
|
||||||
|
|
||||||
# Optional — only set this to false if THIS deployment is served over
|
# Optional — only set this to false if THIS deployment is served over
|
||||||
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
||||||
# requires HTTPS (Secure attribute) as it should for a real deployment;
|
# requires HTTPS (Secure attribute) as it should for a real deployment;
|
||||||
# over plain HTTP a Secure cookie is silently never sent back by the
|
# over plain HTTP a Secure cookie is silently never sent back by the
|
||||||
# browser, so login "succeeds" but every subsequent request 401s.
|
# browser, so login "succeeds" but every subsequent request 401s.
|
||||||
# COOKIE_SECURE=false
|
# COOKIE_SECURE=false
|
||||||
|
|
||||||
# Required — secret shared between "app" and "tech-step-intent-service"
|
|
||||||
# (docker-compose.yml, apps/api/src/config/env.ts). Unlike
|
|
||||||
# INTERNAL_WORKER_SECRET below, there's no "leave it unset" escape hatch:
|
|
||||||
# tech-step-intent-service is a core dependency, not an optional background
|
|
||||||
# job — without it, no recipe step can have its techniques detected at all.
|
|
||||||
# Generate your own the same way as JWT_SECRET above.
|
|
||||||
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Only needed to run the optional `tech-step-llm-worker` service — shared
|
|
||||||
# between it and "app" (docker-compose.yml). Generate your own the same
|
|
||||||
# way as JWT_SECRET above; leave both this and the service commented
|
|
||||||
# out/unset to run without it.
|
|
||||||
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Optional — cron expression (node-cron syntax) the worker wakes up on to
|
|
||||||
# run its audit/feedback-loop jobs. Default: weekly, Sunday 03:00 — a
|
|
||||||
# provisional floor, not a calibrated value (see
|
|
||||||
# services/tech-step-llm-worker/README.md).
|
|
||||||
# TECH_STEP_WORKER_CRON=0 3 * * 0
|
|
||||||
|
|
|
||||||
302
.github/workflows/ci.yml
vendored
302
.github/workflows/ci.yml
vendored
|
|
@ -12,53 +12,22 @@ on:
|
||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Hostname `postgres` = the container the `test` job starts itself (see
|
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
||||||
# that job's "Start Postgres" step) — a plain `docker run`, not a
|
|
||||||
# `services:` block, for the same reason the intent-service below can't be
|
|
||||||
# one: this repo's Forgejo `act` runner does not keep a `services:`
|
|
||||||
# container as the *same* instance across a job's steps, so `migrate
|
|
||||||
# deploy` would land the schema in one Postgres and `pnpm --filter api
|
|
||||||
# test`'s `beforeAll` would then hit an empty one ("table … does not
|
|
||||||
# exist" right after "migrations successfully applied"). One long-lived
|
|
||||||
# container in the job, shared by every step, removes that failure mode.
|
|
||||||
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
|
|
||||||
# Test-only secret, never used outside CI — real deployments must set their own.
|
# Test-only secret, never used outside CI — real deployments must set their own.
|
||||||
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
||||||
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
|
|
||||||
# exercise the success path (matching secret), not just the "unset"
|
|
||||||
# rejection every environment that doesn't set this gets by default.
|
|
||||||
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
|
|
||||||
# Same reasoning — lets admin-auth.test.ts exercise the admin login
|
|
||||||
# success path + the requireAdmin-guarded routes, not just the "no admin
|
|
||||||
# secret configured -> 401" path.
|
|
||||||
ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+"
|
|
||||||
# Shared between the `test` job's own uvicorn step (below) and apps/api's
|
|
||||||
# IntentServiceClient — that step runs `uvicorn` as a plain background
|
|
||||||
# process (not a `services:` block or even a container) because CI can
|
|
||||||
# only pull a published image, not build
|
|
||||||
# services/tech-step-intent-service/Dockerfile from this checkout.
|
|
||||||
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
|
|
||||||
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
|
|
||||||
# Same reasoning — lets forgejo.service.test.ts / admin-contact.test.ts
|
|
||||||
# exercise the "configured" path (issue #81). `globalThis.fetch` is stubbed
|
|
||||||
# in those tests, so no real request ever leaves CI; these values only need
|
|
||||||
# to parse (a valid URL and an `owner/repo`).
|
|
||||||
FORGEJO_BASE_URL: "https://forgejo.ci.invalid"
|
|
||||||
FORGEJO_TOKEN: "ci-only-forgejo-token-not-used-anywhere-else"
|
|
||||||
FORGEJO_REPO: "ci/batchcooking"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Five independent jobs, no needs: between them — each starts in parallel
|
# Four independent jobs, no needs: between them — each starts in parallel
|
||||||
# and reports as its own check, instead of the previous single chained
|
# and reports as its own check, instead of the previous single chained
|
||||||
# "lint-and-test then e2e" pipeline.
|
# "lint-and-test then e2e" pipeline.
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -68,166 +37,42 @@ jobs:
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# ~16-19 min of actual work (runs 117-124); the extra budget covers time
|
services:
|
||||||
# spent queued in "Wait for any earlier test job" below. Also a runaway
|
postgres:
|
||||||
# ceiling — Mocha has no `--exit` (`apps/api/.mocharc.json`), so one
|
image: postgres:16-alpine
|
||||||
# leaked handle would otherwise hang the job until the runner's global
|
env:
|
||||||
# timeout (a `test` job zombied ~1h once). If a hung job hits this
|
POSTGRES_USER: ci
|
||||||
# ceiling the runner removes its container, which unblocks the queue.
|
POSTGRES_PASSWORD: ci
|
||||||
timeout-minutes: 45
|
POSTGRES_DB: batchcooking_ci
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- name: Install curl and the docker CLI
|
- uses: actions/checkout@v4
|
||||||
run: |
|
|
||||||
apt-get update && apt-get install -y curl docker.io
|
|
||||||
|
|
||||||
# Serialize `test` across every concurrent run. Two `test` jobs must
|
|
||||||
# never overlap: they share one `--name postgres` container on the
|
|
||||||
# runner's shared docker network (next step), so their `prisma migrate
|
|
||||||
# deploy` + `seedReferenceData` (root Mocha `beforeAll`) race —
|
|
||||||
# `Unique constraint failed on (key)` in `seedReferenceData`, "No
|
|
||||||
# pending migrations" when there should be some, `tech_step`/`diet` id
|
|
||||||
# shifts breaking unrelated assertions; and even past that they starve
|
|
||||||
# each other's RAM/CPU (each also trains a spaCy model) until
|
|
||||||
# `resetDatabase()` overruns Mocha's 10s hook timeout and cascades.
|
|
||||||
# Hit repeatedly (PRs #47, #53, #54), each needing an empty-commit
|
|
||||||
# re-trigger once the runner was idle.
|
|
||||||
#
|
|
||||||
# GitHub's `concurrency:` is the native fix but this Forgejo (9.0.3)
|
|
||||||
# predates Actions `concurrency` — it arrived in Forgejo v14 / Gitea
|
|
||||||
# 1.26 and is silently ignored here. A lock object (network, container,
|
|
||||||
# file) would leak: this runner (forgejo-runner v3.5.0) does NOT run a
|
|
||||||
# trailing `if: always()` step after an earlier step fails, so there is
|
|
||||||
# no reliable in-job release. Instead, key off the one thing the runner
|
|
||||||
# *does* always clean up — the job container itself (removed on pass,
|
|
||||||
# fail, and cancel alike): wait while any lower-numbered `test` job's
|
|
||||||
# container is still running. Lower task id goes first, so two jobs
|
|
||||||
# that start together can't deadlock; nothing to release.
|
|
||||||
- name: Wait for any earlier test job
|
|
||||||
run: |
|
|
||||||
my_id="$(printf '%s\n' "${JOB_CONTAINER_NAME:-}" | sed -n 's/.*TASK-\([0-9]\{1,\}\).*/\1/p')"
|
|
||||||
echo "this test job is task ${my_id:-<unknown, not serializing>}"
|
|
||||||
# ~25 min cap (75 * 20s): comfortably covers one earlier run
|
|
||||||
# (the realistic overlap is two runs); a rarer 3-way pile-up whose
|
|
||||||
# wait exceeds this just fails and gets an empty-commit re-run.
|
|
||||||
for attempt in $(seq 1 75); do
|
|
||||||
earlier="$(docker ps --format '{{.Names}}' 2>/dev/null \
|
|
||||||
| sed -n 's/.*TASK-\([0-9]\{1,\}\)_.*_JOB-test$/\1/p' \
|
|
||||||
| awk -v me="${my_id:-0}" 'NF && ($1 + 0) < (me + 0)')"
|
|
||||||
if [ -z "$earlier" ]; then
|
|
||||||
[ "$attempt" -gt 1 ] && echo "earlier test job(s) finished — proceeding"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "waiting for earlier test job(s): $(printf '%s ' $earlier)(attempt ${attempt})"
|
|
||||||
sleep 20
|
|
||||||
done
|
|
||||||
echo "still blocked by an earlier test job after ~25 min — giving up; a re-run should get through" >&2
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
# Postgres as a plain background container instead of a `services:`
|
|
||||||
# block — see this workflow's top-level `env.DATABASE_URL` comment for
|
|
||||||
# why. Reachable as `postgres` from this job (the runner mounts the
|
|
||||||
# docker socket and runs jobs on a shared user-defined network, so a
|
|
||||||
# `--name postgres` sibling resolves by name); the network is
|
|
||||||
# discovered from the job container itself rather than hardcoded. The
|
|
||||||
# `docker rm -f postgres` below also clears a container left behind by
|
|
||||||
# the previous run (this runner has no reliable post-job cleanup), and
|
|
||||||
# the serialization step above means no other run is using it now.
|
|
||||||
- name: Start Postgres in the background
|
|
||||||
run: |
|
|
||||||
net="$(docker inspect "$(hostname)" \
|
|
||||||
--format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' \
|
|
||||||
| awk '{print $1}')"
|
|
||||||
echo "job network: ${net:?could not determine the job container network}"
|
|
||||||
docker rm -f postgres >/dev/null 2>&1 || true
|
|
||||||
# Also drop any `postgres-<suffix>` container. A since-reverted
|
|
||||||
# attempt at per-run Postgres naming on this branch left some
|
|
||||||
# running on this persistent runner (their `if: always()` teardown
|
|
||||||
# never fired — this runner skips trailing steps after a failure),
|
|
||||||
# and nothing else removes them. A second idle Postgres here starves
|
|
||||||
# the runner's RAM and slows every query ~5x → `resetDatabase()`
|
|
||||||
# blows Mocha's 10s hook timeout. Nothing creates `postgres-*` any
|
|
||||||
# more, so this only ever clears that debris.
|
|
||||||
docker ps -aq --filter 'name=^postgres-' | xargs -r docker rm -f >/dev/null 2>&1 || true
|
|
||||||
docker run -d --name postgres --network "$net" \
|
|
||||||
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \
|
|
||||||
postgres:16-alpine
|
|
||||||
for _ in $(seq 1 60); do
|
|
||||||
docker exec postgres pg_isready -U ci -d batchcooking_ci && exit 0
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Postgres did not become ready in time" >&2
|
|
||||||
docker logs postgres || true
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- uses: https://github.com/astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
enable-cache: true
|
|
||||||
|
|
||||||
# A `services:` block can only pull an already-published image — it
|
|
||||||
# can't build services/tech-step-intent-service/Dockerfile from this
|
|
||||||
# checkout. Running `uvicorn` as a plain background step instead: it
|
|
||||||
# keeps running for the rest of this job (steps in one job share the
|
|
||||||
# same process tree), and `pnpm --filter api test` below needs a real
|
|
||||||
# instance to talk to per this repo's "never mock an internal service"
|
|
||||||
# test convention — same "real dependency, started by the job itself"
|
|
||||||
# posture as the Postgres step above, not a mock HTTP server.
|
|
||||||
- name: Install services/tech-step-intent-service
|
|
||||||
working-directory: services/tech-step-intent-service
|
|
||||||
run: uv sync --frozen
|
|
||||||
- name: Start services/tech-step-intent-service in the background
|
|
||||||
working-directory: services/tech-step-intent-service
|
|
||||||
run: |
|
|
||||||
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
|
|
||||||
# `/health` only returns 200 once this service has finished
|
|
||||||
# training itself from scratch (no model ever persisted to disk —
|
|
||||||
# see its own README) — measured at ~3min combined (fr+en) against
|
|
||||||
# the current ~74-technique corpus, down from ~930s once
|
|
||||||
# `spacy.TextCatBOW.v3`'s `length` stopped being left at its
|
|
||||||
# 262144 default (see `_TEXTCAT_MODEL_LENGTH` in
|
|
||||||
# `locale_pipeline.py`, and docker-compose.yml's healthcheck for
|
|
||||||
# the same reasoning). The timeout stays well above that: it's a
|
|
||||||
# ceiling for a slow/contended runner, not a target.
|
|
||||||
timeout 600 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm --filter api exec prisma migrate deploy
|
- run: pnpm --filter api exec prisma migrate deploy
|
||||||
- run: pnpm --filter api test
|
- run: pnpm --filter api test
|
||||||
|
|
||||||
intent-service-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
enable-cache: true
|
|
||||||
|
|
||||||
- name: Install services/tech-step-intent-service
|
|
||||||
working-directory: services/tech-step-intent-service
|
|
||||||
run: uv sync --frozen
|
|
||||||
- name: Run pytest
|
|
||||||
working-directory: services/tech-step-intent-service
|
|
||||||
run: uv run pytest -q
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -238,22 +83,17 @@ jobs:
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Xvfb and Cypress dependencies
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth dbus-x11
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Cache Cypress binary
|
- name: Cache Cypress binary
|
||||||
uses: https://github.com/actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/Cypress
|
path: ~/.cache/Cypress
|
||||||
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
@ -263,99 +103,9 @@ jobs:
|
||||||
# download (see apps/web's cypress caveat in the README) — install it
|
# download (see apps/web's cypress caveat in the README) — install it
|
||||||
# explicitly so `cypress run` finds it.
|
# explicitly so `cypress run` finds it.
|
||||||
- run: pnpm --filter web exec cypress install
|
- run: pnpm --filter web exec cypress install
|
||||||
- name: Run E2E tests
|
- run: pnpm --filter web e2e
|
||||||
env:
|
|
||||||
HOST: "0.0.0.0"
|
|
||||||
run: pnpm --filter web e2e
|
|
||||||
# No dev server needed here — Cypress spins up its own Vite dev
|
# No dev server needed here — Cypress spins up its own Vite dev
|
||||||
# server internally for component testing (see cypress.config.ts's
|
# server internally for component testing (see cypress.config.ts's
|
||||||
# `component.devServer`), unlike `e2e` above which needs the real app
|
# `component.devServer`), unlike `e2e` above which needs the real app
|
||||||
# running first.
|
# running first.
|
||||||
- run: pnpm --filter web cy:run:component
|
- run: pnpm --filter web cy:run:component
|
||||||
|
|
||||||
# Builds and packages a signed, downloadable Android APK on every push to
|
|
||||||
# `main` — issue #64. `needs:` on every other job is what actually makes
|
|
||||||
# this "build, TEST, and package" rather than just "build and package in
|
|
||||||
# parallel with tests that might still be failing": Forgejo/GitHub Actions
|
|
||||||
# has no native way for a job in a *different* workflow file to block on
|
|
||||||
# one here, so this has to live in this same file to get a real dependency
|
|
||||||
# rather than a same-push coincidence. `if:` (not a separate trigger) keeps
|
|
||||||
# every other job running on every branch as today — only this one is
|
|
||||||
# main-only.
|
|
||||||
#
|
|
||||||
# No dedicated Android test suite exists (zero native code beyond the
|
|
||||||
# Capacitor-generated shell) — "testée" is satisfied by gating on the 5
|
|
||||||
# existing jobs, not by inventing an instrumented test suite for this.
|
|
||||||
#
|
|
||||||
# Unverified on this runner as of writing: no Android build has ever run
|
|
||||||
# on this self-hosted Forgejo `act` runner (unlike lint/test/build/e2e,
|
|
||||||
# exercised for months). If SDK installation turns out too slow/heavy here,
|
|
||||||
# cache `~/.android/sdk`/`~/.gradle` (same idea as the Cypress binary cache
|
|
||||||
# in `e2e` above) rather than accepting a slow job on every push.
|
|
||||||
build-android:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [lint, test, intent-service-test, build, e2e]
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
steps:
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
# Matches what Android Gradle Plugin 8.x (bundled with the
|
|
||||||
# Capacitor-generated project, see apps/web/android/build.gradle)
|
|
||||||
# expects — bump alongside AGP if that version ever moves.
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- uses: https://github.com/android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Unlike the normal web build (served same-origin as the API), a
|
|
||||||
# packaged Capacitor build runs from the WebView's own local origin and
|
|
||||||
# needs an absolute, public HTTPS URL baked in at build time — see
|
|
||||||
# apps/web/.env.capacitor.example's own comment on this.
|
|
||||||
- name: Build le bundle web pour Capacitor
|
|
||||||
env:
|
|
||||||
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
|
|
||||||
run: pnpm --filter web build:capacitor
|
|
||||||
- run: pnpm --filter web exec cap sync android
|
|
||||||
|
|
||||||
# Le keystore de release est un secret généré une fois à la main (voir
|
|
||||||
# le README, section "Application mobile") — jamais régénéré ici, un
|
|
||||||
# nouveau keystore casserait la mise à jour de l'appli déjà installée
|
|
||||||
# (Android refuse une signature différente de celle déjà installée).
|
|
||||||
- name: Décode le keystore de release
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
|
|
||||||
|
|
||||||
- name: Build l'APK signé
|
|
||||||
working-directory: apps/web/android
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_PATH: release.keystore
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
# chmod défensif : le dev de ce repo se fait sous Windows, où le bit
|
|
||||||
# exécutable git de gradlew ne veut rien dire et peut être reperdu
|
|
||||||
# silencieusement par un futur commit — ne pas dépendre uniquement
|
|
||||||
# du mode suivi par git (voir aussi le fix de ce mode lui-même).
|
|
||||||
run: |
|
|
||||||
chmod +x ./gradlew
|
|
||||||
./gradlew assembleRelease
|
|
||||||
|
|
||||||
# v4+ exige l'API "Results" de GitHub, que ce runner Forgejo self-hosted
|
|
||||||
# ne supporte pas ("GHESNotSupportedError") — v3 utilise encore
|
|
||||||
# l'ancienne API Artifacts (compatible), seule version utilisable ici.
|
|
||||||
- uses: https://github.com/actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: batchcooking-release.apk
|
|
||||||
path: apps/web/android/app/build/outputs/apk/release/app-release.apk
|
|
||||||
|
|
|
||||||
239
.github/workflows/release.yml
vendored
239
.github/workflows/release.yml
vendored
|
|
@ -1,243 +1,44 @@
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# On a `vX.Y.Z` tag: build the three service images and push them to this
|
# No image registry involved: Portainer is wired to this repo's Git remote
|
||||||
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/
|
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
|
||||||
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag
|
# that the image actually builds at the tagged commit, publishes a GitHub
|
||||||
# and `latest`), build+attach a signed Android APK, publish a Forgejo
|
# Release, then (best-effort) pings Portainer's deploy webhook so it doesn't
|
||||||
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can
|
# have to wait for its own polling interval.
|
||||||
# pull those prebuilt images (docker-compose.yml references them via
|
|
||||||
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
|
|
||||||
# keeps a `build:` fallback next to every `image:`.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
|
|
||||||
# (the `build-and-push-images` job below `docker login`s with it). If a
|
|
||||||
# future instance policy scopes the run token tighter, swap that login's
|
|
||||||
# password for a `write:package` PAT added as a repo secret.
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Build the three service images at the tagged commit and push them to the
|
sanity-build:
|
||||||
# Forgejo container registry. A successful push also *is* the "does it
|
|
||||||
# still build" check the old `sanity-build` job did (which only covered
|
|
||||||
# apps/api). One sequential job, not a per-image matrix: three concurrent
|
|
||||||
# `docker build`s hammer this single persistent runner's Docker daemon
|
|
||||||
# (the contended resource per project memory `ci-forgejo-runner-constraints`)
|
|
||||||
# for no real gain on a release, which is rare and usually runs alone.
|
|
||||||
build-and-push-images:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# This runner's job containers don't come with docker preinstalled —
|
- uses: actions/checkout@v4
|
||||||
# same reason ci.yml's `test` job has to do this before its own
|
|
||||||
# `docker run` calls.
|
|
||||||
- name: Install docker
|
|
||||||
run: apt-get update && apt-get install -y docker.io
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
# Build only, no push, no registry — just confirms Portainer will be
|
||||||
|
# able to build this same Dockerfile successfully at this tag before
|
||||||
|
# anyone points it at this ref.
|
||||||
|
- run: docker build -f apps/api/Dockerfile .
|
||||||
|
|
||||||
- name: Build et push des 3 images vers le registre Forgejo
|
github-release:
|
||||||
env:
|
|
||||||
# The per-run token Forgejo Actions mints — has `packages: write`
|
|
||||||
# via this workflow's `permissions:` block. `docker login` accepts
|
|
||||||
# it as the password for the actor who pushed the tag.
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
|
|
||||||
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
|
|
||||||
# registry namespace is the owner, lower-cased (Forgejo package
|
|
||||||
# names are case-insensitive but the CLI wants a stable form).
|
|
||||||
registry="${GITHUB_SERVER_URL#http*://}"
|
|
||||||
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
tag="$GITHUB_REF_NAME"
|
|
||||||
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
|
|
||||||
|
|
||||||
# Every Dockerfile's build context is the repo root (see
|
|
||||||
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
|
|
||||||
build_push() {
|
|
||||||
image="$registry/$owner/$1"
|
|
||||||
echo "::group::$1"
|
|
||||||
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
|
|
||||||
docker push "$image:$tag"
|
|
||||||
docker push "$image:latest"
|
|
||||||
echo "::endgroup::"
|
|
||||||
}
|
|
||||||
build_push batchcooking-app apps/api/Dockerfile
|
|
||||||
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
|
|
||||||
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
|
|
||||||
|
|
||||||
# Builds the same signed APK as ci.yml's build-android job (issue #64),
|
|
||||||
# with one difference: the version baked into the APK is derived from the
|
|
||||||
# tag itself rather than left at build.gradle's default. Duplicated here
|
|
||||||
# rather than factored into a shared `workflow_call` reusable workflow —
|
|
||||||
# deliberate: reusable-workflow support on this self-hosted Forgejo/act
|
|
||||||
# runner is unverified, and this ~15-line recipe is proven working as-is
|
|
||||||
# in ci.yml; not worth risking a new class of runner-compatibility surprise
|
|
||||||
# (this repo has already hit several — see ci-forgejo-runner-constraints
|
|
||||||
# in project memory) to save a little duplication. Keep both copies in
|
|
||||||
# sync if the Android build steps ever change.
|
|
||||||
build-android-apk:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: sanity-build
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: softprops/action-gh-release@v2
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
# Auto-generated from merged PRs since the previous tag — no
|
||||||
cache: pnpm
|
# changelog tooling to maintain, consistent with there being no
|
||||||
|
# versioning tooling elsewhere in the repo yet.
|
||||||
- uses: https://github.com/actions/setup-java@v4
|
generate_release_notes: true
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- uses: https://github.com/android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build le bundle web pour Capacitor
|
|
||||||
env:
|
|
||||||
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
|
|
||||||
run: pnpm --filter web build:capacitor
|
|
||||||
- run: pnpm --filter web exec cap sync android
|
|
||||||
|
|
||||||
- name: Décode le keystore de release
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
|
|
||||||
|
|
||||||
- name: Dérive versionName/versionCode depuis le tag
|
|
||||||
# GITHUB_REF_NAME = "v1.4.2" pour un tag `v1.4.2` — versionName =
|
|
||||||
# "1.4.2" (sans le `v`), versionCode = majeur*10000 + mineur*100 +
|
|
||||||
# correctif (ex. 1.4.2 -> 10402), un entier strictement croissant à
|
|
||||||
# chaque release comme Android l'exige (jamais réutilisé, jamais
|
|
||||||
# décroissant) tant que chaque partie reste sous 100 (largement
|
|
||||||
# suffisant ici). Exposé aux steps suivants via $GITHUB_ENV plutôt
|
|
||||||
# que recalculé.
|
|
||||||
run: |
|
|
||||||
tag="${GITHUB_REF_NAME#v}"
|
|
||||||
major="$(echo "$tag" | cut -d. -f1)"
|
|
||||||
minor="$(echo "$tag" | cut -d. -f2)"
|
|
||||||
patch="$(echo "$tag" | cut -d. -f3)"
|
|
||||||
# Préfixe 10# : force une lecture en base 10, sinon bash lirait
|
|
||||||
# un composant à zéro non significatif (ex. "08") comme de
|
|
||||||
# l'octal invalide et échouerait.
|
|
||||||
code=$((10#$major * 10000 + 10#$minor * 100 + 10#$patch))
|
|
||||||
echo "Version dérivée du tag $GITHUB_REF_NAME : versionName=$tag versionCode=$code"
|
|
||||||
echo "ANDROID_VERSION_NAME=$tag" >> "$GITHUB_ENV"
|
|
||||||
echo "ANDROID_VERSION_CODE=$code" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build l'APK signé
|
|
||||||
working-directory: apps/web/android
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_PATH: release.keystore
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
chmod +x ./gradlew
|
|
||||||
./gradlew assembleRelease -PandroidVersionName="$ANDROID_VERSION_NAME" -PandroidVersionCode="$ANDROID_VERSION_CODE"
|
|
||||||
|
|
||||||
# Nom de fichier stable, jamais versionné : la page d'accueil (#65)
|
|
||||||
# pointe vers /releases/latest (Forgejo n'a pas l'équivalent du
|
|
||||||
# raccourci GitHub /releases/latest/download/<fichier>, seul
|
|
||||||
# /releases/latest lui-même résout) — un nom stable évite au moins que
|
|
||||||
# l'utilisateur voie un nom de fichier différent à chaque release une
|
|
||||||
# fois sur la page.
|
|
||||||
- name: Renomme l'APK sous un nom stable
|
|
||||||
run: cp apps/web/android/app/build/outputs/apk/release/app-release.apk batchcooking.apk
|
|
||||||
|
|
||||||
# v4+ exige l'API "Results" de GitHub, non supportée par ce Forgejo
|
|
||||||
# self-hosted ("GHESNotSupportedError") — v3 (ancienne API Artifacts)
|
|
||||||
# est la seule utilisable ici, voir ci.yml's build-android pour la
|
|
||||||
# même contrainte déjà rencontrée.
|
|
||||||
- uses: https://github.com/actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: batchcooking.apk
|
|
||||||
path: batchcooking.apk
|
|
||||||
|
|
||||||
forgejo-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build-and-push-images, build-android-apk]
|
|
||||||
steps:
|
|
||||||
# Ce runner ne fournit ni docker ni curl par défaut dans ses
|
|
||||||
# conteneurs de job — même constat déjà fait pour docker (voir
|
|
||||||
# build-and-push-images) et pour curl dans ci.yml's test job.
|
|
||||||
- name: Install curl
|
|
||||||
run: apt-get update && apt-get install -y curl
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: batchcooking.apk
|
|
||||||
|
|
||||||
# softprops/action-gh-release (l'action GitHub habituelle pour ça)
|
|
||||||
# échoue systématiquement contre ce Forgejo : sa requête de création
|
|
||||||
# de release répond 405 (vérifié en conditions réelles, tag v0.2.0 —
|
|
||||||
# pas un souci de ref bare comme les autres actions de ce fichier,
|
|
||||||
# une vraie incompatibilité d'API entre les deux plateformes qui n'a
|
|
||||||
# pas de contournement connu côté configuration de l'action). Appelle
|
|
||||||
# directement l'API native de Forgejo plutôt qu'une action tierce
|
|
||||||
# dont la compatibilité GitHub-Forgejo n'est pas garantie.
|
|
||||||
#
|
|
||||||
# Pas d'équivalent à `generate_release_notes` ici (fonctionnalité
|
|
||||||
# propre à GitHub, pas répliquée par l'API Forgejo) — le corps de la
|
|
||||||
# release reste minimal, cohérent avec l'absence d'outillage de
|
|
||||||
# changelog ailleurs dans le repo.
|
|
||||||
- name: Crée (ou récupère) la Release Forgejo
|
|
||||||
id: create-release
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases"
|
|
||||||
body="$(printf '{"tag_name":"%s","name":"%s","draft":false,"prerelease":false}' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME")"
|
|
||||||
|
|
||||||
# POST pour créer la release, code HTTP capturé à part. Si le tag est
|
|
||||||
# déjà connu de Forgejo (tag poussé à la main avant ce workflow, ou
|
|
||||||
# ré-exécution du job), l'API répond 409 : on réutilise alors la
|
|
||||||
# release existante au lieu d'échouer — idempotence, cf. l'incident
|
|
||||||
# du tag v0.2.2 poussé en avance qui a fait tomber ce job sur un 409.
|
|
||||||
code="$(curl -sS -o /tmp/release.json -w '%{http_code}' -X POST "$api" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$body")"
|
|
||||||
|
|
||||||
if [ "$code" = "409" ]; then
|
|
||||||
echo "Release $GITHUB_REF_NAME déjà existante (HTTP 409) — récupération de son id."
|
|
||||||
curl -fsS "$api/tags/$GITHUB_REF_NAME" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" -o /tmp/release.json
|
|
||||||
elif [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
|
|
||||||
echo "Échec de création de la Release (HTTP $code) : $(cat /tmp/release.json)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
id="$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)"
|
|
||||||
if [ -z "$id" ]; then
|
|
||||||
echo "Pas d'id de release trouvé : $(cat /tmp/release.json)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Release $GITHUB_REF_NAME prête (id=$id)"
|
|
||||||
echo "release-id=$id" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Attache l'APK à la Release
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/${{ steps.create-release.outputs.release-id }}/assets"
|
|
||||||
curl -fsS -X POST "$api?name=batchcooking.apk" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-F "attachment=@batchcooking.apk"
|
|
||||||
|
|
||||||
notify-portainer:
|
notify-portainer:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: forgejo-release
|
needs: github-release
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger Portainer redeploy
|
- name: Trigger Portainer redeploy
|
||||||
# Best-effort: does nothing (and doesn't fail the workflow) until
|
# Best-effort: does nothing (and doesn't fail the workflow) until
|
||||||
|
|
|
||||||
22
.gitignore
vendored
22
.gitignore
vendored
|
|
@ -69,15 +69,6 @@ web_modules/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test.example
|
|
||||||
!.env.capacitor.example
|
|
||||||
|
|
||||||
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
|
|
||||||
# is otherwise all-Node — see that service's own .gitignore for the rest;
|
|
||||||
# duplicated here too since some tooling only honors the repo-root file).
|
|
||||||
services/tech-step-intent-service/.venv/
|
|
||||||
services/tech-step-intent-service/__pycache__/
|
|
||||||
services/tech-step-intent-service/.pytest_cache/
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
|
|
@ -134,11 +125,6 @@ dist
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
.vscode-test
|
.vscode-test
|
||||||
|
|
||||||
# JetBrains IDEs (Android Studio opens this repo as a project once the
|
|
||||||
# Capacitor Android app exists under apps/web/android — see that folder's own
|
|
||||||
# .gitignore for the generated native project's build artefacts).
|
|
||||||
/.idea/
|
|
||||||
|
|
||||||
# pnpm
|
# pnpm
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
|
||||||
|
|
@ -164,11 +150,3 @@ tmp-mockups/
|
||||||
|
|
||||||
# IA
|
# IA
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Cypress run artifacts — regenerated locally/in CI, never meant to be committed
|
|
||||||
apps/web/cypress/screenshots/
|
|
||||||
apps/web/cypress/videos/
|
|
||||||
apps/web/cypress/downloads/
|
|
||||||
apps/admin-web/cypress/screenshots/
|
|
||||||
apps/admin-web/cypress/videos/
|
|
||||||
apps/admin-web/cypress/downloads/
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Instructions du projet
|
|
||||||
|
|
||||||
Avant toute action de génération de code, lis attentivement le fichier `specs\dev-conventions.md` pour prendre connaissance de l'ensemble des normes de développement à appliquer impérativement
|
|
||||||
524
README.md
524
README.md
|
|
@ -4,21 +4,13 @@
|
||||||
|
|
||||||
Monorepo pnpm workspaces :
|
Monorepo pnpm workspaces :
|
||||||
|
|
||||||
- `apps/api` — backend Express/TypeScript : auth, foyer, planning (grille de la
|
- `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha)
|
||||||
semaine), catalogue de recettes (favoris/perso/foyer/publique), import de
|
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
|
||||||
recettes depuis des sources externes, préférences (thème, régime, allergies,
|
Page de connexion/inscription en place ; le reste est encore un squelette générique.
|
||||||
ingrédients détestés). Tests Mocha (base Postgres réelle, isolée de la base
|
- `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
|
||||||
de dev — voir plus bas).
|
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
|
||||||
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par
|
numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
|
||||||
Capacitor plus tard. Espace connecté complet (planning, recettes, réglages)
|
même règles des deux côtés, pas de risque de dérive entre front et back.
|
||||||
derrière une sidebar, wizard d'inscription, thème clair/sombre/système.
|
|
||||||
- `packages/shared` — code partagé entre `api` et `web` : schémas zod, types
|
|
||||||
(`RecipeView`, `PlanningView`, `HouseView`, `SafeUserProfile`...), le contrat
|
|
||||||
d'erreurs (`ErrorCode` numérique, `ApiErrorResponse`, voir
|
|
||||||
[specs/error-handling.md](specs/error-handling.md)) et les libellés anglais
|
|
||||||
du catalogue d'ingrédients (`data/catalog-labels-en.ts`, utilisés par le
|
|
||||||
matching de recettes importées — voir plus bas) — même règles des deux
|
|
||||||
côtés, pas de risque de dérive entre front et back.
|
|
||||||
- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
|
- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
|
||||||
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
|
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
|
||||||
d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
|
d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
|
||||||
|
|
@ -27,16 +19,11 @@ Monorepo pnpm workspaces :
|
||||||
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
|
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
|
||||||
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
|
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
|
||||||
pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
|
pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
|
||||||
- `packages/date-tools` — utilitaires de date partagés (Luxon) : convention
|
|
||||||
"date-only = minuit UTC" (`parseDateOnly`/`formatDateOnly`/`toDateOnly`),
|
|
||||||
calcul de semaine lundi-first (`getWeekStart`/`addWeeks`/`buildCalendarMonth`)
|
|
||||||
— utilisés à la fois par `apps/api` (validation de date de planning) et
|
|
||||||
`apps/web` (grille/navigateur de semaine).
|
|
||||||
|
|
||||||
`packages/shared`, `packages/error-tools`, `packages/express-tools` et
|
`packages/shared`, `packages/error-tools` et `packages/express-tools` ont un vrai
|
||||||
`packages/date-tools` ont un vrai build (`tsc` → `dist/`, voir leur
|
build (`tsc` → `dist/`, voir leur `package.json`) : consommés en JS compilé, pas en
|
||||||
`package.json`) : consommés en JS compilé, pas en TS brut — nécessaire pour un
|
TS brut — nécessaire pour un runtime Node pur (Docker, pas de transpilation à la
|
||||||
runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
|
volée), voir la note dans
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
|
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
@ -44,8 +31,6 @@ runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
|
||||||
- Node.js 22 (voir `.nvmrc`)
|
- Node.js 22 (voir `.nvmrc`)
|
||||||
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
|
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
|
||||||
- Docker (pour Postgres en local)
|
- Docker (pour Postgres en local)
|
||||||
- Python 3.12+ et [`uv`](https://docs.astral.sh/uv/) (pour
|
|
||||||
`services/tech-step-intent-service` en dev natif — requis, voir plus bas)
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -64,9 +49,6 @@ sont pas définis dans `.env` — pas de valeur par défaut en dur dans les fich
|
||||||
Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le
|
Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le
|
||||||
tien, voir le commentaire dans `apps/api/.env.example`).
|
tien, voir le commentaire dans `apps/api/.env.example`).
|
||||||
|
|
||||||
Si tu comptes lancer `pnpm --filter api test` (voir [Qualité / Tests](#qualité--tests)),
|
|
||||||
crée aussi `apps/api/.env.test` — voir la section dédiée plus bas.
|
|
||||||
|
|
||||||
### Cypress : téléchargement du binaire
|
### Cypress : téléchargement du binaire
|
||||||
|
|
||||||
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
|
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
|
||||||
|
|
@ -98,21 +80,6 @@ docker compose up -d postgres
|
||||||
# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
|
# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
|
||||||
pnpm --filter api exec prisma migrate dev
|
pnpm --filter api exec prisma migrate dev
|
||||||
|
|
||||||
# Peuple les données de référence (régimes, allergènes, ingrédients, unités,
|
|
||||||
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
|
|
||||||
pnpm --filter api prisma:seed
|
|
||||||
|
|
||||||
# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api`
|
|
||||||
# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en
|
|
||||||
# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage
|
|
||||||
# (~11 minutes pour le corpus actuel, voir son propre README) avant de
|
|
||||||
# répondre quoi que ce soit sur /health.
|
|
||||||
cd services/tech-step-intent-service
|
|
||||||
uv sync
|
|
||||||
cp .env.example .env # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env
|
|
||||||
uv run uvicorn intent_service.main:app --reload --port 8000
|
|
||||||
cd ../..
|
|
||||||
|
|
||||||
# Backend (http://localhost:3000)
|
# Backend (http://localhost:3000)
|
||||||
pnpm dev:api
|
pnpm dev:api
|
||||||
|
|
||||||
|
|
@ -137,46 +104,15 @@ pnpm dev:web
|
||||||
|
|
||||||
## Qualité / Tests
|
## Qualité / Tests
|
||||||
|
|
||||||
Conventions de code (classes vs objets littéraux, préfixe `_` sur les membres
|
|
||||||
privés, règles Biome actives, logs côté serveur, etc.) :
|
|
||||||
[specs/dev-conventions.md](specs/dev-conventions.md).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm lint # Biome (lint + format check)
|
pnpm lint # Biome (lint + format check)
|
||||||
pnpm lint:fix # Biome --write
|
pnpm lint:fix # Biome --write
|
||||||
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
||||||
pnpm --filter web e2e # tests e2e (Cypress + Cucumber, démarre le serveur dev automatiquement)
|
pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
|
||||||
pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress component testing)
|
pnpm build # build de tous les workspaces
|
||||||
pnpm build # build de tous les workspaces
|
|
||||||
```
|
```
|
||||||
|
|
||||||
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute cinq jobs indépendants (`lint`, `test`, `intent-service-test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. `test` démarre `services/tech-step-intent-service` en arrière-plan (voir ce fichier) puisque la suite Mocha ne mocke jamais un service interne. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
||||||
|
|
||||||
### Base de test isolée de la base de dev (`apps/api`)
|
|
||||||
|
|
||||||
`pnpm --filter api test` exécute une `TRUNCATE ... CASCADE` sur presque tout le
|
|
||||||
schéma **avant chaque test** (`test-support/reset-db.ts`). Pour ne jamais
|
|
||||||
risquer de vider une vraie base de dev locale, `NODE_ENV=test` (posé par le
|
|
||||||
script `test`) fait charger `apps/api/.env.test` au lieu de `.env` — un
|
|
||||||
fichier **à créer toi-même**, pas fourni automatiquement :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp apps/api/.env.test.example apps/api/.env.test
|
|
||||||
# puis édite-le : mêmes identifiants Postgres que ton .env, mais une base
|
|
||||||
# différente (ex. batchcooking_test) — .env.test.example documente les
|
|
||||||
# commandes exactes pour la créer et lui appliquer le schéma.
|
|
||||||
```
|
|
||||||
|
|
||||||
Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
|
|
||||||
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
|
||||||
seule base qu'il doit rejeter est ta vraie base de dev.
|
|
||||||
|
|
||||||
`services/tech-step-intent-service` doit aussi tourner en local avant
|
|
||||||
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
|
|
||||||
par le vrai service (jamais un mock, voir
|
|
||||||
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
|
|
||||||
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
|
|
||||||
section [Développement](#développement) ci-dessus.
|
|
||||||
|
|
||||||
## Déploiement
|
## Déploiement
|
||||||
|
|
||||||
|
|
@ -190,35 +126,15 @@ client) via `FRONTEND_DIST_DIR` — voir `packages/express-tools/src/express-ser
|
||||||
en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir
|
en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir
|
||||||
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.
|
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.
|
||||||
|
|
||||||
Le `CMD` de l'image enchaîne trois étapes, chacune dans son propre processus
|
`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un
|
||||||
`node` : `prisma migrate deploy` (applique les migrations), puis
|
seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
||||||
`node dist/scripts/seed-runtime.js` (seed des données de référence **et**
|
coordonner entre deux origines, le frontend et l'API sont désormais servis depuis
|
||||||
synchronisation de la table `sources` depuis le registre d'adaptateurs de code
|
la même origine).
|
||||||
— nécessaire à chaque démarrage : le registre en mémoire peuplé par
|
|
||||||
`server.ts` ne survit pas au changement de processus, voir
|
|
||||||
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation)),
|
|
||||||
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
|
|
||||||
répéter à chaque redémarrage du conteneur.
|
|
||||||
|
|
||||||
Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port
|
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
|
||||||
applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
production est reliée directement au dépôt Git et reconstruit elle-même
|
||||||
coordonner entre deux origines, le frontend et l'API sont désormais servis
|
`docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
|
||||||
depuis la même origine. Les deux autres services du fichier
|
donc aucune image nulle part.
|
||||||
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
|
|
||||||
au host — voir leurs propres README pour leur rôle.
|
|
||||||
|
|
||||||
**Registre d'image** : la CI de release (voir plus bas) construit et pousse les
|
|
||||||
trois images de service — `batchcooking-app`, `batchcooking-intent-service`,
|
|
||||||
`batchcooking-llm-worker` — sur le **registre de conteneurs intégré à Forgejo**
|
|
||||||
(`git.dev.kyuno.fr/kyuno/…`), taguées à la fois avec le tag de release (`vX.Y.Z`)
|
|
||||||
et `latest`. `docker-compose.yml` référence ces images via `IMAGE_TAG` (variable
|
|
||||||
d'environnement, défaut `latest` — voir `.env.example`), tout en gardant un bloc
|
|
||||||
`build:` local à côté de chaque `image:` : `docker compose pull` récupère les
|
|
||||||
images pré-construites, `docker compose up`/`build` retombe sur un build local si
|
|
||||||
elles sont absentes. L'instance **Portainer** de production peut donc désormais
|
|
||||||
tirer les images pré-construites (stack en mode *pull*, `IMAGE_TAG` pinné sur la
|
|
||||||
release voulue) **ou** continuer à builder elle-même depuis le dépôt Git — les
|
|
||||||
deux modes fonctionnent avec le même `docker-compose.yml`.
|
|
||||||
|
|
||||||
### Release (`.github/workflows/release.yml`)
|
### Release (`.github/workflows/release.yml`)
|
||||||
|
|
||||||
|
|
@ -229,80 +145,14 @@ git tag vX.Y.Z
|
||||||
git push --tags
|
git push --tags
|
||||||
```
|
```
|
||||||
|
|
||||||
Le pipeline enchaîne quatre jobs : `build-and-push-images` (build des 3 images à
|
Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
|
||||||
ce tag + push sur le registre Forgejo sous `vX.Y.Z` et `latest` — un push
|
push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
|
||||||
réussi vaut aussi vérification que tout build encore à ce commit),
|
redéployer dessus), `github-release` (crée une Release GitHub avec changelog
|
||||||
`build-android-apk` (APK signé, `versionName` dérivé du tag), `forgejo-release`
|
auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
|
||||||
(crée la Release Forgejo `vX.Y.Z` et y attache `batchcooking.apk`), puis
|
au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
|
||||||
`notify-portainer` — envoie une requête au webhook de redeploy de Portainer si
|
est configuré (sinon Portainer se resynchronise simplement à son prochain
|
||||||
le secret de dépôt `PORTAINER_WEBHOOK_URL` est configuré (sinon Portainer se
|
polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
|
||||||
resynchronise à son prochain polling). Pour l'activer : récupérer l'URL du
|
stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
|
||||||
webhook depuis les réglages du stack Portainer, puis l'ajouter comme secret de
|
|
||||||
dépôt `PORTAINER_WEBHOOK_URL`.
|
|
||||||
|
|
||||||
## Application mobile (Android)
|
|
||||||
|
|
||||||
`apps/web` est empaqueté dans une coquille [Capacitor](https://capacitorjs.com/)
|
|
||||||
pour Android — le projet natif est versionné dans `apps/web/android/` (issue
|
|
||||||
\#39). L'authentification passe par un jeton Bearer stocké dans le stockage
|
|
||||||
sécurisé de Capacitor (le cookie de session `SameSite=Lax` ne part pas depuis
|
|
||||||
l'origine interne d'une WebView), et `main.tsx` bascule sur `HashRouter` quand
|
|
||||||
`Capacitor.isNativePlatform()` est vrai.
|
|
||||||
|
|
||||||
**Prérequis** : Android Studio (SDK Android + un JDK ; le JBR fourni avec
|
|
||||||
Android Studio suffit). Aucun serveur Postgres/Docker local n'est nécessaire —
|
|
||||||
l'appli parle à l'API HTTPS publique.
|
|
||||||
|
|
||||||
**Build** — depuis `apps/web/` :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. URL publique de l'API, gravée dans le bundle empaqueté (le build web
|
|
||||||
# normal utilise une URL relative « même origine », impossible ici).
|
|
||||||
cp .env.capacitor.example .env.capacitor # puis renseigner VITE_API_URL
|
|
||||||
|
|
||||||
# 2. Build web en mode capacitor + synchronisation dans le projet natif.
|
|
||||||
pnpm build:capacitor
|
|
||||||
npx cap sync android
|
|
||||||
|
|
||||||
# 3. Ouvrir dans Android Studio (build/run/émulateur depuis l'IDE)…
|
|
||||||
npx cap open android
|
|
||||||
# …ou build/run en ligne de commande si un appareil/émulateur est prêt :
|
|
||||||
npx cap run android
|
|
||||||
```
|
|
||||||
|
|
||||||
Le bundle web copié (`apps/web/android/app/src/main/assets/public/`) est
|
|
||||||
git-ignoré : il est régénéré par `pnpm build:capacitor && npx cap sync android`.
|
|
||||||
Les icônes et le splash sont générés depuis `apps/web/assets/logo.svg` (voir
|
|
||||||
`apps/web/assets/README.md`). iOS est hors périmètre pour l'instant (nécessite
|
|
||||||
Xcode/macOS).
|
|
||||||
|
|
||||||
**CI — build automatique d'un APK signé** (`.github/workflows/ci.yml`, job
|
|
||||||
`build-android`, issue #64) : à chaque push sur `main`, une fois les 5 autres
|
|
||||||
jobs verts, un APK release signé est construit et déposé comme artefact de
|
|
||||||
run téléchargeable. Secrets du dépôt Forgejo requis (Réglages du dépôt →
|
|
||||||
Secrets) :
|
|
||||||
|
|
||||||
| Secret | Contenu |
|
|
||||||
|---|---|
|
|
||||||
| `MOBILE_API_URL` | URL HTTPS publique de l'API — même valeur que `VITE_API_URL` dans `.env.capacitor` |
|
|
||||||
| `ANDROID_KEYSTORE_BASE64` | Le keystore de release (`.jks`) encodé en base64 |
|
|
||||||
| `ANDROID_KEYSTORE_PASSWORD` | Mot de passe du keystore |
|
|
||||||
| `ANDROID_KEY_ALIAS` | Alias de la clé dans le keystore |
|
|
||||||
| `ANDROID_KEY_PASSWORD` | Mot de passe de la clé |
|
|
||||||
|
|
||||||
Le keystore doit être généré **une seule fois**, à la main, et rester stable
|
|
||||||
pour toujours : Android refuse d'installer la mise à jour d'une appli signée
|
|
||||||
par une clé différente de celle déjà installée.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
keytool -genkeypair -v -keystore batchcooking-release.jks \
|
|
||||||
-alias batchcooking -keyalg RSA -keysize 2048 -validity 10000
|
|
||||||
base64 -w0 batchcooking-release.jks # → coller comme secret ANDROID_KEYSTORE_BASE64
|
|
||||||
```
|
|
||||||
|
|
||||||
Conserver le fichier `.jks` et ses mots de passe en lieu sûr en dehors du
|
|
||||||
dépôt (jamais commité — voir `apps/web/android/.gitignore`) : le perdre
|
|
||||||
empêche de publier toute future mise à jour de l'appli.
|
|
||||||
|
|
||||||
## Auth (apps/api)
|
## Auth (apps/api)
|
||||||
|
|
||||||
|
|
@ -316,14 +166,10 @@ Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpO
|
||||||
l'email ou le mot de passe qui soit incorrect
|
l'email ou le mot de passe qui soit incorrect
|
||||||
- `POST /auth/logout` — efface le cookie (204)
|
- `POST /auth/logout` — efface le cookie (204)
|
||||||
- `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon)
|
- `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon)
|
||||||
- `DELETE /auth/me` — supprime définitivement le compte après re-saisie du mot
|
|
||||||
de passe (`{ password }`, 401 `INVALID_CREDENTIALS` si incorrect) ; gère le
|
|
||||||
départ/transfert d'adminship du foyer avant suppression (voir Foyer plus bas)
|
|
||||||
|
|
||||||
Mots de passe hachés avec argon2. `UserProfile.tokenVersion` existe pour
|
Mots de passe hachés avec argon2. Le hash est indépendant du foyer : un profil crée
|
||||||
invalider les JWT déjà émis (ex. futur changement de mot de passe) mais rien
|
toujours son propre foyer à l'inscription (rejoindre un foyer existant n'est pas
|
||||||
ne l'incrémente encore — pas de route de changement d'email/mot de passe
|
encore implémenté).
|
||||||
aujourd'hui, seulement la suppression de compte.
|
|
||||||
|
|
||||||
> **argon2 : version pinnée à `0.31.2`, pas de `^`.** La version `0.45.1` (dernière au
|
> **argon2 : version pinnée à `0.31.2`, pas de `^`.** La version `0.45.1` (dernière au
|
||||||
> moment de l'écriture) segfault au runtime sur au moins une configuration Windows —
|
> moment de l'écriture) segfault au runtime sur au moins une configuration Windows —
|
||||||
|
|
@ -338,178 +184,171 @@ Les tests (Mocha) tournent avec un coût argon2 réduit
|
||||||
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
||||||
`prisma migrate deploy` avant les tests.
|
`prisma migrate deploy` avant les tests.
|
||||||
|
|
||||||
## Foyer — création, invitation, admin, sources externes (apps/api)
|
> **Les tests automatisés et `pnpm dev:api` partagent la même base Postgres locale.**
|
||||||
|
> Lancer `pnpm test` **vide `user_profiles`/`house`** (`TRUNCATE ... CASCADE`,
|
||||||
Un foyer (`house`) a un admin (`adminId`) et un code d'invitation à 8
|
> voir `test-support/reset-db.ts`) — si tu es en train de tester manuellement à la main
|
||||||
caractères (`inviteCode`, alphabet sans caractères ambigus `0`/`O`/`1`/`I`).
|
> (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle
|
||||||
|
> efface tes données de test sans prévenir. Pas un bug, juste à savoir.
|
||||||
- `GET`/`PATCH /house/current` — foyer courant. `PATCH { name }` ouvert à tout membre.
|
|
||||||
- `POST /house` — crée un foyer (l'appelant devient admin) ; `POST /house/join
|
|
||||||
{ inviteCode }` — rejoint un foyer existant. Les deux 409 `ALREADY_HAS_HOUSE`
|
|
||||||
si le profil a déjà un foyer.
|
|
||||||
- `POST /house/leave` — quitte le foyer courant. Si le partant était l'admin,
|
|
||||||
l'adminship passe au membre restant le plus ancien ; si plus personne ne
|
|
||||||
reste, le foyer est supprimé (un foyer ne peut jamais rester sans admin).
|
|
||||||
- `DELETE /house/current` — supprime le foyer (403 `NOT_HOUSE_ADMIN` si appelé
|
|
||||||
par un non-admin). `DELETE /house/members/:id` — retire un membre (admin
|
|
||||||
seulement, pas de self-retrait par cette route, utiliser `/leave`).
|
|
||||||
- `GET`/`PATCH /house/current/sources` — quelles sources externes de recettes
|
|
||||||
(voir plus bas) le foyer voit dans son catalogue — `{ sourceIds: number[] }`,
|
|
||||||
remplace (pas de fusion), opt-in (aucune source activée par défaut).
|
|
||||||
|
|
||||||
Détail complet (génération du code, transfert d'adminship) :
|
|
||||||
[specs/backend-architecture.md](specs/backend-architecture.md#house--foyer-adminship-code-dinvitation-sources-activées).
|
|
||||||
|
|
||||||
## Planning (apps/api)
|
## Planning (apps/api)
|
||||||
|
|
||||||
- `GET /planning?date=YYYY-MM-DD` — planning de la semaine (lundi→dimanche)
|
- `GET /planning/current` — nécessite le cookie de session (401 sinon). Renvoie le
|
||||||
couvrant `date`, pour le foyer de l'utilisateur connecté — `PlanningView |
|
planning du foyer de l'utilisateur connecté qui couvre la date du jour (`Planning`
|
||||||
null` (`null` = pas de foyer, ou aucun planning pour cette semaine, deux cas
|
dont `start_date <= aujourd'hui <= finish_date`), items inclus avec leur recette
|
||||||
normaux confondus, jamais une erreur).
|
résolue en `{ id, name }` — ou `null` s'il n'y en a aucun (foyer sans planning en
|
||||||
- `POST /planning/items` — ajoute une recette à un créneau :
|
cours, ou profil sans foyer). `null` est une réponse **valide** (200), pas une
|
||||||
`{ date, weekDay, meal, recipeId, portions }`. `portions` est saisi
|
erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul
|
||||||
indépendamment du rendement propre de la recette (`Recipe.portions`) — un
|
batch-cooking », voir [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md),
|
||||||
créneau peut mettre à l'échelle.
|
reste à construire), donc c'est l'état attendu tant que ce module n'existe pas.
|
||||||
- `DELETE /planning/items/:id` — retire un item du planning.
|
- Type de réponse partagé : `PlanningView` (`packages/shared/src/types/planning.ts`),
|
||||||
|
consommé tel quel par `apps/web`.
|
||||||
|
|
||||||
Le planning d'une semaine est créé à la demande (première recette ajoutée),
|
Détail de `AsyncRequestHandler`/`wrapAsyncHandler` (`packages/express-tools`) —
|
||||||
jamais en avance.
|
premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, ce qui
|
||||||
|
a mis au jour une contrainte générique trop stricte, corrigée à la source :
|
||||||
|
[specs/backend-architecture.md](specs/backend-architecture.md).
|
||||||
|
|
||||||
## Recettes — catalogue, favoris, import depuis une source externe (apps/api)
|
## Données de référence — régimes & allergènes (apps/api)
|
||||||
|
|
||||||
- `GET /recipes?tab=favoris|perso|foyer|publique&search=&suitableForHousehold=&ingredientIds=&dietIds=`
|
- `GET /reference/diets` — liste des régimes alimentaires (`Diet`, 5 valeurs seedées).
|
||||||
— catalogue filtré par onglet + filtres optionnels. `PERSONAL`/`HOUSE`/`PUBLIC`
|
- `GET /reference/allergies` — liste des allergènes sélectionnables, `{ id, name }`
|
||||||
(`Recipe.visibility`) contrôlent qui peut **lire** une recette (jamais qui
|
(le nom vient de `Category.name` — la table `allergy` elle-même ne porte pas de
|
||||||
peut l'éditer, toujours réservé à l'auteur) ; les recettes issues d'une
|
nom, voir `schema.prisma` — chaque allergène = une `Category` + une unique
|
||||||
source externe non activée pour le foyer du viewer sont masquées de tous les
|
`Allergy` sous cette catégorie).
|
||||||
onglets.
|
|
||||||
- `GET /recipes/:id`, `POST /recipes`, `PATCH /recipes/:id`,
|
|
||||||
`DELETE /recipes/:id` (409 `RECIPE_IN_USE` si encore référencée par un
|
|
||||||
planning), `POST`/`DELETE /recipes/:id/favorite`.
|
|
||||||
- **Import depuis une source externe** (`/sources`) : `GET
|
|
||||||
/sources/:sourceKey/browse` (parcourir), `GET
|
|
||||||
/sources/:sourceKey/preview/:externalId` (prévisualiser sans sauvegarder —
|
|
||||||
ingrédients/unités/techniques déjà résolus contre les catalogues), `POST
|
|
||||||
/sources/:sourceKey/import/:externalId` (finaliser — même payload qu'une
|
|
||||||
création manuelle). Un item de source n'est sauvegardé qu'en conséquence de
|
|
||||||
son ajout au planning (import transparent si tout est résolu) ou d'une revue
|
|
||||||
manuelle (ingrédients ambigus à choisir à la main) — jamais un bouton
|
|
||||||
"importer" isolé. Une seule source concrète aujourd'hui : **TheMealDB**
|
|
||||||
(API officielle, catalogue anglais).
|
|
||||||
|
|
||||||
Détail complet (adaptateurs, algorithmes de matching ingrédients/techniques,
|
Les deux sont **publics** (pas de `requireAuth`) : ce sont des données de référence,
|
||||||
synchronisation de la table `sources`) :
|
pas des données de foyer, et le wizard d'inscription doit pouvoir les lire avant
|
||||||
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation).
|
qu'un compte (donc une session) n'existe.
|
||||||
|
|
||||||
## Données de référence — régimes, allergènes, ingrédients, unités, techniques (apps/api)
|
Données seedées via `apps/api/prisma/seed.ts` (`pnpm --filter api prisma:seed`, ou
|
||||||
|
automatiquement après `prisma migrate reset` — config `prisma.seed` dans
|
||||||
|
`package.json`). La logique réelle (listes + upsert idempotent) vit dans
|
||||||
|
`src/db/reference-seed-data.ts`, partagée avec `test-support/reset-db.ts` : chaque
|
||||||
|
test repart d'une base **avec** ces données de référence, pas de tables vides —
|
||||||
|
nécessaire pour tester `dietId`/`allergyIds` sur de vraies lignes.
|
||||||
|
|
||||||
- `GET /reference/diets`, `/allergies`, `/ingredients`, `/units`,
|
`Diet.name` et `Category.name` sont `@unique` — ajouté à ce schéma (pas dans le doc
|
||||||
`/tech-steps`, `/sources` — tous **publics** (pas de `requireAuth`) : ce sont
|
spec d'origine) précisément pour permettre cet upsert idempotent par nom.
|
||||||
des données de référence, pas des données de foyer, et le wizard
|
|
||||||
d'inscription doit pouvoir les lire avant qu'un compte n'existe.
|
|
||||||
|
|
||||||
Données seedées via `apps/api/src/db/reference-seed-data.ts` (`pnpm --filter
|
Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
|
||||||
api prisma:seed`, ou automatiquement après `prisma migrate reset`) — jamais
|
standard, pas inventée.
|
||||||
créées/éditées/supprimées via l'API applicative. `key`/`name` sont `@unique`
|
|
||||||
pour permettre un seed idempotent (`upsert`). Le catalogue d'ingrédients
|
|
||||||
(400+) est organisé en 7 rayons/sous-catégories façon supermarché français, et
|
|
||||||
chaque allergène est classé `ALLERGY` (immunitaire) ou `INTOLERANCE`
|
|
||||||
(Gluten/Sulfites). Détail complet du schéma :
|
|
||||||
[specs/batch-cooking-modele.md](specs/batch-cooking-modele.md).
|
|
||||||
|
|
||||||
## Foyer & profil — régime, allergènes, ingrédients détestés (apps/api)
|
**Allergies vs intolérances** (retour fonctionnel, pas dans le doc spec d'origine) :
|
||||||
|
`Category.kind` (`AllergenKind` — `ALLERGY` | `INTOLERANCE`) classe chaque allergène.
|
||||||
|
Seuls `Gluten` et `Sulfites` sont en `INTOLERANCE` (réaction non-immunitaire
|
||||||
|
documentée) ; les 12 autres en `ALLERGY` (réaction immunitaire classique). Classifié
|
||||||
|
par substance, pas par utilisateur — un même foyer ne peut pas déclarer "allergie au
|
||||||
|
lait" pour un membre et "intolérance au lait" pour un autre ; a suffi pour le besoin
|
||||||
|
exprimé, à revoir si ça devient un problème réel. `GET /reference/allergies` renvoie
|
||||||
|
`kind` dans chaque `AllergyView` ; `PATCH /profile/allergies` ne change pas (une
|
||||||
|
seule liste d'IDs, `kind` ne sert qu'à grouper l'affichage côté client).
|
||||||
|
|
||||||
Nécessitent tous une session (`requireAuth`) — données propres à
|
## Foyer & profil — nom, régime, allergènes (apps/api)
|
||||||
l'utilisateur/au foyer, pas des données de référence.
|
|
||||||
|
|
||||||
|
Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de
|
||||||
|
référence ci-dessus, ce sont des données propres à l'utilisateur/au foyer.
|
||||||
|
|
||||||
|
- `GET`/`PATCH /house/current` — foyer de l'utilisateur connecté. `GET` renvoie
|
||||||
|
`null` si le profil n'a pas encore de foyer (cas théorique : le signup en crée
|
||||||
|
toujours un) ; `PATCH { name }` le renomme (`404 HOUSE_NOT_FOUND` si le profil
|
||||||
|
n'a pas de foyer).
|
||||||
- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
|
- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
|
||||||
`null` efface le régime.
|
`null` efface le régime (étape "skippable" du parcours). `404 DIET_NOT_FOUND` si
|
||||||
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances (medical), liste
|
`dietId` ne correspond à aucun régime de référence.
|
||||||
d'IDs, remplace (pas de fusion).
|
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances du profil connecté,
|
||||||
- `GET`/`PATCH /profile/disliked-ingredients` — ingrédients personnellement
|
sous forme de liste d'IDs (`number[]`). `PATCH { allergyIds }` **remplace**
|
||||||
"pas aimés" (**goût, pas médical** — ne déclenche jamais un avertissement de
|
l'ensemble (pas une fusion — le client renvoie toujours la sélection complète,
|
||||||
sécurité, juste un rappel discret sur la fiche recette), même contrat de
|
cohérent avec un composant de multi-sélection). `404 ALLERGY_NOT_FOUND` si un ID
|
||||||
remplacement.
|
ne correspond à aucun allergène de référence.
|
||||||
- `GET`/`PATCH /preferences { theme: "LIGHT"|"DARK"|"SYSTEM" }` — préférence
|
|
||||||
d'affichage, upsert (pas de ligne tant que rien n'a été choisi, défaut
|
|
||||||
`SYSTEM`).
|
|
||||||
|
|
||||||
`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
|
`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
|
||||||
(`toSafeProfile`).
|
(`toSafeProfile`), auparavant dupliqué dans `auth.service.ts` et
|
||||||
|
`require-auth.ts` — `profile.service.ts` le réutilise aussi.
|
||||||
|
|
||||||
## Page de connexion / inscription (apps/web)
|
## Page de connexion / inscription (apps/web)
|
||||||
|
|
||||||
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée
|
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) :
|
||||||
`apiClient`) : enveloppe `fetch` vers l'API (`credentials: "include"`, requis
|
enveloppe `fetch` vers l'API (`credentials: "include"`, requis pour que le cookie
|
||||||
pour que le cookie de session httpOnly parte/revienne — l'API et le front
|
de session httpOnly parte/revienne — l'API et le front sont sur des origines
|
||||||
sont sur des origines différentes). URL configurable via `VITE_API_URL`
|
différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
|
||||||
(voir `.env.example`).
|
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET /auth/me` au
|
||||||
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET
|
chargement pour restaurer la session depuis le cookie.
|
||||||
/auth/me` au chargement pour restaurer la session depuis le cookie ;
|
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes de route
|
||||||
`deleteAccount()` pour la suppression de compte.
|
(react-router-dom) : `/` exige d'être connecté, `/login` et `/signup` redirigent vers
|
||||||
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes
|
`/` si on l'est déjà.
|
||||||
de route (react-router-dom) : l'espace connecté exige d'être connecté,
|
- `src/pages/{Login,Signup,Home}Page.tsx` — validation client instantanée via les
|
||||||
`/login` et `/signup` redirigent vers `/` si on l'est déjà.
|
|
||||||
- `src/pages/{Login,Signup}Page.tsx` — validation client instantanée via les
|
|
||||||
schémas zod partagés (`packages/shared`), erreurs API traduites via
|
schémas zod partagés (`packages/shared`), erreurs API traduites via
|
||||||
`ErrorMessageService` (voir ci-dessous).
|
`ErrorMessageService` (voir ci-dessous).
|
||||||
|
|
||||||
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
||||||
|
|
||||||
## Sidebar, planning, recettes & sections (apps/web)
|
## Accueil, sidebar & sections (apps/web)
|
||||||
|
|
||||||
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` —
|
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar
|
||||||
sidebar (nav Planning/Recettes/Liste de courses, sous-menu Paramètres
|
(nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et
|
||||||
repliable, menu compte en pied) et `<Outlet />` pour la route active — montée
|
`<Outlet />` pour la route active — montée une seule fois comme route parente de tout
|
||||||
une seule fois comme route parente de tout l'espace authentifié (`App.tsx`).
|
l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx`
|
||||||
|
(routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`,
|
||||||
|
voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes` et `Liste de
|
||||||
|
courses` n'ont pas encore de backend dédié et rendent pour l'instant le même
|
||||||
|
composant `ComingSoonPage` — `Foyer & profil` (`src/pages/HouseholdPage.tsx`), lui,
|
||||||
|
est une vraie page (voir section suivante). Détail complet (pourquoi une seule route
|
||||||
|
parente, pourquoi un composant stub partagé) :
|
||||||
|
[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
|
||||||
|
|
||||||
- **`/` — `PlanningPage`** : grille complète de la semaine (7 jours × 5
|
## Parcours profil — foyer, régime, allergènes (apps/web)
|
||||||
repas), navigation par semaine avec mini-calendrier, ajout via
|
|
||||||
`RecipePickerDialog` (parcourir le catalogue **et** les sources externes,
|
|
||||||
prévisualiser avant de confirmer, import transparent en un clic si la
|
|
||||||
recette d'une source n'est pas encore résolue automatiquement, sinon revue
|
|
||||||
intégrée dans le même dialogue).
|
|
||||||
- **`/recettes`** (+ `/recettes/:id`, `/recettes/sources/:sourceKey/:externalId`)
|
|
||||||
— `RecipesPage`, vue maître-détail : onglets favoris/perso/foyer/publique
|
|
||||||
**plus un onglet par source externe activée pour le foyer**, tableau +
|
|
||||||
panneau de détail (surlignage des techniques détectées avec infobulle, icônes
|
|
||||||
d'ingrédients génériques, badges régime/allergènes/reproductible).
|
|
||||||
`/recettes/nouvelle` et `/recettes/:id/modifier` (`RecipeFormPage`) pour la
|
|
||||||
création/édition manuelle.
|
|
||||||
- **`/liste-de-courses`** — toujours un stub (`ComingSoonPage`), le module
|
|
||||||
« Calcul batch-cooking » reste `TODO` (voir
|
|
||||||
[specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md)).
|
|
||||||
- **`/parametres/*`** — Compte (identité + suppression), Préférences
|
|
||||||
(régime/allergies/ingrédients détestés), Foyer (création/invitation,
|
|
||||||
membres, sources activées), Préférences utilisateur (thème
|
|
||||||
clair/sombre/système), Crédits (attribution des icônes CC BY 4.0).
|
|
||||||
|
|
||||||
Détail complet (pourquoi une seule route parente, le flux d'import détaillé,
|
- `src/features/profile/` — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
|
||||||
les composants UI partagés `Dialog`/`Checkbox`/`Radio`/`Tooltip`) :
|
contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une
|
||||||
|
grille de cases à cocher dans un `<fieldset>`/`<legend>` plutôt qu'un
|
||||||
|
`<select multiple>` — bien plus repérable/tapable, notamment sur mobile. Prend un
|
||||||
|
`legend` en prop (pas un libellé fixe interne) : le même composant est rendu
|
||||||
|
**deux fois** par chaque page consommatrice — une fois pour les allergies
|
||||||
|
(`AllergyView.kind === "ALLERGY"`), une fois pour les intolérances
|
||||||
|
(`"INTOLERANCE"`) — les deux listes filtrées côté client à partir d'un seul
|
||||||
|
`GET /reference/allergies`, mais la sélection (`allergyIds`) reste une seule
|
||||||
|
liste d'IDs partagée entre les deux groupes (une seule `PATCH /profile/allergies`).
|
||||||
|
- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
|
||||||
|
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage` →
|
||||||
|
`OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`).
|
||||||
|
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante (y
|
||||||
|
compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip
|
||||||
|
implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
|
||||||
|
wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`.
|
||||||
|
- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes réglages, modifiables
|
||||||
|
à tout moment. **Hot saving** (retour fonctionnel) : pas de bouton "Enregistrer",
|
||||||
|
chaque section sauvegarde automatiquement peu après la dernière modification —
|
||||||
|
nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms,
|
||||||
|
pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé
|
||||||
|
immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le
|
||||||
|
handler `onChange` de chaque champ, jamais depuis un `useEffect` générique qui
|
||||||
|
observerait la valeur — un tel effect se déclencherait aussi au chargement
|
||||||
|
initial (quand le `GET` peuple le même state), sans moyen propre de distinguer
|
||||||
|
"vient d'être chargé" de "vient d'être modifié par l'utilisateur".
|
||||||
|
|
||||||
## Parcours d'inscription — onboarding (apps/web)
|
**Piège trouvé en testant dans le navigateur** : `RedirectIfAuthenticated` (garde de
|
||||||
|
`/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la
|
||||||
|
vérification initiale — un `navigate()` explicite dans le gestionnaire de soumission
|
||||||
|
d'un formulaire qu'elle protège (ex. `SignupPage` après `signup()`, qui met `user` à
|
||||||
|
jour) entre alors en course avec le propre `<Navigate>` de la garde. Invisible tant
|
||||||
|
que les deux ciblaient "/", devenu un vrai bug dès que `SignupPage` a dû rediriger
|
||||||
|
ailleurs (`/onboarding/foyer`). Fix : la décision de redirection est verrouillée une
|
||||||
|
seule fois, au moment où `isLoading` passe à `false`, plus jamais réévaluée après.
|
||||||
|
|
||||||
Wizard de 4 écrans lancé une fois juste après l'inscription :
|
**Autre piège, même méthode** : `HouseholdPage` initialisait le régime affiché depuis
|
||||||
`/onboarding/regime` → `/onboarding/foyer` → `/onboarding/sources`
|
`useAuth().user.dietId` (un instantané jamais rafraîchi après une modification faite
|
||||||
(conditionnelle, sautée si aucun foyer n'a été créé/rejoint à l'étape
|
directement via `apiClient`, qui ne touche pas `AuthContext`) — revenait à l'ancienne
|
||||||
précédente) → `/onboarding/allergenes`. Chaque étape a un unique bouton
|
valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la
|
||||||
"Continuer" qui envoie la valeur courante (y compris "aucune" pour
|
page fetch son propre profil frais (`apiClient.me()`) au montage, et
|
||||||
régime/allergènes) — pas de bouton "Passer" séparé, skip implicite. Routes
|
`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
|
||||||
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein écran
|
régime pour que le reste de l'app reste cohérent aussi.
|
||||||
sans sidebar, même langage visuel que `/login`/`/signup`. Les mêmes réglages
|
|
||||||
restent modifiables à tout moment depuis `/parametres/*` (hot saving, pas de
|
|
||||||
bouton "Enregistrer" — chaque champ sauvegarde peu après la dernière
|
|
||||||
modification).
|
|
||||||
|
|
||||||
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts` et `*.feature` +
|
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts`) : mockent l'API via `cy.intercept`
|
||||||
`@badeball/cypress-cucumber-preprocessor`) : mockent l'API via `cy.intercept`
|
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne pas de
|
||||||
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne
|
Postgres/API, seulement le serveur de dev Vite. Le comportement réel de l'API est
|
||||||
pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de
|
couvert par la suite Mocha d'`apps/api` (contre une vraie base).
|
||||||
l'API est couvert par la suite Mocha d'`apps/api` (contre une vraie base).
|
|
||||||
Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
|
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#tests-cypress--cucumber).
|
|
||||||
|
|
||||||
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
|
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
|
||||||
> Chromium/Electron headless plante au lancement du process GPU
|
> Chromium/Electron headless plante au lancement du process GPU
|
||||||
|
|
@ -524,9 +363,8 @@ Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
|
||||||
## Gestion des erreurs (API ↔ web)
|
## Gestion des erreurs (API ↔ web)
|
||||||
|
|
||||||
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
|
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
|
||||||
**numérique** groupée par famille — `4000` validation, `401x` auth, `402x`
|
**numérique** groupée par famille — `4000` validation, `401x` auth, `404x` not
|
||||||
conflit/état invalide, `403x` autorisation, `404x` not found, `500x` interne
|
found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
|
||||||
— et `ApiErrorResponse`) : l'API renvoie toujours
|
|
||||||
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
|
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
|
||||||
quel), et le client traduit `code` en libellé français via **i18next**
|
quel), et le client traduit `code` en libellé français via **i18next**
|
||||||
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
|
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
|
||||||
|
|
@ -536,8 +374,8 @@ centralisent la transformation de toute erreur levée en réponse HTTP conforme
|
||||||
aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
|
aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
|
||||||
compris dans les mocks Cypress).
|
compris dans les mocks Cypress).
|
||||||
|
|
||||||
Détail complet (schéma, liste des ~19 codes actuels, exemples, comment ajouter
|
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
|
||||||
un nouveau code d'erreur) : [specs/error-handling.md](specs/error-handling.md).
|
[specs/error-handling.md](specs/error-handling.md).
|
||||||
|
|
||||||
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
|
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
|
||||||
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
|
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
|
||||||
|
|
@ -553,21 +391,9 @@ voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshare
|
||||||
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
|
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
|
||||||
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`),
|
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`),
|
||||||
jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
|
jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
|
||||||
en ajouter une est une question de fichier de locale, pas de code. Les
|
en ajouter une est une question de fichier de locale, pas de code. Détail :
|
||||||
libellés des tables de référence (régimes, allergènes, ingrédients, unités,
|
|
||||||
techniques) vivent aussi dans ce fichier (`catalog.*`, par `key` stable de
|
|
||||||
`schema.prisma`), jamais stockés en base. Détail :
|
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
|
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
|
||||||
|
|
||||||
## Thème clair / sombre / système (apps/web)
|
|
||||||
|
|
||||||
Préférence par utilisateur, persistée côté serveur (`GET`/`PATCH
|
|
||||||
/preferences`, pas `localStorage`). Tokens de design en custom properties CSS
|
|
||||||
(`apps/web/src/styles/_theme.scss`) redéfinies sous `[data-theme="dark"]`
|
|
||||||
(choix explicite) ou sous `prefers-color-scheme: dark` quand aucun
|
|
||||||
`data-theme` n'est posé (choix "système", le défaut). Détail :
|
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#thème-clairsombresystème).
|
|
||||||
|
|
||||||
## Données de test (faker.js)
|
## Données de test (faker.js)
|
||||||
|
|
||||||
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
|
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,14 @@
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3000
|
||||||
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
|
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
|
||||||
# do not commit the real value.
|
# do not commit the real value.
|
||||||
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"
|
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"
|
||||||
|
|
||||||
# Required, no default on purpose — generate your own, e.g.:
|
# Required, no default on purpose — generate your own, e.g.:
|
||||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
|
||||||
# Optional — defaults shown (see src/config/env.ts)
|
# Optional — defaults shown (see src/config/env.ts)
|
||||||
# JWT_EXPIRES_IN=7d
|
# JWT_EXPIRES_IN=7d
|
||||||
# AUTH_COOKIE_NAME=session
|
# AUTH_COOKIE_NAME=session
|
||||||
# CORS_ORIGIN=http://localhost:5173,https://localhost
|
# CORS_ORIGIN=http://localhost:5173
|
||||||
# INTENT_SERVICE_BASE_URL=http://localhost:8000
|
|
||||||
|
|
||||||
# Required — services/tech-step-intent-service must be running locally (see
|
|
||||||
# that service's own README) for any recipe save/preview to detect
|
|
||||||
# techniques at all. Must match that service's own INTENT_SERVICE_SECRET.
|
|
||||||
# Generate your own the same way as JWT_SECRET above.
|
|
||||||
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Only needed if you're running services/tech-step-llm-worker locally —
|
|
||||||
# every /internal/tech-steps/* request is rejected outright while unset.
|
|
||||||
# Generate your own the same way as JWT_SECRET above; must match the
|
|
||||||
# worker's own INTERNAL_WORKER_SECRET.
|
|
||||||
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Only needed to use the admin surface (apps/web's /admin/* routes) — every
|
|
||||||
# /admin/* request 401s while unset (`requireAdmin` fails closed). MUST be a
|
|
||||||
# different value than JWT_SECRET. Generate your own the same way.
|
|
||||||
# ADMIN_JWT_SECRET=changeme-a-different-random-secret-at-least-32-chars
|
|
||||||
# Read only by `src/scripts/create-admin.ts` when its --email/--password/
|
|
||||||
# --name flags are omitted — never by the running server.
|
|
||||||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
|
||||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
|
||||||
# ADMIN_INITIAL_NAME=Ops
|
|
||||||
|
|
||||||
# Only needed to create a Forgejo issue from a contact message
|
|
||||||
# (apps/web's /admin/messages, issue #81). The button is hidden and the
|
|
||||||
# endpoint answers 503 while any of the three is unset. FORGEJO_TOKEN is a
|
|
||||||
# personal access token with the write:issue scope — generate it in Forgejo,
|
|
||||||
# never commit the real value.
|
|
||||||
# FORGEJO_BASE_URL=https://git.example.com
|
|
||||||
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
|
|
||||||
# FORGEJO_REPO=owner/repo
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
NODE_ENV=test
|
|
||||||
PORT=3000
|
|
||||||
# Must point at a *different* database than your `.env`'s — `pnpm test`
|
|
||||||
# (test-support/reset-db.ts's `resetDatabase()`) TRUNCATEs almost every
|
|
||||||
# table before each test. Pointing this at the same database `pnpm dev`
|
|
||||||
# uses will wipe real local data on every test run. Easiest setup: same
|
|
||||||
# Postgres server/credentials as `.env`, just a different database name —
|
|
||||||
# create it once with e.g.:
|
|
||||||
# pnpm exec prisma db execute --url "postgresql://USER:PASSWORD@localhost:PORT/postgres?schema=public" --file - <<< "CREATE DATABASE batchcooking_test;"
|
|
||||||
# DATABASE_URL="postgresql://USER:PASSWORD@localhost:PORT/batchcooking_test?schema=public" pnpm exec prisma migrate deploy
|
|
||||||
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking_test?schema=public"
|
|
||||||
|
|
||||||
# Required, no default on purpose — generate your own, e.g.:
|
|
||||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
|
||||||
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Required — the Mocha suite exercises the real techStepClassifier, which
|
|
||||||
# now round-trips over HTTP to services/tech-step-intent-service (no mocks
|
|
||||||
# of internal services, per this repo's test conventions). Start that
|
|
||||||
# service locally first (see its own README) with a matching
|
|
||||||
# INTENT_SERVICE_SECRET, or every test touching tech-step-matcher.ts fails
|
|
||||||
# with a connection error rather than a useful assertion failure.
|
|
||||||
INTENT_SERVICE_BASE_URL=http://localhost:8000
|
|
||||||
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Optional — only needed to exercise tech-step-worker.routes.test.ts's
|
|
||||||
# success path (a request with a matching secret); every other test runs
|
|
||||||
# fine without it. Any value at least 32 chars works locally.
|
|
||||||
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Optional — set to run admin-auth.test.ts's login success path and the
|
|
||||||
# requireAdmin-guarded routes (any value at least 32 chars). Left unset,
|
|
||||||
# those cases self-skip and only the "no secret configured -> 401" path
|
|
||||||
# runs. Same "optional in test, fail-closed at runtime" posture as
|
|
||||||
# INTERNAL_WORKER_SECRET above.
|
|
||||||
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
@ -2,6 +2,5 @@
|
||||||
"extension": ["ts"],
|
"extension": ["ts"],
|
||||||
"spec": "test/**/*.test.ts",
|
"spec": "test/**/*.test.ts",
|
||||||
"node-option": ["import=tsx"],
|
"node-option": ["import=tsx"],
|
||||||
"timeout": 10000,
|
"timeout": 10000
|
||||||
"require": ["test-support/mocha-root-hooks.ts"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "step_tech_step" ADD COLUMN "end" INTEGER,
|
|
||||||
ADD COLUMN "start" INTEGER;
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "tech_step_mapping" DROP CONSTRAINT "tech_step_mapping_tech_step_id_fkey";
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "tech_step_mapping";
|
|
||||||
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER,
|
|
||||||
ADD COLUMN "context_start" INTEGER;
|
|
||||||
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step_correction" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"corrector_id" INTEGER NOT NULL,
|
|
||||||
"start" INTEGER NOT NULL,
|
|
||||||
"end" INTEGER NOT NULL,
|
|
||||||
"previous_tech_step_id" INTEGER,
|
|
||||||
"corrected_tech_step_id" INTEGER,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"consumed_at" TIMESTAMP(3),
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_correction_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "tech_step_training_suggestion" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"tech_step_id" INTEGER NOT NULL,
|
|
||||||
"locale" TEXT NOT NULL,
|
|
||||||
"suggested_synonyms" TEXT[],
|
|
||||||
"suggested_utterances" TEXT[],
|
|
||||||
"source_type" TEXT NOT NULL,
|
|
||||||
"source_correction_id" INTEGER,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "tech_step_training_suggestion_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrector_id_fkey" FOREIGN KEY ("corrector_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_previous_tech_step_id_fkey" FOREIGN KEY ("previous_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrected_tech_step_id_fkey" FOREIGN KEY ("corrected_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_source_correction_id_fkey" FOREIGN KEY ("source_correction_id") REFERENCES "step_tech_step_correction"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "utensil" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "utensil_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "utensil_key_key" ON "utensil"("key");
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step_ingredient" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"tech_step_order" INTEGER NOT NULL,
|
|
||||||
"ingredient_id" INTEGER NOT NULL,
|
|
||||||
"quantity" DECIMAL(10,2),
|
|
||||||
"unit_id" INTEGER,
|
|
||||||
"start" INTEGER NOT NULL,
|
|
||||||
"end" INTEGER NOT NULL,
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'auto',
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_ingredient_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step_utensil" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"tech_step_order" INTEGER NOT NULL,
|
|
||||||
"utensil_id" INTEGER NOT NULL,
|
|
||||||
"start" INTEGER NOT NULL,
|
|
||||||
"end" INTEGER NOT NULL,
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'auto',
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_utensil_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "unit"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_utensil_id_fkey" FOREIGN KEY ("utensil_id") REFERENCES "utensil"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -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");
|
|
||||||
|
|
@ -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")
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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")
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -99,13 +100,9 @@ model UserProfile {
|
||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
/// Bumped to invalidate previously-issued JWTs (e.g. on password change).
|
/// Bumped to invalidate previously-issued JWTs (e.g. on password change).
|
||||||
/// Not in the original spec doc — required for stateless JWT auth.
|
/// Not in the original spec doc — required for stateless JWT auth.
|
||||||
tokenVersion Int @default(0) @map("token_version")
|
tokenVersion Int @default(0) @map("token_version")
|
||||||
houseId Int? @map("house_id")
|
houseId Int? @map("house_id")
|
||||||
dietId Int? @map("diet_id")
|
dietId Int? @map("diet_id")
|
||||||
/// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
|
|
||||||
/// the `admin_metrics` migration for the dashboard's signup curve. No
|
|
||||||
/// application code reads it (auth doesn't need it).
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
|
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
|
||||||
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
||||||
|
|
@ -124,21 +121,6 @@ model UserProfile {
|
||||||
/// list regardless of that real-world cardinality.
|
/// list regardless of that real-world cardinality.
|
||||||
administeredHouses House[] @relation("HouseAdmin")
|
administeredHouses House[] @relation("HouseAdmin")
|
||||||
preferences UserPreference?
|
preferences UserPreference?
|
||||||
/// Guided-tour scenarios this profile has seen (completed or skipped) —
|
|
||||||
/// see {@link TourProgress} and issues #42 / #44.
|
|
||||||
tourProgress TourProgress[]
|
|
||||||
/// Tech-step corrections this profile has submitted (any profile that can
|
|
||||||
/// view a recipe may correct its tech-step matches, not just its author —
|
|
||||||
/// see `StepTechStepCorrection.correctorId`).
|
|
||||||
techStepCorrections StepTechStepCorrection[]
|
|
||||||
/// Placeholder `Ingredient` rows this profile created by typing a free-text
|
|
||||||
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
|
|
||||||
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
|
|
||||||
/// Placeholder `TechStep` rows this profile proposed while correcting a step
|
|
||||||
/// — see `TechStep.isPlaceholder`.
|
|
||||||
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
|
|
||||||
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
|
|
||||||
contactMessages ContactMessage[]
|
|
||||||
|
|
||||||
@@map("user_profiles")
|
@@map("user_profiles")
|
||||||
}
|
}
|
||||||
|
|
@ -206,12 +188,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 +201,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 +315,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 +323,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 +344,21 @@ model RecipeFavorite {
|
||||||
@@map("recipe_favorite")
|
@@map("recipe_favorite")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit join table for the recipe <-> diet "associated regime" tags
|
||||||
|
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
|
||||||
|
/// manual reminder set by whoever creates/edits the recipe, not computed
|
||||||
|
/// from its ingredients.
|
||||||
|
model RecipeDiet {
|
||||||
|
recipeId Int @map("recipe_id")
|
||||||
|
dietId Int @map("diet_id")
|
||||||
|
|
||||||
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
|
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([recipeId, dietId])
|
||||||
|
@@map("recipe_diet")
|
||||||
|
}
|
||||||
|
|
||||||
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
||||||
/// script (reference-seed-data.ts) can `upsert` by key and stay
|
/// script (reference-seed-data.ts) can `upsert` by key and stay
|
||||||
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
|
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
|
||||||
|
|
@ -548,52 +510,14 @@ model Ingredient {
|
||||||
/// ingredient↔recipe linking in the database, the UI only pre-fills the
|
/// ingredient↔recipe linking in the database, the UI only pre-fills the
|
||||||
/// catalog's own search with this ingredient's name).
|
/// catalog's own search with this ingredient's name).
|
||||||
reproducible Boolean @default(false)
|
reproducible Boolean @default(false)
|
||||||
/// `true` = a "placeholder" row: a free-text ingredient a user typed on a
|
|
||||||
/// recipe line because the seeded catalog had nothing matching (see
|
|
||||||
/// specs/batch-cooking-modele.md's "ingrédients hors-catalogue"). Such a
|
|
||||||
/// row has `displayName` non-null, a generated `key` (`placeholder:<uuid>`,
|
|
||||||
/// never an i18n label), and the default metadata (`JAR`/`dryGoods`/`other`,
|
|
||||||
/// no allergen/diet links). It is referenced by `RecipeIngredient` like any
|
|
||||||
/// other `Ingredient`, but `GET /reference/ingredients` and
|
|
||||||
/// `ingredient-matcher.ts`'s `loadIngredientCatalog` both exclude it — it is
|
|
||||||
/// never a browsable/matchable target, only a per-line stand-in a
|
|
||||||
/// maintainer later promotes into a real catalog entry by hand. The
|
|
||||||
/// `/admin/catalog/placeholders` view groups these by normalized name so
|
|
||||||
/// the maintainer sees which ingredients the catalog is missing.
|
|
||||||
isPlaceholder Boolean @default(false) @map("is_placeholder")
|
|
||||||
/// Display name of a placeholder ingredient — the exact text the user
|
|
||||||
/// typed. `null` for a real catalog row (whose label lives in i18n under
|
|
||||||
/// `catalog.ingredients.<key>`). Invariant "non-null iff `isPlaceholder`"
|
|
||||||
/// is enforced service-side, not by the schema (same posture as other
|
|
||||||
/// cross-field invariants here).
|
|
||||||
displayName String? @map("display_name")
|
|
||||||
/// Profile that first created this placeholder — context for the admin
|
|
||||||
/// catalog-gap review. `onDelete: SetNull` so deleting an account never
|
|
||||||
/// blocks on, or cascades into, the recipes that still use its placeholder.
|
|
||||||
/// `null` for a real catalog row.
|
|
||||||
createdById Int? @map("created_by_id")
|
|
||||||
/// When this placeholder was created. `null` for a real catalog row (the
|
|
||||||
/// seed carries no timestamp).
|
|
||||||
createdAt DateTime? @map("created_at")
|
|
||||||
/// Stamped when an admin has triaged this catalog gap
|
|
||||||
/// (`PATCH /admin/catalog/placeholders/mark-reviewed`) — the group then
|
|
||||||
/// drops out of the default "à traiter" list. `null` while pending / for a
|
|
||||||
/// real catalog row.
|
|
||||||
reviewedAt DateTime? @map("reviewed_at")
|
|
||||||
|
|
||||||
recipes RecipeIngredient[]
|
recipes RecipeIngredient[]
|
||||||
allergies IngredientAllergy[]
|
allergies IngredientAllergy[]
|
||||||
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
||||||
dislikedBy UserProfileDislikedIngredient[]
|
dislikedBy UserProfileDislikedIngredient[]
|
||||||
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
|
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
|
||||||
diets IngredientDiet[]
|
diets IngredientDiet[]
|
||||||
/// Mentions of this ingredient detected in a step's free text alongside a
|
|
||||||
/// technique — see `StepTechStepIngredient`.
|
|
||||||
stepTechSteps StepTechStepIngredient[]
|
|
||||||
/// The profile that created this row when it is a placeholder — see `createdById`.
|
|
||||||
createdBy UserProfile? @relation("PlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([isPlaceholder])
|
|
||||||
@@map("ingredients")
|
@@map("ingredients")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -668,13 +592,7 @@ model Unit {
|
||||||
type UnitType
|
type UnitType
|
||||||
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
|
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
|
||||||
|
|
||||||
recipeIngredients RecipeIngredient[]
|
recipeIngredients RecipeIngredient[]
|
||||||
/// Ingredient mentions detected alongside a technique in a step's free
|
|
||||||
/// text (e.g. "50g" resolved against this `Unit`) — see
|
|
||||||
/// `StepTechStepIngredient`. Distinct from `recipeIngredients` above
|
|
||||||
/// (the recipe's structured ingredient list): a step can mention a
|
|
||||||
/// quantity+unit that was never itself an ingredient list line.
|
|
||||||
stepTechStepIngredients StepTechStepIngredient[]
|
|
||||||
|
|
||||||
@@map("unit")
|
@@map("unit")
|
||||||
}
|
}
|
||||||
|
|
@ -684,19 +602,14 @@ model Unit {
|
||||||
/// quantity per recipe, so this join table carries quantity + unit
|
/// quantity per recipe, so this join table carries quantity + unit
|
||||||
/// (project decision, not in the original spec doc).
|
/// (project decision, not in the original spec doc).
|
||||||
model RecipeIngredient {
|
model RecipeIngredient {
|
||||||
recipeId Int @map("recipe_id")
|
recipeId Int @map("recipe_id")
|
||||||
ingredientId Int @map("ingredient_id")
|
ingredientId Int @map("ingredient_id")
|
||||||
/// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line —
|
quantity Decimal @db.Decimal(10, 2)
|
||||||
/// salt, pepper… added to taste, with nothing to scale by portions or to
|
unitId Int @map("unit_id")
|
||||||
/// aggregate on a shopping list. The "both null or neither" invariant is
|
|
||||||
/// enforced service-side (and by `recipeIngredientInputSchema`), not by
|
|
||||||
/// the schema — same posture as this file's other cross-field invariants.
|
|
||||||
quantity Decimal? @db.Decimal(10, 2)
|
|
||||||
unitId Int? @map("unit_id")
|
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||||
unit Unit? @relation(fields: [unitId], references: [id])
|
unit Unit @relation(fields: [unitId], references: [id])
|
||||||
|
|
||||||
@@id([recipeId, ingredientId])
|
@@id([recipeId, ingredientId])
|
||||||
@@map("recipe_ingredient")
|
@@map("recipe_ingredient")
|
||||||
|
|
@ -706,82 +619,34 @@ model RecipeIngredient {
|
||||||
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
|
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
|
||||||
/// label lives in `apps/web`'s `locales/fr/translation.json` under
|
/// label lives in `apps/web`'s `locales/fr/translation.json` under
|
||||||
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
|
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
|
||||||
///
|
|
||||||
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
|
|
||||||
/// `TechStepClassifierService`) used to go through a DB-backed
|
|
||||||
/// `TechStepMapping` table of per-locale regex expressions — replaced with
|
|
||||||
/// a spaCy-based model (`services/tech-step-intent-service`) trained from
|
|
||||||
/// in-code data (`tech-step-training-data.ts`) once regexes turned out
|
|
||||||
/// unable to generalize past their own literal vocabulary. Nothing
|
|
||||||
/// queries/edits that matching data at runtime anymore (it only ever feeds
|
|
||||||
/// that service's one-time training pass), so it no longer needs a table
|
|
||||||
/// of its own — this row now only exists to be a stable id/key other
|
|
||||||
/// tables (`StepTechStep`) reference.
|
|
||||||
model TechStep {
|
model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
/// `true` = a "placeholder" technique: a free-text technique a user typed
|
steps StepTechStep[]
|
||||||
/// while correcting a step, because nothing in the seeded catalog matched
|
mappings TechStepMapping[]
|
||||||
/// (issue #131 for the ingredient precedent this mirrors — see
|
|
||||||
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
|
|
||||||
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
|
|
||||||
/// training data of its own, and is excluded from
|
|
||||||
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
|
|
||||||
/// like any other technique (the correction that created it applies
|
|
||||||
/// immediately), but a maintainer reviews it at
|
|
||||||
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
|
|
||||||
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
|
|
||||||
isPlaceholder Boolean @default(false) @map("is_placeholder")
|
|
||||||
/// The exact text the user typed — `null` for a real catalog row (whose
|
|
||||||
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
|
|
||||||
/// `isPlaceholder`, enforced service-side.
|
|
||||||
displayName String? @map("display_name")
|
|
||||||
/// Profile that first proposed this technique — context for the admin
|
|
||||||
/// review. `onDelete: SetNull` so deleting an account never blocks on the
|
|
||||||
/// recipes still using its placeholder. `null` for a real catalog row.
|
|
||||||
createdById Int? @map("created_by_id")
|
|
||||||
/// When this placeholder was proposed. `null` for a real catalog row.
|
|
||||||
createdAt DateTime? @map("created_at")
|
|
||||||
/// Stamped once a maintainer has triaged this proposal
|
|
||||||
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
|
|
||||||
/// `null` while pending / for a real catalog row.
|
|
||||||
reviewedAt DateTime? @map("reviewed_at")
|
|
||||||
|
|
||||||
steps StepTechStep[]
|
|
||||||
/// Corrections where this technique was the *previous* (possibly wrong)
|
|
||||||
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
|
||||||
correctionsAsPrevious StepTechStepCorrection[] @relation("PreviousTechStep")
|
|
||||||
/// Corrections where this technique was the *corrected* (user-asserted)
|
|
||||||
/// match — see `StepTechStepCorrection.correctedTechStepId`.
|
|
||||||
correctionsAsCorrected StepTechStepCorrection[] @relation("CorrectedTechStep")
|
|
||||||
/// Training-corpus suggestions targeting this technique — see
|
|
||||||
/// `TechStepTrainingSuggestion`.
|
|
||||||
trainingSuggestions TechStepTrainingSuggestion[]
|
|
||||||
/// The profile that proposed this row when it is a placeholder — see `createdById`.
|
|
||||||
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([isPlaceholder])
|
|
||||||
@@map("tech_step")
|
@@map("tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `key` is `@unique`, same bare id+key shape as `TechStep` — no
|
/// Used by `tech-step-matcher.ts` to auto-detect which technique a recipe
|
||||||
/// categorization taxonomy like `Ingredient` needed yet, and no matching
|
/// step's description corresponds to (expression = regex pattern tested
|
||||||
/// data of its own here either: unlike `TechStep` (whose matching synonyms
|
/// against the description, weight = tie-break score when several
|
||||||
/// used to live in TS and were moved into
|
/// mappings match, or overlap-resolution score when two mappings match the
|
||||||
/// `services/tech-step-intent-service`'s `training_data.py`), this catalog
|
/// same span of text — see `matchTechSteps`). `locale` (e.g. `"fr"`) lets
|
||||||
/// was *born* owned by that service (`utensil_vocabulary.py`) since nothing
|
/// the same TechStep carry one matching rule set per language — the
|
||||||
/// pre-existing needed it — this row only exists to be a stable id/key
|
/// matcher is always called with a target locale and only considers
|
||||||
/// `StepTechStepUtensil` references, and to carry a French label
|
/// mappings for that locale.
|
||||||
/// (`apps/web`'s `catalog.utensils.<key>`, see `reference-seed-data.ts`'s
|
model TechStepMapping {
|
||||||
/// `UTENSILS`).
|
id Int @id @default(autoincrement())
|
||||||
model Utensil {
|
techStepId Int @map("tech_step_id")
|
||||||
id Int @id @default(autoincrement())
|
locale String
|
||||||
key String @unique
|
expression String
|
||||||
|
weight Int
|
||||||
|
|
||||||
steps StepTechStepUtensil[]
|
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("utensil")
|
@@map("tech_step_mapping")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
||||||
|
|
@ -795,11 +660,8 @@ model Step {
|
||||||
picture String?
|
picture String?
|
||||||
order Int
|
order Int
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
techSteps StepTechStep[]
|
techSteps StepTechStep[]
|
||||||
/// User-submitted corrections to this step's detected techniques — see
|
|
||||||
/// `StepTechStepCorrection`.
|
|
||||||
corrections StepTechStepCorrection[]
|
|
||||||
|
|
||||||
@@map("step")
|
@@map("step")
|
||||||
}
|
}
|
||||||
|
|
@ -810,449 +672,17 @@ model Step {
|
||||||
/// `melt`), which is why this replaced the original single nullable
|
/// `melt`), which is why this replaced the original single nullable
|
||||||
/// `Step.techStepId` FK (per PR review feedback on the first version of
|
/// `Step.techStepId` FK (per PR review feedback on the first version of
|
||||||
/// this feature). `order` is the position within *this step* (0-based, in
|
/// this feature). `order` is the position within *this step* (0-based, in
|
||||||
/// the order `matchTechStepSpans` — `tech-step-matcher.ts` — detected the
|
/// the order `matchTechSteps` — `tech-step-matcher.ts` — detected the
|
||||||
/// techniques in the description), not a global ordering across different
|
/// techniques in the description), not a global ordering across different
|
||||||
/// steps of the recipe (that's `Step.order`).
|
/// steps of the recipe (that's `Step.order`).
|
||||||
///
|
|
||||||
/// `start`/`end` are the tight matched *keyword* span within
|
|
||||||
/// `Step.description` (see `TechStepMatch`, `tech-step-matcher.ts`) — what
|
|
||||||
/// the recipe detail view highlights strongly, with a tooltip.
|
|
||||||
/// `contextStart`/`contextEnd` are the wider *clause* the keyword was found
|
|
||||||
/// in (e.g. "Dans une poêle chaude" around a `preheat` keyword of "poêle
|
|
||||||
/// chaude") — always contains `start`/`end` — what the detail view
|
|
||||||
/// highlights more subtly around it, so both "the exact trigger word(s)"
|
|
||||||
/// and "how much of the sentence is about this technique" are visible.
|
|
||||||
/// Nullable, **not backfilled**: adding them `NOT NULL` without a default
|
|
||||||
/// would fail outright against any pre-existing row, the same mistake the
|
|
||||||
/// `ingredient_unit_catalog` migration made against real prod data. A row
|
|
||||||
/// from before a column existed just has no span for it (no highlight)
|
|
||||||
/// until its recipe is next saved, which recomputes every step's
|
|
||||||
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
|
|
||||||
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
|
|
||||||
/// graceful degradation, not a permanent gap.
|
|
||||||
///
|
|
||||||
/// `source` distinguishes a `"manual"` row — written immediately when a
|
|
||||||
/// user submits a `StepTechStepCorrection` that asserts a technique
|
|
||||||
/// (`recipe-tech-step-correction.service.ts`'s `applyManualCorrection`),
|
|
||||||
/// not just recorded as a pending suggestion — from an `"auto"` row the
|
|
||||||
/// classifier itself produced (`tech-step-matcher.ts`). Both kinds coexist
|
|
||||||
/// in the same ordered sequence; the detail view (`apps/web`) renders them
|
|
||||||
/// with a different highlight color so a viewer can tell which is which.
|
|
||||||
/// `backfillTechSteps` (`scripts/backfill-tech-steps.ts`) only ever
|
|
||||||
/// deletes/recreates `"auto"` rows — a `"manual"` row survives a
|
|
||||||
/// classifier/corpus change until a user (or a future moderation feature)
|
|
||||||
/// explicitly changes it again.
|
|
||||||
model StepTechStep {
|
model StepTechStep {
|
||||||
stepId Int @map("step_id")
|
stepId Int @map("step_id")
|
||||||
techStepId Int @map("tech_step_id")
|
techStepId Int @map("tech_step_id")
|
||||||
order Int
|
order Int
|
||||||
start Int?
|
|
||||||
end Int?
|
|
||||||
contextStart Int? @map("context_start")
|
|
||||||
contextEnd Int? @map("context_end")
|
|
||||||
source String @default("auto")
|
|
||||||
|
|
||||||
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
||||||
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
||||||
/// Ingredients mentioned in the same clause as this technique occurrence
|
|
||||||
/// — see `StepTechStepIngredient`.
|
|
||||||
ingredients StepTechStepIngredient[]
|
|
||||||
/// Utensils mentioned in the same clause as this technique occurrence —
|
|
||||||
/// see `StepTechStepUtensil`.
|
|
||||||
utensils StepTechStepUtensil[]
|
|
||||||
/// Temperatures mentioned in the same clause as this technique occurrence
|
|
||||||
/// — see `StepTechStepTemperature`.
|
|
||||||
temperatures StepTechStepTemperature[]
|
|
||||||
|
|
||||||
@@id([stepId, order])
|
@@id([stepId, order])
|
||||||
@@map("step_tech_step")
|
@@map("step_tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An ingredient mention found in the same *clause* as one `StepTechStep`
|
|
||||||
/// occurrence (`tech-step-matcher.ts`'s `matchTechStepSpans` — clauses are
|
|
||||||
/// already the unit a technique is judged on, see that file's doc comment,
|
|
||||||
/// so "same clause" is the association rule, no dependency-parsing needed).
|
|
||||||
/// `quantity`/`unitId` are best-effort, populated only when a leading
|
|
||||||
/// numeric expression immediately preceding the ingredient mention resolved
|
|
||||||
/// against the `Unit` catalog (`ingredient-matcher.ts`'s
|
|
||||||
/// `findIngredientMentions`) — both `null` when the clause names the
|
|
||||||
/// ingredient with no quantity ("ajouter le sel"). `start`/`end` are the
|
|
||||||
/// ingredient mention's own span in `Step.description`, same `[start, end)`
|
|
||||||
/// convention as `StepTechStep.start`/`end`. `source` mirrors
|
|
||||||
/// `StepTechStep.source` (`"auto"` today, room for a future user
|
|
||||||
/// correction without a shape change).
|
|
||||||
model StepTechStepIngredient {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
stepId Int @map("step_id")
|
|
||||||
techStepOrder Int @map("tech_step_order")
|
|
||||||
ingredientId Int @map("ingredient_id")
|
|
||||||
quantity Decimal? @db.Decimal(10, 2)
|
|
||||||
unitId Int? @map("unit_id")
|
|
||||||
start Int
|
|
||||||
end Int
|
|
||||||
source String @default("auto")
|
|
||||||
|
|
||||||
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
|
|
||||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
|
||||||
unit Unit? @relation(fields: [unitId], references: [id])
|
|
||||||
|
|
||||||
@@map("step_tech_step_ingredient")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A utensil mention found in the same clause as one `StepTechStep`
|
|
||||||
/// occurrence — same association rule as `StepTechStepIngredient` (see its
|
|
||||||
/// doc comment). Detected by
|
|
||||||
/// `services/tech-step-intent-service`'s own utensil `PhraseMatcher`
|
|
||||||
/// (`intent_service/utensil_vocabulary.py`), returned alongside technique
|
|
||||||
/// entities in `POST /v1/process` and filtered to this clause's span by
|
|
||||||
/// `tech-step-matcher.ts`.
|
|
||||||
model StepTechStepUtensil {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
stepId Int @map("step_id")
|
|
||||||
techStepOrder Int @map("tech_step_order")
|
|
||||||
utensilId Int @map("utensil_id")
|
|
||||||
start Int
|
|
||||||
end Int
|
|
||||||
source String @default("auto")
|
|
||||||
|
|
||||||
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
|
|
||||||
utensil Utensil @relation(fields: [utensilId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@map("step_tech_step_utensil")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A temperature mention found in the same *clause* as one `StepTechStep`
|
|
||||||
/// occurrence — same association rule as `StepTechStepIngredient` /
|
|
||||||
/// `StepTechStepUtensil` (see their doc comments). Detected by
|
|
||||||
/// `services/tech-step-intent-service`'s regex pass (`temperature_extraction.py`),
|
|
||||||
/// not a `PhraseMatcher` — a temperature is numeric/pattern, not a word from
|
|
||||||
/// a finite vocabulary. Carries no foreign key: the structured value *is*
|
|
||||||
/// the data. At least one of `celsius` / `gasMark` / `qualitative` is
|
|
||||||
/// non-null:
|
|
||||||
/// - `celsius` — an oven/pan temperature in °C (a °F mention is
|
|
||||||
/// converted + rounded upstream).
|
|
||||||
/// - `gasMark` — a gas-mark / thermostat number ("th. 6").
|
|
||||||
/// - `qualitative` — "low" | "medium" | "high" ("feu doux/moyen/vif").
|
|
||||||
/// `raw` is the exact recognized fragment, `start`/`end` its span in
|
|
||||||
/// `Step.description` ([start, end), same as `StepTechStep.start`/`end`).
|
|
||||||
/// `source` mirrors the others (`"auto"` only today — no correction UI).
|
|
||||||
model StepTechStepTemperature {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
stepId Int @map("step_id")
|
|
||||||
techStepOrder Int @map("tech_step_order")
|
|
||||||
start Int
|
|
||||||
end Int
|
|
||||||
celsius Decimal? @db.Decimal(6, 2)
|
|
||||||
gasMark Int? @map("gas_mark")
|
|
||||||
qualitative String?
|
|
||||||
raw String
|
|
||||||
source String @default("auto")
|
|
||||||
|
|
||||||
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@map("step_tech_step_temperature")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One user-submitted correction to a `Step`'s detected techniques —
|
|
||||||
/// captures ADD (a missing technique the classifier didn't find),
|
|
||||||
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
|
|
||||||
/// `previousTechStepId` is the (possibly absent) match being corrected,
|
|
||||||
/// `correctedTechStepId` is what the user asserts instead (absent means
|
|
||||||
/// "no technique belongs here"). Both `null` at once is invalid (nothing
|
|
||||||
/// would have changed) — enforced service-side, not by the schema, same
|
|
||||||
/// posture as other cross-field invariants in this codebase (e.g.
|
|
||||||
/// `RecipeIngredientView`'s no-duplicate-ingredient check).
|
|
||||||
///
|
|
||||||
/// `start`/`end` are the user's selected `[start, end)` span within
|
|
||||||
/// `Step.description` (`String.prototype.slice` convention, same as
|
|
||||||
/// `StepTechStep`) — what they highlighted before assigning a technique to
|
|
||||||
/// it, not necessarily identical to any existing `StepTechStep` span.
|
|
||||||
///
|
|
||||||
/// Never edited/deleted once created (an audit trail of what was actually
|
|
||||||
/// submitted) — only `consumedAt` changes, stamped once
|
|
||||||
/// `services/tech-step-llm-worker` has turned this correction into a
|
|
||||||
/// `TechStepTrainingSuggestion` for a maintainer to review, so the same
|
|
||||||
/// correction isn't proposed twice on the next scheduled run.
|
|
||||||
model StepTechStepCorrection {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
stepId Int @map("step_id")
|
|
||||||
/// Any profile that could *view* the recipe when they submitted this, not
|
|
||||||
/// necessarily its author — see `assertRecipeVisible`,
|
|
||||||
/// `recipe.service.ts`.
|
|
||||||
correctorId Int @map("corrector_id")
|
|
||||||
start Int
|
|
||||||
end Int
|
|
||||||
previousTechStepId Int? @map("previous_tech_step_id")
|
|
||||||
correctedTechStepId Int? @map("corrected_tech_step_id")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
consumedAt DateTime? @map("consumed_at")
|
|
||||||
|
|
||||||
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
|
||||||
corrector UserProfile @relation(fields: [correctorId], references: [id], onDelete: Cascade)
|
|
||||||
previousTechStep TechStep? @relation("PreviousTechStep", fields: [previousTechStepId], references: [id], onDelete: SetNull)
|
|
||||||
correctedTechStep TechStep? @relation("CorrectedTechStep", fields: [correctedTechStepId], references: [id], onDelete: SetNull)
|
|
||||||
trainingSuggestions TechStepTrainingSuggestion[]
|
|
||||||
|
|
||||||
@@map("step_tech_step_correction")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A candidate addition to `TECH_STEP_TRAINING_DATA`
|
|
||||||
/// (`tech-step-training-data.ts`), proposed by `services/tech-step-llm-worker`
|
|
||||||
/// from one of two sources (`sourceType`):
|
|
||||||
///
|
|
||||||
/// - `"correction"` — a user's `StepTechStepCorrection`, turned into
|
|
||||||
/// suggested synonyms/utterances by the worker's LLM
|
|
||||||
/// (`transform-corrections` job).
|
|
||||||
/// - `"llm_audit"` — a low-confidence NLP clause on an *existing* recipe the
|
|
||||||
/// worker periodically samples and re-judges with its LLM
|
|
||||||
/// (`audit-low-confidence` job); no `sourceCorrectionId` in this case.
|
|
||||||
///
|
|
||||||
/// Deliberately never auto-applied to `tech-step-training-data.ts` — a
|
|
||||||
/// maintainer reviews `status: "pending"` rows (see
|
|
||||||
/// `list-pending-training-suggestions.ts`) and edits that file by hand,
|
|
||||||
/// same "generated suggestion, human-reviewed source of truth" split as a
|
|
||||||
/// linter's autofix vs. a human-authored diff. `retrain-tech-steps.ts` then
|
|
||||||
/// flips `status` to `"applied"`/`"rejected"` once a maintainer has acted on
|
|
||||||
/// a batch, so the same suggestion isn't reviewed twice.
|
|
||||||
///
|
|
||||||
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
|
||||||
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
|
||||||
/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text
|
|
||||||
/// proposed once for a human to read, not ids referencing another catalog
|
|
||||||
/// table, so there's nothing for a join table to normalize against.
|
|
||||||
model TechStepTrainingSuggestion {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
techStepId Int @map("tech_step_id")
|
|
||||||
locale String
|
|
||||||
suggestedSynonyms String[] @map("suggested_synonyms")
|
|
||||||
suggestedUtterances String[] @map("suggested_utterances")
|
|
||||||
sourceType String @map("source_type")
|
|
||||||
sourceCorrectionId Int? @map("source_correction_id")
|
|
||||||
status String @default("pending")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
techStep TechStep @relation(fields: [techStepId], references: [id])
|
|
||||||
sourceCorrection StepTechStepCorrection? @relation(fields: [sourceCorrectionId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@map("tech_step_training_suggestion")
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Admin application
|
|
||||||
// A separate operations app (usage metrics, microservice monitoring, tech-step
|
|
||||||
// correction triage) — see specs/backend-architecture.md's "admin" section.
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// An operator of the admin application (`apps/admin-web` / the `/admin/*`
|
|
||||||
/// API surface). Deliberately its **own** table with **no relation** to
|
|
||||||
/// `UserProfile`: admin access is a completely separate concern from being
|
|
||||||
/// an end user of the recipe app — a person can be one, both or neither,
|
|
||||||
/// and the two auth mechanisms (`requireAdmin` vs `requireAuth`, distinct
|
|
||||||
/// cookies, distinct JWT secrets) never overlap. No self-service signup —
|
|
||||||
/// the first row is created out-of-band by `src/scripts/create-admin.ts`,
|
|
||||||
/// and (for now) there's no in-app admin-management UI.
|
|
||||||
model AdminUser {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
email String @unique
|
|
||||||
/// argon2 hash of the password — same hashing as `UserProfile.passwordHash`
|
|
||||||
/// (`auth.service.ts`'s `hashOptions`, cheaper cost under NODE_ENV=test).
|
|
||||||
passwordHash String @map("password_hash")
|
|
||||||
/// Display name shown in the admin UI's account menu.
|
|
||||||
name String
|
|
||||||
/// Bumped to invalidate previously-issued admin JWTs — same mechanism as
|
|
||||||
/// `UserProfile.tokenVersion`, checked on every request by `requireAdmin`.
|
|
||||||
tokenVersion Int @default(0) @map("token_version")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
/// Stamped on every successful login — a cheap "is this account still in
|
|
||||||
/// use" signal for the operator managing admins by hand.
|
|
||||||
lastLoginAt DateTime? @map("last_login_at")
|
|
||||||
|
|
||||||
@@map("admin_users")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One recorded product event, for the admin dashboard's usage metrics.
|
|
||||||
/// Written fire-and-forget by `lib/analytics.service.ts`'s `recordEvent`
|
|
||||||
/// from a handful of key service methods (signup, recipe import/create,
|
|
||||||
/// planning add, cooking-session open, tech-step correction, shopping-list
|
|
||||||
/// view) — never on the request's critical path, so a failed insert is
|
|
||||||
/// logged and swallowed, never surfaced to the user.
|
|
||||||
///
|
|
||||||
/// `type` is a free `String` (`"user.signup"`, `"recipe.imported"`…), not
|
|
||||||
/// an enum: adding a new event to instrument is a one-line call site
|
|
||||||
/// change with **no migration**. `actorId` is a `UserProfile.id` when
|
|
||||||
/// `actorType == "user"` but carries **no FK** — an event is an immutable
|
|
||||||
/// historical fact that must outlive the account it describes (a deleted
|
|
||||||
/// user's signup still counts on the curve). `context` is a small free
|
|
||||||
/// JSON blob (`{ sourceKey, recipeId, … }`) for slicing later; nothing
|
|
||||||
/// queries into it today.
|
|
||||||
model AnalyticsEvent {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
type String
|
|
||||||
actorType String @map("actor_type")
|
|
||||||
actorId Int? @map("actor_id")
|
|
||||||
context Json?
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
@@index([type, createdAt])
|
|
||||||
@@map("analytics_events")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Liveness/last-run record for a background worker that has no inbound
|
|
||||||
/// HTTP surface of its own — one row per worker (`workerKey`, today only
|
|
||||||
/// `"tech-step-llm-worker"`). The worker POSTs `/internal/tech-steps/heartbeat`
|
|
||||||
/// (`requireInternalWorker`) on boot, on every scheduler tick, and after
|
|
||||||
/// each job; the admin monitoring board reads this to show the worker as
|
|
||||||
/// up / stale / down and to surface its last job result. Upserted, never
|
|
||||||
/// accumulated — only the latest state matters.
|
|
||||||
model WorkerHeartbeat {
|
|
||||||
workerKey String @id @map("worker_key")
|
|
||||||
lastSeenAt DateTime @map("last_seen_at")
|
|
||||||
/// Set only by a `"job"` heartbeat — the last time the worker actually ran a job (vs. just a tick proving it's alive).
|
|
||||||
lastRunAt DateTime? @map("last_run_at")
|
|
||||||
/// Small JSON summary of that last job (`{ job, ok, counts }`).
|
|
||||||
lastResult Json? @map("last_result")
|
|
||||||
|
|
||||||
@@map("worker_heartbeats")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Idempotency ledger for one-time data backfills run from the production
|
|
||||||
/// image's start command (`src/scripts/backfill-runtime.ts`, invoked in
|
|
||||||
/// apps/api/Dockerfile's CMD after `prisma migrate deploy` + `seed-runtime`).
|
|
||||||
///
|
|
||||||
/// The CMD re-runs on every container start, but a backfill here *rewrites*
|
|
||||||
/// existing rows (unlike `seed-runtime`, which only upserts and is safe to
|
|
||||||
/// repeat) — e.g. `step_tech_step_metadata_20260904` recomputes every
|
|
||||||
/// `StepTechStep` sequence against the intent service. So each backfill
|
|
||||||
/// gates itself on a row in this table: `key` present → already done, skip;
|
|
||||||
/// absent → run it, then insert the row. A schema migration only ever
|
|
||||||
/// creates the table structure; it can't run TypeScript against the intent
|
|
||||||
/// service, which is why this is a start-command step and not a migration.
|
|
||||||
model BackfillState {
|
|
||||||
key String @id
|
|
||||||
completedAt DateTime @default(now()) @map("completed_at")
|
|
||||||
|
|
||||||
@@map("backfill_state")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Catalogue des cibles de surlignage valides pour un didacticiel guidé —
|
|
||||||
/// même esprit que `TechStep`/`Unit`/`Diet` : une valeur de référence
|
|
||||||
/// maintenue par le code, seedée (`reference-seed-data.ts`). Chaque `key`
|
|
||||||
/// correspond à un attribut `data-tour-<key>` réellement posé quelque part
|
|
||||||
/// dans le frontend ; un admin choisit parmi ces cibles en construisant un
|
|
||||||
/// scénario, il ne peut pas en inventer une (poser un nouveau `data-tour-*`
|
|
||||||
/// reste un changement de code + une ligne seedée ici). Voir issues #42/#44.
|
|
||||||
model TourTarget {
|
|
||||||
key String @id
|
|
||||||
/// Libellé pour aider l'admin à choisir dans le formulaire, ex.
|
|
||||||
/// "Bouton + d'un créneau du planning".
|
|
||||||
description String
|
|
||||||
|
|
||||||
steps TourScenarioStep[]
|
|
||||||
|
|
||||||
@@map("tour_target")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Un scénario de didacticiel — une séquence d'étapes surlignées, rejouable
|
|
||||||
/// et éditable depuis l'admin (#44). `key` est une clé stable (comme
|
|
||||||
/// `Diet.key`/`Source.key`) : `"general"` / `"techStepMetadata"` pour les
|
|
||||||
/// deux scénarios seedés, libre pour un scénario créé par un admin.
|
|
||||||
model TourScenario {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
key String @unique
|
|
||||||
/// Libellé admin, ex. "Tour d'orientation".
|
|
||||||
name String
|
|
||||||
/// Désactiver sans supprimer — `GET /reference/tours` ne renvoie que les
|
|
||||||
/// scénarios activés.
|
|
||||||
enabled Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
steps TourScenarioStep[]
|
|
||||||
progress TourProgress[]
|
|
||||||
|
|
||||||
@@map("tour_scenario")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Une étape d'un scénario : (si `route` est défini) navigue vers cette
|
|
||||||
/// route, puis surligne l'élément portant `data-tour-<targetKey>` avec un
|
|
||||||
/// popover `title`/`content`. `title`/`content` sont du **texte libre**
|
|
||||||
/// saisi par l'admin, pas des clés i18n (#44 : le chrome de l'UI vient
|
|
||||||
/// d'i18next, le contenu administré dynamiquement est du texte, comme le
|
|
||||||
/// nom d'une recette).
|
|
||||||
model TourScenarioStep {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
scenarioId Int @map("scenario_id")
|
|
||||||
order Int
|
|
||||||
/// Route React Router à ouvrir avant cette étape ; `null` = rester sur la
|
|
||||||
/// route de l'étape précédente.
|
|
||||||
route String?
|
|
||||||
targetKey String @map("target_key")
|
|
||||||
title String
|
|
||||||
content String
|
|
||||||
|
|
||||||
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
|
||||||
target TourTarget @relation(fields: [targetKey], references: [key])
|
|
||||||
|
|
||||||
@@unique([scenarioId, order])
|
|
||||||
@@map("tour_scenario_step")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Présence d'une ligne = ce profil a *vu* ce scénario. Le terminer **ou**
|
|
||||||
/// le passer volontairement crée la ligne (pas de re-nag après un skip
|
|
||||||
/// délibéré). Révise la version de #42 (`tourKey: String` libre) en une
|
|
||||||
/// vraie FK vers `TourScenario` (#44).
|
|
||||||
model TourProgress {
|
|
||||||
userProfileId Int @map("user_profile_id")
|
|
||||||
scenarioId Int @map("scenario_id")
|
|
||||||
completedAt DateTime @default(now()) @map("completed_at")
|
|
||||||
|
|
||||||
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
|
||||||
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([userProfileId, scenarioId])
|
|
||||||
@@map("tour_progress")
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Contact
|
|
||||||
// In-app "report a bug / suggestion" form — user-submitted, read-only for
|
|
||||||
// admins at /admin/messages. See issues #80 (this) and #81 (turn a message
|
|
||||||
// into a Forgejo issue).
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A message sent via the app's contact form (bug, suggestion, other).
|
|
||||||
/// Written once by a signed-in user, never edited afterwards — consulted
|
|
||||||
/// read-only from the admin app. `authorId` is nullable + `onDelete: SetNull`
|
|
||||||
/// (same choice as `Ingredient.createdById`): a bug report keeps its value
|
|
||||||
/// even if the account that sent it is deleted in the meantime.
|
|
||||||
model ContactMessage {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
authorId Int? @map("author_id")
|
|
||||||
/// "bug" | "suggestion" | "other" — a plain `String` backed by the shared
|
|
||||||
/// `CONTACT_MESSAGE_TYPES` const (same approach as
|
|
||||||
/// `TechStepTrainingSuggestion.status` / `AnalyticsEvent.type`): adding a
|
|
||||||
/// value is one line, no Postgres enum migration.
|
|
||||||
type String
|
|
||||||
message String
|
|
||||||
/// Technical context captured automatically at submit time, for triage —
|
|
||||||
/// all nullable (an older client, or a direct API call, can omit them).
|
|
||||||
pageUrl String? @map("page_url")
|
|
||||||
userAgent String? @map("user_agent")
|
|
||||||
appVersion String? @map("app_version")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
/// Number of the Forgejo issue an admin created from this message via
|
|
||||||
/// `/admin/messages` (issue #81). `null` = no ticket yet. Non-null ⇒ the
|
|
||||||
/// "Créer un ticket" button disappears (one message links to at most one
|
|
||||||
/// issue).
|
|
||||||
forgejoIssueNumber Int? @map("forgejo_issue_number")
|
|
||||||
/// The issue's html URL (`<base>/<owner>/<repo>/issues/<n>`) — stored
|
|
||||||
/// as-is rather than rebuilt from `FORGEJO_*` config, so the link stays
|
|
||||||
/// correct even if that config changes later.
|
|
||||||
forgejoIssueUrl String? @map("forgejo_issue_url")
|
|
||||||
|
|
||||||
author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([createdAt])
|
|
||||||
@@map("contact_message")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,15 @@
|
||||||
import { errorHandlerService } from "@batch-cooking/error-tools";
|
import { errorHandlerService } from "@batch-cooking/error-tools";
|
||||||
import { createErrorMiddleware, ExpressServer } from "@batch-cooking/express-tools";
|
import { ExpressServer, createErrorMiddleware } from "@batch-cooking/express-tools";
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { errorLogger } from "./middlewares/error-logger.js";
|
|
||||||
import { requestLogger } from "./middlewares/request-logger.js";
|
|
||||||
import { adminRouter } from "./modules/admin/admin.routes.js";
|
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
import { contactRouter } from "./modules/contact/contact.routes.js";
|
|
||||||
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
|
|
||||||
import { houseRouter } from "./modules/house/house.routes.js";
|
import { houseRouter } from "./modules/house/house.routes.js";
|
||||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
|
||||||
import { mobileRouter } from "./modules/mobile/mobile.routes.js";
|
|
||||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||||
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
|
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
|
||||||
import { referenceRouter } from "./modules/reference/reference.routes.js";
|
import { referenceRouter } from "./modules/reference/reference.routes.js";
|
||||||
import { shoppingListRouter } from "./modules/shopping-list/shopping-list.routes.js";
|
|
||||||
import { sourcesRouter } from "./modules/sources/sources.routes.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the API's `ExpressServer`: standard middleware, routes, and the
|
* Builds the API's `ExpressServer`: standard middleware, routes, and the
|
||||||
|
|
@ -30,17 +21,6 @@ import { sourcesRouter } from "./modules/sources/sources.routes.js";
|
||||||
*/
|
*/
|
||||||
export function createServer(): ExpressServer {
|
export function createServer(): ExpressServer {
|
||||||
const server = new ExpressServer();
|
const server = new ExpressServer();
|
||||||
// First middleware registered, before even setupCore's own (CORS/JSON
|
|
||||||
// body parsing/cookies) — it only reads `req`/`res`, so it doesn't need
|
|
||||||
// to run after them, and mounting it first means it wraps the *whole*
|
|
||||||
// pipeline (its "finish" listener still fires for a request that never
|
|
||||||
// makes it past CORS/body-parsing, not just ones that reach a route).
|
|
||||||
server.addMiddleware(requestLogger);
|
|
||||||
// Allowed origin(s) come from `CORS_ORIGIN` (see its own doc comment in
|
|
||||||
// config/env.ts for why the packaged Android app's origin must always be
|
|
||||||
// one of them). The admin surface (`/admin/*`) is served by this same API
|
|
||||||
// and consumed by `apps/web`'s own `/admin/*` routes — same origin as the
|
|
||||||
// rest of the app, so no extra CORS entry.
|
|
||||||
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
||||||
|
|
||||||
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||||
|
|
@ -48,41 +28,12 @@ export function createServer(): ExpressServer {
|
||||||
});
|
});
|
||||||
|
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
// Admin application surface — its own auth (`requireAdmin`, distinct
|
|
||||||
// cookie/secret), never the end-user session. Mounted at `/admin/api`,
|
|
||||||
// *not* bare `/admin`: apps/web's own admin pages live at `/admin/*` too
|
|
||||||
// (`/admin/monitoring`, `/admin/login`...), and this API is served from
|
|
||||||
// the same origin/process as that frontend (see `serveStaticFrontend`
|
|
||||||
// below). A bare `/admin` mount put `GET /admin/monitoring` (API) and the
|
|
||||||
// page route `/admin/monitoring` in direct conflict — Express matches by
|
|
||||||
// path alone, so a hard navigation/refresh on that page was answered by
|
|
||||||
// this router's own handler (raw JSON) instead of falling through to the
|
|
||||||
// SPA's `index.html`. The `/api` segment guarantees no current or future
|
|
||||||
// admin page path can ever collide with an admin API path again.
|
|
||||||
server.mountRouter("/admin/api", adminRouter);
|
|
||||||
server.mountRouter("/house", houseRouter);
|
server.mountRouter("/house", houseRouter);
|
||||||
// In-app "signaler un bug / suggestion" form (issue #80) — session
|
|
||||||
// required, one POST. Admins read these rows via `/admin/api/contact`,
|
|
||||||
// never this router.
|
|
||||||
server.mountRouter("/contact", contactRouter);
|
|
||||||
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
|
||||||
// `requireInternalWorker` on every route within (see that router's own
|
|
||||||
// doc comment), never `requireAuth`. Mounted alongside the other routers
|
|
||||||
// rather than nested under one of them since it isn't scoped to a single
|
|
||||||
// recipe/step the way `recipeRouter`'s own correction routes are.
|
|
||||||
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
|
|
||||||
// Public proxy for Forgejo's Releases API (issues #65/#66) — see
|
|
||||||
// mobile.service.ts's doc comment for why this can't just be called
|
|
||||||
// directly from the browser.
|
|
||||||
server.mountRouter("/mobile", mobileRouter);
|
|
||||||
server.mountRouter("/planning", planningRouter);
|
server.mountRouter("/planning", planningRouter);
|
||||||
server.mountRouter("/preferences", preferencesRouter);
|
server.mountRouter("/preferences", preferencesRouter);
|
||||||
server.mountRouter("/profile", profileRouter);
|
server.mountRouter("/profile", profileRouter);
|
||||||
server.mountRouter("/recipes", recipeRouter);
|
server.mountRouter("/recipes", recipeRouter);
|
||||||
server.mountRouter("/reference", referenceRouter);
|
server.mountRouter("/reference", referenceRouter);
|
||||||
server.mountRouter("/shopping-list", shoppingListRouter);
|
|
||||||
server.mountRouter("/cooking-session", cookingSessionRouter);
|
|
||||||
server.mountRouter("/sources", sourcesRouter);
|
|
||||||
|
|
||||||
// Serves the built frontend (production Docker image only — see
|
// Serves the built frontend (production Docker image only — see
|
||||||
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after
|
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after
|
||||||
|
|
@ -99,12 +50,10 @@ export function createServer(): ExpressServer {
|
||||||
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
|
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Two error-handling middlewares in a row (Express runs them in
|
// Final error-handling middleware: every thrown/`next(err)`-ed error in
|
||||||
// registration order, same as regular middleware) — errorLogger logs the
|
// the app ends up here. All the "what status/body does this error map
|
||||||
// error, then hands it on (`next(err)`) to the real one: all the "what
|
// to" logic lives in ErrorHandlerService, from @batch-cooking/error-tools
|
||||||
// status/body does this error map to" logic lives in ErrorHandlerService,
|
// — this stays a thin adapter.
|
||||||
// from @batch-cooking/error-tools — this stays a thin adapter.
|
|
||||||
server.setErrorHandler(errorLogger);
|
|
||||||
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
|
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,12 @@
|
||||||
import dotenv from "dotenv";
|
import "dotenv/config";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// Loads `.env.test` instead of `.env` when running the test suite
|
|
||||||
// (NODE_ENV=test, set by `cross-env` in package.json's `test` script —
|
|
||||||
// already present in `process.env` by the time this module runs, since
|
|
||||||
// `cross-env` sets it before invoking node/tsx at all). Keeps
|
|
||||||
// `resetDatabase()` (test-support/reset-db.ts, which TRUNCATEs almost
|
|
||||||
// every table before each test) pointed at a dedicated test database,
|
|
||||||
// never whatever `pnpm dev` actually uses — running the test suite once
|
|
||||||
// already wiped a real local dev database this way (`.env`/`.env.test`
|
|
||||||
// sharing one `DATABASE_URL`), see `.env.test.example` for how to set the
|
|
||||||
// separate test database this now requires.
|
|
||||||
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes
|
|
||||||
* every optional var through as `${FOO:-}`, which resolves to `""` (not an
|
|
||||||
* absent key) when the stack's `.env` leaves it empty — and a `.env` file
|
|
||||||
* is `.dockerignore`d, so that passthrough is the container's only source
|
|
||||||
* of these. Fields that must *fail closed* on a blank value rather than run
|
|
||||||
* their own format check against `""` (and crash `parse()` at boot) wrap
|
|
||||||
* their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE`
|
|
||||||
* folds the same `"" -> undefined` step into its own `.transform`.
|
|
||||||
*/
|
|
||||||
const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for every environment variable the API reads. Parsing (below)
|
* Schema for every environment variable the API reads. Parsing (below)
|
||||||
* fails fast at startup if something required is missing/invalid, instead
|
* fails fast at startup if something required is missing/invalid, instead
|
||||||
* of surfacing as a confusing runtime error later. Exported so the parsing
|
* of surfacing as a confusing runtime error later.
|
||||||
* rules (e.g. `blankAsUnset` on the `FORGEJO_*` trio) can be unit-tested
|
|
||||||
* without the process-wide `process.env` the singleton below parses.
|
|
||||||
*/
|
*/
|
||||||
export const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
|
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
/** Port the HTTP server listens on. */
|
/** Port the HTTP server listens on. */
|
||||||
|
|
@ -48,42 +22,8 @@ export const envSchema = z.object({
|
||||||
JWT_EXPIRES_IN: z.string().default("7d"),
|
JWT_EXPIRES_IN: z.string().default("7d"),
|
||||||
/** Name of the httpOnly cookie carrying the session JWT. */
|
/** Name of the httpOnly cookie carrying the session JWT. */
|
||||||
AUTH_COOKIE_NAME: z.string().default("session"),
|
AUTH_COOKIE_NAME: z.string().default("session"),
|
||||||
/**
|
/** Origin allowed by CORS — must match wherever apps/web is served from. */
|
||||||
* Origin(s) allowed by CORS, comma-separated — parsed below into a
|
CORS_ORIGIN: z.string().default("http://localhost:5173"),
|
||||||
* `string[]` for `ExpressServerCoreOptions.corsOrigin`
|
|
||||||
* (`app.ts`/`express-server.ts`, which passes it straight to the `cors`
|
|
||||||
* package) and reused as-is for the Socket.IO handshake's own CORS config
|
|
||||||
* (`server.ts`, which needs its own since the WS upgrade never runs
|
|
||||||
* through `setupCore`'s middleware).
|
|
||||||
*
|
|
||||||
* Must always include `https://localhost`: the packaged Android app's
|
|
||||||
* WebView is served from exactly that fixed origin (Capacitor's default
|
|
||||||
* for `androidScheme`/hostname — see `apps/web/capacitor.config.ts`'s own
|
|
||||||
* doc comment), and every request it makes to this API — `/auth/login`
|
|
||||||
* and `/auth/signup` included — is genuinely cross-origin, unlike
|
|
||||||
* apps/web's *browser* build, which is served from the very same origin
|
|
||||||
* as this API in production (see docker-compose.yml's `app` service) and
|
|
||||||
* so never needs an entry here at all. Missing this origin doesn't fail
|
|
||||||
* loudly: the `cors` package still answers a preflight `OPTIONS` with 204
|
|
||||||
* for an origin it's about to reject (it only *omits*
|
|
||||||
* `Access-Control-Allow-Origin`), so the request the app's WebView logs
|
|
||||||
* looks identical to a normal one right up until the browser silently
|
|
||||||
* drops the real `POST` — surfacing client-side as a generic "unexpected
|
|
||||||
* error" with no 401/403 anywhere to point at the actual cause. Default
|
|
||||||
* covers both the Vite dev server and the packaged app out of the box; a
|
|
||||||
* real deployment's own web origin (e.g. a production domain) never needs
|
|
||||||
* adding here for the reason above, but the env var still allows
|
|
||||||
* extending/overriding this list (comma-separated) if that ever changes.
|
|
||||||
*/
|
|
||||||
CORS_ORIGIN: z
|
|
||||||
.string()
|
|
||||||
.default("http://localhost:5173,https://localhost")
|
|
||||||
.transform((value) =>
|
|
||||||
value
|
|
||||||
.split(",")
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter((origin) => origin.length > 0),
|
|
||||||
),
|
|
||||||
/**
|
/**
|
||||||
* Absolute path to the built frontend (`apps/web/dist`), to serve
|
* Absolute path to the built frontend (`apps/web/dist`), to serve
|
||||||
* alongside the API. Optional, no default — only set inside the
|
* alongside the API. Optional, no default — only set inside the
|
||||||
|
|
@ -108,75 +48,6 @@ export const envSchema = z.object({
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => (value === undefined || value === "" ? undefined : value === "true")),
|
.transform((value) => (value === undefined || value === "" ? undefined : value === "true")),
|
||||||
/**
|
|
||||||
* Shared secret `services/tech-step-llm-worker` sends as an
|
|
||||||
* `X-Internal-Worker-Secret` header on every call to `/internal/tech-steps/*`
|
|
||||||
* (`requireInternalWorker`, `middlewares/require-internal-worker.ts`).
|
|
||||||
* Optional with no default in the schema itself (unlike `JWT_SECRET`) so
|
|
||||||
* an environment that doesn't run the worker at all (e.g. this repo's
|
|
||||||
* existing test suite) never needs to set it — but `requireInternalWorker`
|
|
||||||
* itself rejects every request outright when it's unset, so the surface
|
|
||||||
* fails closed rather than open if a real deployment forgets to set it.
|
|
||||||
*/
|
|
||||||
INTERNAL_WORKER_SECRET: z.string().min(32).optional(),
|
|
||||||
/**
|
|
||||||
* Base URL of `services/tech-step-intent-service` (the spaCy-based
|
|
||||||
* microservice `TechStepClassifierService` delegates NER + intent
|
|
||||||
* classification to, see `lib/recipe-matching/intent-service-client.ts`).
|
|
||||||
* Has a default (unlike `DATABASE_URL`/secrets below) since it isn't
|
|
||||||
* secret and dev natively runs it on a fixed local port — Docker Compose
|
|
||||||
* overrides it to the compose network's service name.
|
|
||||||
*/
|
|
||||||
INTENT_SERVICE_BASE_URL: z.string().url().default("http://localhost:8000"),
|
|
||||||
/**
|
|
||||||
* Shared secret sent as an `X-Intent-Service-Secret` header on every call
|
|
||||||
* to `services/tech-step-intent-service`. Unlike `INTERNAL_WORKER_SECRET`
|
|
||||||
* above, **required, no `.optional()`** — that service is a core
|
|
||||||
* dependency (recipe save/preview can no longer detect any technique
|
|
||||||
* without it), not an optional background job; an environment that
|
|
||||||
* forgets to set this must fail loudly at startup, not silently run with
|
|
||||||
* every technique detection request failing one at a time.
|
|
||||||
*/
|
|
||||||
INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"),
|
|
||||||
/**
|
|
||||||
* Secret used to sign/verify the **admin** session JWT (`lib/admin-jwt.ts`)
|
|
||||||
* — entirely separate from `JWT_SECRET`, so an end-user session token can
|
|
||||||
* never be replayed against `/admin/*` and vice versa. `.optional()`
|
|
||||||
* (unlike `JWT_SECRET`): an instance that doesn't run the admin app at
|
|
||||||
* all never needs it — but `requireAdmin` (`middlewares/require-admin.ts`)
|
|
||||||
* rejects every request outright when it's unset, so the surface fails
|
|
||||||
* closed, same posture as `INTERNAL_WORKER_SECRET`.
|
|
||||||
*/
|
|
||||||
ADMIN_JWT_SECRET: z
|
|
||||||
.string()
|
|
||||||
.min(32, "ADMIN_JWT_SECRET must be at least 32 characters")
|
|
||||||
.optional(),
|
|
||||||
/** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */
|
|
||||||
ADMIN_COOKIE_NAME: z.string().default("admin_session"),
|
|
||||||
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
|
|
||||||
ADMIN_INITIAL_EMAIL: z.string().optional(),
|
|
||||||
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
|
||||||
ADMIN_INITIAL_NAME: z.string().optional(),
|
|
||||||
/**
|
|
||||||
* Forgejo instance + credentials for "create an issue from a contact
|
|
||||||
* message" (`/admin/messages`, issue #81). All three `.optional()` and
|
|
||||||
* checked together by `ForgejoService.isConfigured()` — the feature
|
|
||||||
* **fails closed** when any is missing: the endpoint answers `503` and
|
|
||||||
* the admin UI hides the button (same posture as `ADMIN_JWT_SECRET` /
|
|
||||||
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
|
|
||||||
* rest of the app never talks to Forgejo.
|
|
||||||
*/
|
|
||||||
FORGEJO_BASE_URL: z.preprocess(blankAsUnset, z.string().url().optional()),
|
|
||||||
/** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */
|
|
||||||
FORGEJO_TOKEN: z.preprocess(blankAsUnset, z.string().optional()),
|
|
||||||
/** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */
|
|
||||||
FORGEJO_REPO: z.preprocess(
|
|
||||||
blankAsUnset,
|
|
||||||
z
|
|
||||||
.string()
|
|
||||||
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
|
|
||||||
.optional(),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,3 @@
|
||||||
// Imported for its side effect only (loading `.env`/`.env.test` via
|
|
||||||
// dotenv) — must run *before* `new PrismaClient()` below. The generated
|
|
||||||
// Prisma Client bakes in its own fallback `.env` path (always
|
|
||||||
// `apps/api/.env`, the dev one — resolved once at `prisma generate` time)
|
|
||||||
// and loads it internally the first time a `PrismaClient` is constructed,
|
|
||||||
// unless `DATABASE_URL` is already set in `process.env` by then — dotenv
|
|
||||||
// never overrides an already-set variable, so whichever of these two env
|
|
||||||
// loads runs first "wins" for the rest of the process. Without this
|
|
||||||
// import, that race depended entirely on which test file some *other*
|
|
||||||
// module happened to import first, which normally worked out only by
|
|
||||||
// coincidence (whatever file mocha's `test/**/*.test.ts` glob happens to
|
|
||||||
// resolve first) — running a single test file in isolation (e.g. `mocha
|
|
||||||
// test/some-file.test.ts` directly, bypassing that glob) could silently
|
|
||||||
// resolve `DATABASE_URL` to the real dev database instead of
|
|
||||||
// `.env.test`'s. `resetDatabase()`'s own `assertRunningAgainstTestDatabase`
|
|
||||||
// guard (test-support/reset-db.ts) is what actually caught this in
|
|
||||||
// practice — it throws rather than truncating the wrong database — but
|
|
||||||
// the fix belongs here, at the source, not just at that one call site.
|
|
||||||
import "../config/env.js";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.js";
|
import { listRecipeSources } from "../lib/recipe-source-registry.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upserts one `Source` row (schema.prisma) per adapter currently in
|
* Upserts one `Source` row (schema.prisma) per adapter currently in
|
||||||
|
|
@ -25,66 +25,44 @@ import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.
|
||||||
* themselves at startup).
|
* themselves at startup).
|
||||||
*/
|
*/
|
||||||
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
|
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
|
||||||
try {
|
for (const adapter of listRecipeSources()) {
|
||||||
for (const adapter of listRecipeSources()) {
|
await prisma.source.upsert({
|
||||||
await prisma.source.upsert({
|
where: { key: adapter.key },
|
||||||
where: { key: adapter.key },
|
update: { name: adapter.name, official: adapter.official, iconUrl: adapter.iconUrl },
|
||||||
update: {
|
create: {
|
||||||
name: adapter.name,
|
key: adapter.key,
|
||||||
official: adapter.official,
|
name: adapter.name,
|
||||||
iconUrl: adapter.iconUrl,
|
official: adapter.official,
|
||||||
},
|
iconUrl: adapter.iconUrl,
|
||||||
create: {
|
},
|
||||||
key: adapter.key,
|
});
|
||||||
name: adapter.name,
|
|
||||||
official: adapter.official,
|
|
||||||
iconUrl: adapter.iconUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — callers (app startup, `sources.service.ts` via test
|
|
||||||
// setup) already handle/log failures centrally; this function just
|
|
||||||
// isn't allowed a bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which of `externalIds` already have a `Recipe` imported from the source
|
* Which of `externalIds` already have a `Recipe` imported from the source
|
||||||
* registered under `sourceKey`, mapped to that `Recipe`'s id — the
|
* registered under `sourceKey` — the DB-touching counterpart to
|
||||||
* DB-touching counterpart to `markAlreadyImported` (recipe-source-adapter.ts),
|
* `markAlreadyImported` (recipe-source-adapter.ts), which stays pure and
|
||||||
* which stays pure and takes a plain `ReadonlySet<string>` (this map's
|
* takes this set as a plain argument rather than querying itself. Returns
|
||||||
* `.keys()`) rather than querying itself. The id (not just membership) is
|
* an empty set (not an error) for a `sourceKey` with no matching `Source`
|
||||||
* what `sources.service.ts`'s browse endpoint needs to link an
|
* row — nothing can have been imported from a source we don't even have a
|
||||||
* already-imported item straight to its real `Recipe`, instead of a
|
* catalog entry for.
|
||||||
* caller having to look it up again. Returns an empty map (not an error)
|
|
||||||
* for a `sourceKey` with no matching `Source` row — nothing can have been
|
|
||||||
* imported from a source we don't even have a catalog entry for.
|
|
||||||
*/
|
*/
|
||||||
export async function findImportedRecipeIds(
|
export async function findImportedExternalIds(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
sourceKey: string,
|
sourceKey: string,
|
||||||
externalIds: string[],
|
externalIds: string[],
|
||||||
): Promise<Map<string, number>> {
|
): Promise<Set<string>> {
|
||||||
try {
|
if (externalIds.length === 0) return new Set();
|
||||||
if (externalIds.length === 0) return new Map();
|
|
||||||
|
|
||||||
const source = await prisma.source.findUnique({
|
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
|
||||||
where: { key: sourceKey },
|
if (!source) return new Set();
|
||||||
});
|
|
||||||
if (!source) return new Map();
|
|
||||||
|
|
||||||
const imported = await prisma.recipe.findMany({
|
const imported = await prisma.recipe.findMany({
|
||||||
where: { sourceId: source.id, externalId: { in: externalIds } },
|
where: { sourceId: source.id, externalId: { in: externalIds } },
|
||||||
select: { id: true, externalId: true },
|
select: { externalId: true },
|
||||||
});
|
});
|
||||||
return new Map(
|
return new Set(
|
||||||
imported.flatMap((recipe) =>
|
imported.flatMap((recipe) => (recipe.externalId !== null ? [recipe.externalId] : [])),
|
||||||
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
|
);
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see syncRecipeSources()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,262 +41,161 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
|
||||||
{ uid: "bunch", type: "COUNT", toBaseFactor: 1 },
|
{ uid: "bunch", type: "COUNT", toBaseFactor: 1 },
|
||||||
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
|
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
|
||||||
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 },
|
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 },
|
||||||
// US customary units — added for English-language sources (TheMealDB and
|
|
||||||
// similar routinely state quantities in cups/oz/lb, not metric), so
|
|
||||||
// `ingredient-matcher.ts` has something to resolve them to instead of
|
|
||||||
// always falling back to `unitId: null`. Convertible against their
|
|
||||||
// metric siblings like any other MASS/VOLUME unit.
|
|
||||||
{ uid: "cup", type: "VOLUME", toBaseFactor: 236.5882 },
|
|
||||||
{ uid: "ounce", type: "MASS", toBaseFactor: 28.3495 },
|
|
||||||
{ uid: "pound", type: "MASS", toBaseFactor: 453.5924 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Cooking-technique catalog (French recipe-step normalization) — the
|
// Cooking-technique catalog (French recipe-step normalization) — a static
|
||||||
// stable `key`s `tech-step-matcher.ts` auto-detects in a free-text
|
// list of common instructions, each carrying one or more text-matching
|
||||||
// `Step.description` (a step can mention several, e.g. "faire chauffer une
|
// rules used by `tech-step-matcher.ts` to auto-detect which technique(s) a
|
||||||
// poêle puis y faire fondre le beurre" is both `preheat` and `melt` — see
|
// free-text `Step.description` corresponds to (a step can mention several,
|
||||||
// `Step.techSteps`/`StepTechStep` in schema.prisma). Same "English
|
// e.g. "faire chauffer une poêle puis y faire fondre le beurre" is both
|
||||||
// camelCase uid, no French label" authoring as DIETS/UNITS — the label
|
// `preheat` and `melt` — see `Step.techSteps`/`StepTechStep` in
|
||||||
// lives in apps/web's locales/fr/translation.json under
|
// schema.prisma). Same "English camelCase uid, no French label" authoring
|
||||||
// `catalog.techSteps.<key>`.
|
// as DIETS/UNITS — the label lives in apps/web's
|
||||||
//
|
// locales/fr/translation.json under `catalog.techSteps.<key>`.
|
||||||
// Just a flat list of stable ids here — the actual matching data (per-
|
// `expression` is a regex source matched (case/accent-insensitive, via
|
||||||
// locale synonym lists + example phrasings the classifier trains on) lives
|
// `normalizeText`) against the step description; `weight` breaks ties when
|
||||||
// in `services/tech-step-intent-service/intent_service/training_data.py`'s
|
// two *different* techniques' expressions match the same span of text
|
||||||
// `TECH_STEP_TRAINING_DATA`, not here: it's owned and trained entirely by
|
// (highest weight wins) — see `tech-step-matcher.ts`'s `matchTechSteps`.
|
||||||
// that separate Python service (see its own README), not read by this
|
// Specific, multi-word phrases ("cuire au four", "faire revenir") are
|
||||||
// seed script at all, so it doesn't belong alongside the rest of this
|
// weighted higher than the generic single-verb forms they overlap with
|
||||||
// file's DB-seeded reference data. Every entry here must have a matching
|
// ("cuire", "sauter") so the more specific technique wins when both match
|
||||||
// entry there.
|
// the same words. `locale` lets the same technique carry one matching rule
|
||||||
export const TECH_STEPS: string[] = [
|
// set per language — every entry below is `"fr"` for now, the field exists
|
||||||
"cook",
|
// so other languages can be added later without a schema change.
|
||||||
"fry",
|
export const TECH_STEPS: Array<{
|
||||||
"melt",
|
uid: string;
|
||||||
"deglaze",
|
mappings: Array<{ locale: string; expression: string; weight: number }>;
|
||||||
"simmer",
|
|
||||||
"boil",
|
|
||||||
"roast",
|
|
||||||
"grill",
|
|
||||||
"panFry",
|
|
||||||
"blanch",
|
|
||||||
"marinate",
|
|
||||||
"chop",
|
|
||||||
"peel",
|
|
||||||
"mince",
|
|
||||||
"mix",
|
|
||||||
"whisk",
|
|
||||||
"foldIn",
|
|
||||||
"setAside",
|
|
||||||
"season",
|
|
||||||
"drain",
|
|
||||||
"brown",
|
|
||||||
"rest",
|
|
||||||
"preheat",
|
|
||||||
"bake",
|
|
||||||
"plate",
|
|
||||||
"coat",
|
|
||||||
// Lexique de techniques ajouté par la suite — voir
|
|
||||||
// `services/tech-step-intent-service/intent_service/training_data.py`
|
|
||||||
// pour les synonymes/phrases d'exemple de chacune.
|
|
||||||
"baste",
|
|
||||||
"appertize",
|
|
||||||
"whiskPale",
|
|
||||||
"goldenBrown",
|
|
||||||
"braise",
|
|
||||||
"truss",
|
|
||||||
"caramelize",
|
|
||||||
"score",
|
|
||||||
"lineMold",
|
|
||||||
"clarify",
|
|
||||||
"compote",
|
|
||||||
"concasse",
|
|
||||||
"confit",
|
|
||||||
"julienne",
|
|
||||||
"brunoise",
|
|
||||||
"mirepoix",
|
|
||||||
"paysanne",
|
|
||||||
"blindBake",
|
|
||||||
"bainMarie",
|
|
||||||
"smother",
|
|
||||||
"decant",
|
|
||||||
"dilute",
|
|
||||||
"punchDown",
|
|
||||||
"disgorge",
|
|
||||||
"loosen",
|
|
||||||
"shellEgg",
|
|
||||||
"scald",
|
|
||||||
"pod",
|
|
||||||
"emulsify",
|
|
||||||
"hollowOut",
|
|
||||||
"shock",
|
|
||||||
"setGel",
|
|
||||||
"glaze",
|
|
||||||
"thicken",
|
|
||||||
"filet",
|
|
||||||
"proof",
|
|
||||||
"peelBlanch",
|
|
||||||
"whipUp",
|
|
||||||
"moisten",
|
|
||||||
"pasteurize",
|
|
||||||
"poach",
|
|
||||||
"reduce",
|
|
||||||
"rubIn",
|
|
||||||
"dustWithFlour",
|
|
||||||
"sweat",
|
|
||||||
"sift",
|
|
||||||
"toast",
|
|
||||||
"zest",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Same authoring convention as `TECH_STEPS` right above (stable English
|
|
||||||
// camelCase uid, French label in `apps/web`'s `locales/fr/translation.json`
|
|
||||||
// under `catalog.utensils.<key>`) — but unlike `TECH_STEPS`, the matching
|
|
||||||
// data (per-locale synonym lists a `PhraseMatcher` matches against) lives
|
|
||||||
// in `services/tech-step-intent-service/intent_service/utensil_vocabulary.py`'s
|
|
||||||
// `UTENSIL_VOCABULARY`, not `training_data.py`: no textcat/training
|
|
||||||
// involved, a utensil mention doesn't need to be classified, only matched.
|
|
||||||
// Every entry here must have a matching entry there. See
|
|
||||||
// `StepTechStepUtensil` in schema.prisma for how a mention gets attached to
|
|
||||||
// a detected technique.
|
|
||||||
export const UTENSILS: string[] = [
|
|
||||||
"pan",
|
|
||||||
"saucepan",
|
|
||||||
"pot",
|
|
||||||
"knife",
|
|
||||||
"whisk",
|
|
||||||
"bowl",
|
|
||||||
"bakingSheet",
|
|
||||||
"mold",
|
|
||||||
"colander",
|
|
||||||
"cuttingBoard",
|
|
||||||
"oven",
|
|
||||||
"blender",
|
|
||||||
"mixer",
|
|
||||||
"spatula",
|
|
||||||
"ladle",
|
|
||||||
"grater",
|
|
||||||
"rollingPin",
|
|
||||||
"lid",
|
|
||||||
"tongs",
|
|
||||||
"peeler",
|
|
||||||
"sieve",
|
|
||||||
"foodProcessor",
|
|
||||||
"steamerBasket",
|
|
||||||
"skewer",
|
|
||||||
"pastryBrush",
|
|
||||||
"ramekin",
|
|
||||||
"dish",
|
|
||||||
"wok",
|
|
||||||
"thermometer",
|
|
||||||
"mandoline",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Guided-tour highlight targets (issues #42/#44). Each `key` is the suffix
|
|
||||||
// of a `data-tour-<key>` attribute the frontend puts on the element the
|
|
||||||
// tour points at; `description` is the hint an admin sees when picking a
|
|
||||||
// target in the scenario editor. Reference data maintained here, like
|
|
||||||
// `TECH_STEPS`/`UTENSILS` — adding a real new target needs a frontend
|
|
||||||
// `data-tour-*` *and* a line here.
|
|
||||||
export const TOUR_TARGETS: Array<{ key: string; description: string }> = [
|
|
||||||
{ key: "nav-planning", description: "Lien « Planning » de la barre latérale" },
|
|
||||||
{ key: "nav-recipes", description: "Lien « Recettes » de la barre latérale" },
|
|
||||||
{ key: "nav-shopping-list", description: "Lien « Liste de courses » de la barre latérale" },
|
|
||||||
{ key: "planning-grid", description: "La grille du planning de la semaine" },
|
|
||||||
{ key: "planning-add-recipe", description: "Bouton « + » d'un créneau du planning" },
|
|
||||||
{ key: "planning-cook-button", description: "Bouton « Cuisiner » de l'en-tête du planning" },
|
|
||||||
{ key: "shopping-list-summary", description: "La liste de courses générée pour la semaine" },
|
|
||||||
{
|
|
||||||
key: "techstep-description",
|
|
||||||
description: "Le texte d'une étape de recette, avec les techniques surlignées",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "techstep-technique-picker",
|
|
||||||
description: "Le sélecteur de technique du popover de correction",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// The two tours filed in issue #42, as the DB-backed scenarios #44 defines.
|
|
||||||
// Only *created* if absent (an admin owns name/enabled/steps afterwards —
|
|
||||||
// see `seedTourScenarios`); `content`/`title` are plain French text, not
|
|
||||||
// i18n keys (#44). The second tour is deliberately short for now — issue
|
|
||||||
// #36 (correction popover closes mid-selection) is still open, so v1 only
|
|
||||||
// covers the gestures that already work (select text, pick a technique).
|
|
||||||
export const TOUR_SCENARIOS: Array<{
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
steps: Array<{ route: string | null; targetKey: string; title: string; content: string }>;
|
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
key: "general",
|
uid: "cook",
|
||||||
name: "Tour d'orientation",
|
mappings: [
|
||||||
steps: [
|
{ locale: "fr", expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", weight: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "fry",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "melt",
|
||||||
|
mappings: [
|
||||||
{
|
{
|
||||||
route: "/",
|
locale: "fr",
|
||||||
targetKey: "nav-planning",
|
expression:
|
||||||
title: "Votre semaine, ici",
|
"\\bfondre\\b|\\bfondu(e|es|s)?\\b|\\bfaire fondre\\b|\\bfaites fondre\\b|\\bfaire chauffer\\b|\\bfaites chauffer\\b",
|
||||||
content: "Le planning est votre point de départ : ce que vous mangez, jour par jour.",
|
weight: 15,
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
uid: "deglaze",
|
||||||
name: "Corriger les techniques d'une recette",
|
mappings: [{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 }],
|
||||||
steps: [
|
},
|
||||||
|
{
|
||||||
|
uid: "simmer",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bmijot(er|ez|e|ant|é)\\b", weight: 15 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "boil",
|
||||||
|
mappings: [
|
||||||
|
{ locale: "fr", expression: "\\bbouill(ir|ant|ie|ies)\\b|\\b[ée]bullition\\b", weight: 12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "roast",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\br[ôo]tir\\b|\\br[ôo]ti(e|es|s)?\\b", weight: 15 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "grill",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bgrill(er|ez|é|ée|ées|ade)\\b", weight: 15 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "panFry",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bsaut(er|ez|é|ée|ées|ant)\\b", weight: 12 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "blanch",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bblanch(ir|issez|i|ie|ies|iment)\\b", weight: 18 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "marinate",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bmarin(er|ez|é|ée|ées|ade)\\b", weight: 18 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "chop",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bhach(er|ez|é|ée|ées|is)\\b", weight: 15 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "peel",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\b[ée]pluch(er|ez|é|ée|ées|age)\\b", weight: 15 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "mince",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\b[ée]minc(er|ez|é|ée|ées)\\b", weight: 18 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "mix",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bm[ée]lang(er|ez|é|ée|ées|e|es)\\b", weight: 10 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "whisk",
|
||||||
|
mappings: [
|
||||||
|
{ locale: "fr", expression: "\\bfouett(er|ez|é|ée|ées)\\b|\\bau fouet\\b", weight: 15 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "foldIn",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bincorpor(er|ez|é|ée|ées|ant)\\b", weight: 15 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "setAside",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\br[ée]serv(er|ez|é|ée|ées)\\b", weight: 15 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "season",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bassaisonn(er|ez|é|ée|ées|ement)\\b", weight: 15 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "drain",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\b[ée]goutt(er|ez|é|ée|ées)\\b", weight: 15 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "brown",
|
||||||
|
mappings: [
|
||||||
{
|
{
|
||||||
route: null,
|
locale: "fr",
|
||||||
targetKey: "techstep-description",
|
expression:
|
||||||
title: "Les techniques repérées",
|
"\\bfaire revenir\\b|\\bfaites revenir\\b|\\bfais revenir\\b|\\bfaire dorer\\b|\\bfaites dorer\\b",
|
||||||
content:
|
weight: 25,
|
||||||
"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.",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
uid: "rest",
|
||||||
|
mappings: [
|
||||||
|
{ locale: "fr", expression: "\\blaiss(er|ez|e) reposer\\b|\\breposer\\b", weight: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "preheat",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", weight: 20 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "bake",
|
||||||
|
mappings: [
|
||||||
|
{
|
||||||
|
locale: "fr",
|
||||||
|
expression:
|
||||||
|
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
|
||||||
|
weight: 25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ uid: "plate", mappings: [{ locale: "fr", expression: "\\bdress(er|ez|age)\\b", weight: 15 }] },
|
||||||
|
{
|
||||||
|
uid: "coat",
|
||||||
|
mappings: [{ locale: "fr", expression: "\\bnapp(er|ez|é|ée|ées|age)\\b", weight: 15 }],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
||||||
|
|
@ -558,13 +457,6 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
{ uid: "vealCutlet", allergenUids: [] },
|
{ uid: "vealCutlet", allergenUids: [] },
|
||||||
{ uid: "porkTenderloin", allergenUids: [] },
|
{ uid: "porkTenderloin", allergenUids: [] },
|
||||||
{ uid: "porkChop", allergenUids: [] },
|
{ uid: "porkChop", allergenUids: [] },
|
||||||
// Ground/minced meats, requested alongside `groundBeef` (already
|
|
||||||
// seeded) — one per red-meat type the catalog otherwise only offers
|
|
||||||
// as a whole cut. Ground poultry (turkey/chicken) lives in the
|
|
||||||
// `poultry` subcategory below, next to their whole-cut counterparts.
|
|
||||||
{ uid: "groundVeal", allergenUids: [] },
|
|
||||||
{ uid: "groundPork", allergenUids: [] },
|
|
||||||
{ uid: "groundLamb", allergenUids: [] },
|
|
||||||
{ uid: "lamb", allergenUids: [] },
|
{ uid: "lamb", allergenUids: [] },
|
||||||
{ uid: "legOfLamb", allergenUids: [] },
|
{ uid: "legOfLamb", allergenUids: [] },
|
||||||
{ uid: "baconLardons", allergenUids: [] },
|
{ uid: "baconLardons", allergenUids: [] },
|
||||||
|
|
@ -615,9 +507,7 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
defaultIcon: "POULTRY",
|
defaultIcon: "POULTRY",
|
||||||
items: [
|
items: [
|
||||||
{ uid: "chicken", allergenUids: [] },
|
{ uid: "chicken", allergenUids: [] },
|
||||||
{ uid: "groundChicken", allergenUids: [] },
|
|
||||||
{ uid: "turkey", allergenUids: [] },
|
{ uid: "turkey", allergenUids: [] },
|
||||||
{ uid: "groundTurkey", allergenUids: [] },
|
|
||||||
{ uid: "duck", allergenUids: [] },
|
{ uid: "duck", allergenUids: [] },
|
||||||
{ uid: "duckBreast", allergenUids: [] },
|
{ uid: "duckBreast", allergenUids: [] },
|
||||||
// Ciqual 2025 additions — see the VIANDES group above.
|
// Ciqual 2025 additions — see the VIANDES group above.
|
||||||
|
|
@ -953,11 +843,7 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
subcategory: "eggs",
|
subcategory: "eggs",
|
||||||
defaultDiets: ["vegetarian", "pescatarian"],
|
defaultDiets: ["vegetarian", "pescatarian"],
|
||||||
defaultIcon: "EGG",
|
defaultIcon: "EGG",
|
||||||
items: [
|
items: [{ uid: "egg", allergenUids: ["eggs"] }],
|
||||||
{ uid: "egg", allergenUids: ["eggs"] },
|
|
||||||
{ uid: "eggYolk", allergenUids: ["eggs"] },
|
|
||||||
{ uid: "eggWhite", allergenUids: ["eggs"] },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "dairyAndCheese",
|
category: "dairyAndCheese",
|
||||||
|
|
@ -1017,7 +903,6 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
{ uid: "fiveSpice", allergenUids: [] },
|
{ uid: "fiveSpice", allergenUids: [] },
|
||||||
{ uid: "garamMasala", allergenUids: [] },
|
{ uid: "garamMasala", allergenUids: [] },
|
||||||
{ uid: "corianderSeeds", allergenUids: [] },
|
{ uid: "corianderSeeds", allergenUids: [] },
|
||||||
{ uid: "groundCoriander", allergenUids: [] },
|
|
||||||
{ uid: "cardamom", allergenUids: [] },
|
{ uid: "cardamom", allergenUids: [] },
|
||||||
{ uid: "fenugreek", allergenUids: [] },
|
{ uid: "fenugreek", allergenUids: [] },
|
||||||
{ uid: "jalapeno", allergenUids: [] },
|
{ uid: "jalapeno", allergenUids: [] },
|
||||||
|
|
@ -1396,16 +1281,36 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TechStep: upsert by key (same idempotent-seed reasoning as everything
|
// TechStep: upsert by key (same idempotent-seed reasoning as everything
|
||||||
// above) — just the stable id/key rows themselves now, no matching data
|
// above), then fully replace its mappings on every reseed. Mappings carry
|
||||||
// to replace alongside them (see `TECH_STEPS`' own comment for why).
|
// no natural per-row identity to upsert against, and expressions/weights
|
||||||
for (const key of TECH_STEPS) {
|
// are expected to be tuned over time — a straight "delete all, recreate
|
||||||
|
// from source" keeps the table an exact mirror of `TECH_STEPS` rather
|
||||||
|
// than accumulating stale/duplicate rows from earlier edits. Nothing else
|
||||||
|
// references `TechStepMapping.id` (`Step` only points at `TechStep`, not
|
||||||
|
// at a specific mapping), so this replace is safe.
|
||||||
|
for (const { uid: key } of TECH_STEPS) {
|
||||||
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
|
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
|
||||||
}
|
}
|
||||||
|
const techSteps = await prisma.techStep.findMany({
|
||||||
|
where: { key: { in: TECH_STEPS.map((t) => t.uid) } },
|
||||||
|
});
|
||||||
|
const techStepIdByKey = new Map(techSteps.map((t) => [t.key, t.id]));
|
||||||
|
|
||||||
// Utensil: same idempotent bare id/key upsert as TechStep right above —
|
await prisma.techStepMapping.deleteMany({
|
||||||
// no matching data alongside it either (see `UTENSILS`' own comment).
|
where: { techStepId: { in: [...techStepIdByKey.values()] } },
|
||||||
for (const key of UTENSILS) {
|
});
|
||||||
await prisma.utensil.upsert({ where: { key }, update: {}, create: { key } });
|
const techStepMappingRows = TECH_STEPS.flatMap(({ uid, mappings }) => {
|
||||||
|
const techStepId = techStepIdByKey.get(uid);
|
||||||
|
if (techStepId === undefined) return [];
|
||||||
|
return mappings.map(({ locale, expression, weight }) => ({
|
||||||
|
techStepId,
|
||||||
|
locale,
|
||||||
|
expression,
|
||||||
|
weight,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
if (techStepMappingRows.length > 0) {
|
||||||
|
await prisma.techStepMapping.createMany({ data: techStepMappingRows });
|
||||||
}
|
}
|
||||||
|
|
||||||
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
||||||
|
|
@ -1524,47 +1429,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,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { env } from "../config/env.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side operational logging — the one place in this codebase allowed
|
|
||||||
* to call `console.*` directly (see `biome.json`'s `noConsole`, which bans
|
|
||||||
* bare `console.log` everywhere else: every log line here goes through a
|
|
||||||
* named level instead, `logger.info(...)`/`logger.error(...)`, never an
|
|
||||||
* unlabeled dump of text). Everything else (the request logger, `server.ts`'s
|
|
||||||
* startup line, the error middleware) goes through this instead of touching
|
|
||||||
* `console` itself, so there's exactly one place that decides the log
|
|
||||||
* *shape* (structured JSON lines — one object per line, trivially
|
|
||||||
* grep/parse-able by `docker logs`/Portainer or any log aggregator, unlike
|
|
||||||
* free-form `console.log` text).
|
|
||||||
*
|
|
||||||
* A class, not a plain object literal — same convention as
|
|
||||||
* `ErrorHandlerService` (`packages/error-tools`): `public` methods are the
|
|
||||||
* actual API, `private` ones are internals a caller never touches directly.
|
|
||||||
* `export const logger = new LoggerService()` below is the single shared
|
|
||||||
* instance every caller imports — there's exactly one log stream for the
|
|
||||||
* whole process, nothing to parameterize per call site, so nobody
|
|
||||||
* constructs their own.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Ascending severity — mirrors the standard `debug < info < warn < error` convention. */
|
|
||||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
||||||
|
|
||||||
/** Arbitrary structured context attached to a log line (a request id, a duration, an error's own fields, …) — merged into the emitted JSON object, never interpolated into the message string itself. */
|
|
||||||
export type LogMeta = Record<string, unknown>;
|
|
||||||
|
|
||||||
const LEVEL_SEVERITY: Record<LogLevel, number> = {
|
|
||||||
debug: 0,
|
|
||||||
info: 1,
|
|
||||||
warn: 2,
|
|
||||||
error: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lines below this level are dropped rather than emitted — keeps `debug`
|
|
||||||
* out of production (verbose, meant for local troubleshooting only) and
|
|
||||||
* out of the test suite's own output (Mocha's reporter is noisy enough
|
|
||||||
* already), while `pnpm dev:api` sees everything. A free function, not a
|
|
||||||
* method — exported (unlike the rest of this module's internals) purely so
|
|
||||||
* `logger.service.test.ts` can exercise the three `NODE_ENV` cases
|
|
||||||
* directly, without needing a `LoggerService` instance at all.
|
|
||||||
*/
|
|
||||||
export function minLevelFor(nodeEnv: typeof env.NODE_ENV): LogLevel {
|
|
||||||
if (nodeEnv === "production") return "info";
|
|
||||||
if (nodeEnv === "test") return "warn";
|
|
||||||
return "debug";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Console method each level writes through — `error`/`warn` go to stderr (their own native behavior), `debug`/`info` to stdout, the usual split log aggregators expect. */
|
|
||||||
const CONSOLE_METHOD: Record<LogLevel, "debug" | "info" | "warn" | "error"> = {
|
|
||||||
debug: "debug",
|
|
||||||
info: "info",
|
|
||||||
warn: "warn",
|
|
||||||
error: "error",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Server-side operational logger — see the module doc comment for why this exists and what it's for. */
|
|
||||||
export class LoggerService {
|
|
||||||
/** Computed once at construction from `env.NODE_ENV` — see {@link minLevelFor}. */
|
|
||||||
private readonly _minSeverity: number;
|
|
||||||
|
|
||||||
public constructor(nodeEnv: typeof env.NODE_ENV = env.NODE_ENV) {
|
|
||||||
this._minSeverity = LEVEL_SEVERITY[minLevelFor(nodeEnv)];
|
|
||||||
}
|
|
||||||
|
|
||||||
public debug(message: string, meta?: LogMeta): void {
|
|
||||||
this._emit("debug", message, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public info(message: string, meta?: LogMeta): void {
|
|
||||||
this._emit("info", message, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public warn(message: string, meta?: LogMeta): void {
|
|
||||||
this._emit("warn", message, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public error(message: string, meta?: LogMeta): void {
|
|
||||||
this._emit("error", message, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _emit(level: LogLevel, message: string, meta?: LogMeta): void {
|
|
||||||
if (LEVEL_SEVERITY[level] < this._minSeverity) return;
|
|
||||||
|
|
||||||
// `meta` spread first so a caller accidentally passing e.g. `{ message:
|
|
||||||
// ... }` in it can never shadow the line's own core fields.
|
|
||||||
const line = {
|
|
||||||
...meta,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
level,
|
|
||||||
message,
|
|
||||||
};
|
|
||||||
// biome-ignore lint/suspicious/noConsole: this is the one place allowed to — see the class doc comment above.
|
|
||||||
console[CONSOLE_METHOD[level]](JSON.stringify(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Single shared instance — this service has no per-call-site state to isolate, same reasoning as `errorHandlerService`. */
|
|
||||||
export const logger = new LoggerService();
|
|
||||||
|
|
@ -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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,471 +0,0 @@
|
||||||
import {
|
|
||||||
INGREDIENT_LABEL_SYNONYMS_EN,
|
|
||||||
INGREDIENT_LABEL_SYNONYMS_FR,
|
|
||||||
INGREDIENT_LABELS_EN,
|
|
||||||
INGREDIENT_LABELS_FR,
|
|
||||||
UNIT_LABELS_EN,
|
|
||||||
UNIT_LABELS_FR,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { normalizeText } from "./tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter`
|
|
||||||
* lifts from a recipe source (`ParsedRecipeIngredient`) against our own
|
|
||||||
* `Ingredient`/`Unit` reference catalogs — the ingredient-side counterpart
|
|
||||||
* to `tech-step-matcher.ts`'s technique detection, built for the same
|
|
||||||
* reason: a source's raw text has no idea our catalogs even exist.
|
|
||||||
*
|
|
||||||
* Unlike tech steps (regex mappings hand-authored per technique),
|
|
||||||
* ingredient/unit labels are plain hand-written text, one table per
|
|
||||||
* supported `locale` (`INGREDIENT_LABELS_EN`/`INGREDIENT_LABELS_FR`/
|
|
||||||
* `UNIT_LABELS_EN`/`UNIT_LABELS_FR`, `packages/shared`) — matching them
|
|
||||||
* against arbitrary free text (extra adjectives, plurals, "large diced
|
|
||||||
* yellow onion" for a catalog entry that's just "Onion") needs its own,
|
|
||||||
* lighter algorithm: word-tokenize both sides, naively stem for plurals,
|
|
||||||
* then look for the catalog phrase's tokens as a contiguous run inside the
|
|
||||||
* ingredient text's tokens. The longest (most specific) matching catalog
|
|
||||||
* phrase wins, same "specificity resolves overlaps" idea as
|
|
||||||
* `matchTechSteps`, just without weights (there's no need to rank two
|
|
||||||
* *unrelated* ingredients — only a phrase against its own substrings, e.g.
|
|
||||||
* "chicken breast" beating bare "chicken").
|
|
||||||
*
|
|
||||||
* `matchIngredientName`/`matchUnit`/`extractQuantity` are pure, so they're
|
|
||||||
* unit-testable without a database (see `test/ingredient-matcher.test.ts`);
|
|
||||||
* `loadIngredientCatalog`/`loadUnitCatalog` are the only DB-touching pieces,
|
|
||||||
* meant to be fetched once per request and reused across every ingredient
|
|
||||||
* line, the same "don't requery per item" convention
|
|
||||||
* `tech-step-matcher.ts`'s `TechStepClassifierService` follows for its own
|
|
||||||
* one-time training pass.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its matching label in whichever locale it was loaded for. */
|
|
||||||
export interface IngredientMatchEntry {
|
|
||||||
ingredientId: number;
|
|
||||||
/** Matching label — English (`INGREDIENT_LABELS_EN`) or French (`INGREDIENT_LABELS_FR`) depending on which locale {@link loadIngredientCatalog} was called with, e.g. `"Chicken breast"`/`"Blanc de poulet"` — matched against free text, never displayed. */
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted spellings in whichever locale it was loaded for. */
|
|
||||||
export interface UnitMatchEntry {
|
|
||||||
unitId: number;
|
|
||||||
/** Accepted spellings — English (`UNIT_LABELS_EN`) or French (`UNIT_LABELS_FR`), e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]` or `["cuillère à soupe", "cas", ...]`. Unlike English, a French entry can be genuinely multi-word — see `matchUnit`'s own doc comment. */
|
|
||||||
synonyms: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Naive English stemmer — strips a plural suffix so "tomato"/"tomatoes",
|
|
||||||
* "onion"/"onions", "cherry"/"cherries" compare equal after stemming.
|
|
||||||
* Deliberately not a real linguistic stemmer: it only has to be
|
|
||||||
* *consistent* (the same word always stems the same way) since both sides
|
|
||||||
* of every comparison go through it, not linguistically correct on its
|
|
||||||
* own — see the module doc comment.
|
|
||||||
*/
|
|
||||||
function stemWordEn(word: string): string {
|
|
||||||
if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
|
|
||||||
if (word.endsWith("es") && word.length > 3) return word.slice(0, -2);
|
|
||||||
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
|
|
||||||
return word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Naive French stemmer — French regular plurals are overwhelmingly just
|
|
||||||
* "+s" on the singular (`"carotte"`/`"carottes"`, `"pomme"`/`"pommes"`),
|
|
||||||
* unlike English's several suffix patterns, so this only strips a single
|
|
||||||
* trailing "s". Deliberately *not* {@link stemWordEn}'s `"es"` rule reused
|
|
||||||
* here: applying it to French would silently corrupt any word whose
|
|
||||||
* singular itself ends in "e" plus a consonant before the final "s" — e.g.
|
|
||||||
* `"carottes"` would wrongly stem to `"carott"` (dropping the "e" that's
|
|
||||||
* actually part of the singular `"carotte"`) instead of `"carotte"`,
|
|
||||||
* exactly the class of near-miss that made ingredient matching
|
|
||||||
* French-locale silently broken before this stemmer existed (almost every
|
|
||||||
* regular French plural ends in "es" this way — it's not an edge case).
|
|
||||||
* Irregular plurals (`"cheval"`/`"chevaux"`, `"chou"`/`"choux"`) aren't
|
|
||||||
* handled — same "consistent, not linguistically perfect" trade-off as
|
|
||||||
* {@link stemWordEn}.
|
|
||||||
*/
|
|
||||||
function stemWordFr(word: string): string {
|
|
||||||
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
|
|
||||||
return word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dispatches to {@link stemWordEn}/{@link stemWordFr} by `locale` — any locale other than `"fr"` uses the English rules (the long-standing default, unchanged for every existing caller that doesn't pass a locale at all). */
|
|
||||||
function stemWord(word: string, locale: string): string {
|
|
||||||
return locale === "fr" ? stemWordFr(word) : stemWordEn(word);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lowercases, strips diacritics (via `normalizeText`) and splits `text` into stemmed word tokens — empty/non-letter runs are dropped, not kept as empty tokens. `locale` picks the stemming rules (see {@link stemWord}); defaults to `"en"`, the original behavior every pre-existing caller still gets without passing one. */
|
|
||||||
function tokenize(text: string, locale = "en"): string[] {
|
|
||||||
return normalizeText(text)
|
|
||||||
.split(/[^a-z]+/)
|
|
||||||
.filter((word) => word.length > 0)
|
|
||||||
.map((word) => stemWord(word, locale));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
|
|
||||||
function containsSubsequence(haystack: string[], needle: string[]): boolean {
|
|
||||||
if (needle.length === 0 || needle.length > haystack.length) return false;
|
|
||||||
for (let start = 0; start <= haystack.length - needle.length; start++) {
|
|
||||||
if (needle.every((word, i) => haystack[start + i] === word)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One stemmed word from {@link tokenizeWithOffsets}, alongside its `[start, end)` span in the *original* (un-normalized) text it came from. */
|
|
||||||
interface OffsetToken {
|
|
||||||
word: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Matches a run of letters (any script, diacritics included) — the same "word" unit {@link tokenize} splits `normalizeText`'d text on (`/[^a-z]+/`), applied here directly to the *original* text instead so each token keeps its real character offsets. Digits/punctuation are never part of a run, same separator role they play for `tokenize` (a leading quantity is `extractQuantity`'s job, not this module's word-tokenizer's). */
|
|
||||||
const LETTER_RUN_PATTERN = /\p{L}+/gu;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link tokenize}'s positional twin: same stemmed/normalized words, but
|
|
||||||
* each one keeps the `[start, end)` span it occupies in `text` — needed by
|
|
||||||
* {@link findIngredientMentions} to report *where* a mention is, not just
|
|
||||||
* that the catalog has a matching label somewhere. Splitting the original
|
|
||||||
* text into letter-runs first (rather than normalizing the whole string up
|
|
||||||
* front, the way `tokenize` does, then losing track of offsets) works
|
|
||||||
* safely here because `normalizeText` only ever rewrites a character's own
|
|
||||||
* form (case/diacritics) — see `_DiacriticsNormalizer`'s doc comment on the
|
|
||||||
* Python side, ported from the same guarantee — never merges or splits
|
|
||||||
* words, so normalizing one already-isolated run in place can't shift its
|
|
||||||
* boundaries relative to the un-normalized text.
|
|
||||||
*/
|
|
||||||
function tokenizeWithOffsets(text: string, locale = "en"): OffsetToken[] {
|
|
||||||
const tokens: OffsetToken[] = [];
|
|
||||||
for (const match of text.matchAll(LETTER_RUN_PATTERN)) {
|
|
||||||
const raw = match[0];
|
|
||||||
const start = match.index ?? 0;
|
|
||||||
const word = stemWord(normalizeText(raw), locale);
|
|
||||||
if (word.length === 0) continue;
|
|
||||||
tokens.push({ word, start, end: start + raw.length });
|
|
||||||
}
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches a quantity (integer/decimal/fraction/mixed number, same shapes as
|
|
||||||
* {@link extractQuantity}) immediately followed by an optional unit
|
|
||||||
* word/phrase (up to three words, e.g. "cuillères à soupe") and an optional
|
|
||||||
* connector ("de"/"d'"/"of"/"a"/"an"), anchored at the *end* of whatever
|
|
||||||
* string it's tested against (`$`) rather than the start. Anchoring at the
|
|
||||||
* end — not the start — is what lets {@link findQuantityBeforeIngredient}
|
|
||||||
* test the *whole* text preceding a mention without first having to guess
|
|
||||||
* where an unrelated preamble ("ajouter", "puis", an earlier sentence) ends
|
|
||||||
* and the quantity phrase begins: whatever doesn't fit the pattern
|
|
||||||
* immediately before the ingredient simply isn't part of the match, no
|
|
||||||
* separate boundary-finding step needed.
|
|
||||||
*/
|
|
||||||
const QUANTITY_BEFORE_INGREDIENT_PATTERN =
|
|
||||||
/(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:[.,]\d+)?)\s*((?:\p{L}+\s+){0,2}\p{L}*)\s*(?:de\s|d['’]|of\s|a\s|an\s)?$/u;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort quantity+unit lookup for an ingredient mention {@link findIngredientMentions}
|
|
||||||
* just found at `mentionStart` in `text` — looks *only* at what immediately
|
|
||||||
* precedes the mention (see {@link QUANTITY_BEFORE_INGREDIENT_PATTERN}), the
|
|
||||||
* dominant French/English recipe phrasing ("200g de beurre", "2 cuillères à
|
|
||||||
* soupe d'huile", "3 œufs"). Both `null` when nothing recognizable precedes
|
|
||||||
* it (no leading digit at all) — same "no match, not an error" posture as
|
|
||||||
* {@link extractQuantity}. Doesn't detect a quantity that *follows* its
|
|
||||||
* ingredient ("du beurre, 50g") — an accepted gap, same trade-off
|
|
||||||
* {@link extractQuantity} already documents for the leading-only case it
|
|
||||||
* was built for.
|
|
||||||
*/
|
|
||||||
function findQuantityBeforeIngredient(
|
|
||||||
text: string,
|
|
||||||
mentionStart: number,
|
|
||||||
unitCatalog: UnitMatchEntry[],
|
|
||||||
locale: string,
|
|
||||||
): { quantity: number | null; unitId: number | null } {
|
|
||||||
const match = QUANTITY_BEFORE_INGREDIENT_PATTERN.exec(text.slice(0, mentionStart));
|
|
||||||
if (!match) return { quantity: null, unitId: null };
|
|
||||||
const { quantity } = extractQuantity(match[1] ?? "");
|
|
||||||
const unitId = matchUnit(match[2] ?? "", unitCatalog, locale);
|
|
||||||
return { quantity, unitId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One ingredient mention {@link findIngredientMentions} found in a free-text clause, alongside its `[start, end)` span (same convention as `TechStepMatch`, `tech-step-matcher.ts`) and any quantity+unit resolved immediately before it (see {@link findQuantityBeforeIngredient}) — both `null` when the clause names the ingredient with no quantity ("ajouter le sel"). */
|
|
||||||
export interface IngredientMention {
|
|
||||||
ingredientId: number;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
quantity: number | null;
|
|
||||||
unitId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scans `text` (typically one technique's clause, see `tech-step-matcher.ts`'s
|
|
||||||
* `splitIntoClauses`) for every mention of a catalog ingredient, left to
|
|
||||||
* right, non-overlapping — the free-text-*scanning* counterpart to
|
|
||||||
* {@link matchIngredientName} (which resolves one *already-isolated*
|
|
||||||
* ingredient-line string to a single winner, not several mentions spread
|
|
||||||
* across a longer text). Same "longest catalog label wins" rule as
|
|
||||||
* {@link matchIngredientName}, applied at every token position in turn: once
|
|
||||||
* a mention is found, scanning resumes right after it rather than
|
|
||||||
* considering a shorter label starting inside an already-matched longer one.
|
|
||||||
*
|
|
||||||
* `locale` must match whatever `ingredientCatalog`/`unitCatalog` were loaded
|
|
||||||
* in (see {@link loadIngredientCatalog}/{@link loadUnitCatalog}) — defaults
|
|
||||||
* to `"en"`, same as every other function in this module.
|
|
||||||
*/
|
|
||||||
export function findIngredientMentions(
|
|
||||||
text: string,
|
|
||||||
ingredientCatalog: IngredientMatchEntry[],
|
|
||||||
unitCatalog: UnitMatchEntry[],
|
|
||||||
locale = "en",
|
|
||||||
): IngredientMention[] {
|
|
||||||
const tokens = tokenizeWithOffsets(text, locale);
|
|
||||||
if (tokens.length === 0) return [];
|
|
||||||
|
|
||||||
const candidates = ingredientCatalog
|
|
||||||
.map((entry) => ({
|
|
||||||
ingredientId: entry.ingredientId,
|
|
||||||
labelTokens: tokenize(entry.label, locale),
|
|
||||||
}))
|
|
||||||
.filter((entry) => entry.labelTokens.length > 0);
|
|
||||||
|
|
||||||
const mentions: IngredientMention[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < tokens.length) {
|
|
||||||
let best: { ingredientId: number; tokenCount: number } | null = null;
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const { labelTokens } = candidate;
|
|
||||||
if (i + labelTokens.length > tokens.length) continue;
|
|
||||||
const matches = labelTokens.every((word, offset) => tokens[i + offset]?.word === word);
|
|
||||||
if (!matches) continue;
|
|
||||||
if (
|
|
||||||
best === null ||
|
|
||||||
labelTokens.length > best.tokenCount ||
|
|
||||||
(labelTokens.length === best.tokenCount && candidate.ingredientId < best.ingredientId)
|
|
||||||
) {
|
|
||||||
best = { ingredientId: candidate.ingredientId, tokenCount: labelTokens.length };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (best === null) {
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const startToken = tokens[i];
|
|
||||||
const endToken = tokens[i + best.tokenCount - 1];
|
|
||||||
if (startToken === undefined || endToken === undefined) {
|
|
||||||
// Unreachable — `best` was only ever set above after confirming
|
|
||||||
// `i + labelTokens.length <= tokens.length`, so both tokens exist.
|
|
||||||
// Satisfies `noUncheckedIndexedAccess`.
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const { quantity, unitId } = findQuantityBeforeIngredient(
|
|
||||||
text,
|
|
||||||
startToken.start,
|
|
||||||
unitCatalog,
|
|
||||||
locale,
|
|
||||||
);
|
|
||||||
mentions.push({
|
|
||||||
ingredientId: best.ingredientId,
|
|
||||||
start: startToken.start,
|
|
||||||
end: endToken.end,
|
|
||||||
quantity,
|
|
||||||
unitId,
|
|
||||||
});
|
|
||||||
i += best.tokenCount;
|
|
||||||
}
|
|
||||||
return mentions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves free-text `name` (a `ParsedRecipeIngredient.name`, e.g. `"large
|
|
||||||
* diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or
|
|
||||||
* `null` if nothing matches. Among every catalog entry whose label's words
|
|
||||||
* all appear as a contiguous run in `name`, **in the same order**, the one
|
|
||||||
* with the most words wins (most specific — "chicken breast" over bare
|
|
||||||
* "chicken"); ties break on the lowest `ingredientId`, for a deterministic
|
|
||||||
* result independent of catalog order. `locale` must match whatever
|
|
||||||
* `catalog`'s labels were loaded in (see {@link loadIngredientCatalog}) —
|
|
||||||
* defaults to `"en"`.
|
|
||||||
*/
|
|
||||||
export function matchIngredientName(
|
|
||||||
name: string,
|
|
||||||
catalog: IngredientMatchEntry[],
|
|
||||||
locale = "en",
|
|
||||||
): number | null {
|
|
||||||
const nameTokens = tokenize(name, locale);
|
|
||||||
if (nameTokens.length === 0) return null;
|
|
||||||
|
|
||||||
let best: { ingredientId: number; tokenCount: number } | null = null;
|
|
||||||
for (const entry of catalog) {
|
|
||||||
const labelTokens = tokenize(entry.label, locale);
|
|
||||||
if (!containsSubsequence(nameTokens, labelTokens)) continue;
|
|
||||||
if (
|
|
||||||
best === null ||
|
|
||||||
labelTokens.length > best.tokenCount ||
|
|
||||||
(labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId)
|
|
||||||
) {
|
|
||||||
best = {
|
|
||||||
ingredientId: entry.ingredientId,
|
|
||||||
tokenCount: labelTokens.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best?.ingredientId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`, `"cuillères à
|
|
||||||
* soupe de farine"`) to the best-matching `Unit` in `catalog`, or `null` if
|
|
||||||
* nothing matches. Same ordered-contiguous-run search as
|
|
||||||
* {@link matchIngredientName}, longest match wins — **not** the
|
|
||||||
* single-first-word equality check this function used before French
|
|
||||||
* support existed: every English unit synonym happens to be one word, so
|
|
||||||
* comparing only `unitText`'s first token against each *whole* synonym
|
|
||||||
* string used to be enough, but a French unit can be genuinely multi-word
|
|
||||||
* (`"cuillère à soupe"`, see `UNIT_LABELS_FR`) — a whole multi-word phrase
|
|
||||||
* (spaces and all) can never equal a single extracted token, so that
|
|
||||||
* approach would have silently matched nothing for any French unit
|
|
||||||
* requiring more than one word. `locale` must match whatever `catalog`'s
|
|
||||||
* synonyms were loaded in (see {@link loadUnitCatalog}) — defaults to
|
|
||||||
* `"en"`.
|
|
||||||
*/
|
|
||||||
export function matchUnit(
|
|
||||||
unitText: string,
|
|
||||||
catalog: UnitMatchEntry[],
|
|
||||||
locale = "en",
|
|
||||||
): number | null {
|
|
||||||
const textTokens = tokenize(unitText, locale);
|
|
||||||
if (textTokens.length === 0) return null;
|
|
||||||
|
|
||||||
let best: { unitId: number; tokenCount: number } | null = null;
|
|
||||||
for (const entry of catalog) {
|
|
||||||
for (const synonym of entry.synonyms) {
|
|
||||||
const synonymTokens = tokenize(synonym, locale);
|
|
||||||
if (!containsSubsequence(textTokens, synonymTokens)) continue;
|
|
||||||
if (
|
|
||||||
best === null ||
|
|
||||||
synonymTokens.length > best.tokenCount ||
|
|
||||||
(synonymTokens.length === best.tokenCount && entry.unitId < best.unitId)
|
|
||||||
) {
|
|
||||||
best = { unitId: entry.unitId, tokenCount: synonymTokens.length };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best?.unitId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
|
|
||||||
export interface ExtractedQuantity {
|
|
||||||
quantity: number | null;
|
|
||||||
/** `rawText` with the leading quantity (and any separating whitespace) removed — `rawText` unchanged if none was found. */
|
|
||||||
remainder: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leading "1 1/2", "1/2", "1.5", "1,5" or "2" (optionally followed by a
|
|
||||||
// hyphenated range like "2-3", in which case only the first number counts —
|
|
||||||
// good enough for a best-effort quantity, not meant to model ranges. The
|
|
||||||
// `[.,]` decimal separator already covers French recipe text ("1,5") as-is,
|
|
||||||
// same pattern used for English ("1.5") — no locale-specific handling
|
|
||||||
// needed here, unlike tokenize/stemWord above.
|
|
||||||
const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls a leading quantity off `rawText` (e.g. `"1 1/2 cups flour"` ->
|
|
||||||
* `{ quantity: 1.5, remainder: "cups flour" }`), supporting a plain
|
|
||||||
* integer/decimal, a simple fraction (`"1/2"`), or a mixed number
|
|
||||||
* (`"1 1/2"`). `quantity: null` (remainder === rawText, trimmed) when
|
|
||||||
* `rawText` doesn't start with a recognizable number — e.g. `"salt to
|
|
||||||
* taste"`, which has none.
|
|
||||||
*/
|
|
||||||
export function extractQuantity(rawText: string): ExtractedQuantity {
|
|
||||||
const trimmed = rawText.trim();
|
|
||||||
const match = LEADING_QUANTITY_PATTERN.exec(trimmed);
|
|
||||||
if (!match) return { quantity: null, remainder: trimmed };
|
|
||||||
|
|
||||||
let quantity: number;
|
|
||||||
if (match[1] !== undefined) {
|
|
||||||
// Mixed number: "1 1/2".
|
|
||||||
quantity = Number(match[1]) + Number(match[2]) / Number(match[3]);
|
|
||||||
} else if (match[4] !== undefined) {
|
|
||||||
// Simple fraction: "1/2".
|
|
||||||
quantity = Number(match[4]) / Number(match[5]);
|
|
||||||
} else {
|
|
||||||
// Plain integer/decimal: "2" or "1.5"/"1,5" — group 6 is guaranteed
|
|
||||||
// defined here (the only remaining alternative in the pattern), `?? ""`
|
|
||||||
// is just satisfying the indexed-access type, not a real fallback.
|
|
||||||
quantity = Number((match[6] ?? "").replace(",", "."));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { quantity, remainder: trimmed.slice(match[0].length).trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-locale matching label tables {@link loadIngredientCatalog}/
|
|
||||||
* {@link loadUnitCatalog} pick from — the only two locales with any
|
|
||||||
* matching data authored yet (see `packages/shared/src/data/`). A locale
|
|
||||||
* with no entry here (anything but `"en"`/`"fr"`) falls back to empty
|
|
||||||
* tables in both loaders below — the same "no matching-language data,
|
|
||||||
* degrade to doing nothing rather than guess" behavior `tech-step-matcher.ts`
|
|
||||||
* already has for a locale with no trained mappings, not a thrown error.
|
|
||||||
*/
|
|
||||||
const INGREDIENT_LABELS_BY_LOCALE: Record<string, Record<string, string>> = {
|
|
||||||
en: INGREDIENT_LABELS_EN,
|
|
||||||
fr: INGREDIENT_LABELS_FR,
|
|
||||||
};
|
|
||||||
const INGREDIENT_LABEL_SYNONYMS_BY_LOCALE: Record<string, Record<string, string[]>> = {
|
|
||||||
en: INGREDIENT_LABEL_SYNONYMS_EN,
|
|
||||||
fr: INGREDIENT_LABEL_SYNONYMS_FR,
|
|
||||||
};
|
|
||||||
const UNIT_LABELS_BY_LOCALE: Record<string, Record<string, string[]>> = {
|
|
||||||
en: UNIT_LABELS_EN,
|
|
||||||
fr: UNIT_LABELS_FR,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s for `locale` (default `"en"`) — one entry per key with an authored label in that locale, plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`/`_FR`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no label in `locale` yet is silently skipped, never a matching target — same for every ingredient when `locale` itself has no label table at all (see {@link INGREDIENT_LABELS_BY_LOCALE}). Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
|
|
||||||
export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMatchEntry[]> {
|
|
||||||
try {
|
|
||||||
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
|
|
||||||
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
|
|
||||||
const ingredients = await prisma.ingredient.findMany({
|
|
||||||
// Placeholder rows (user free-text, `placeholder:<uuid>` key) have no
|
|
||||||
// authored label so they'd be skipped by the `label === undefined`
|
|
||||||
// check below anyway — filtered here too so an import never even
|
|
||||||
// considers resolving one raw line to another line's placeholder.
|
|
||||||
where: { isPlaceholder: false },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
const catalog: IngredientMatchEntry[] = [];
|
|
||||||
for (const ingredient of ingredients) {
|
|
||||||
const label = labels[ingredient.key];
|
|
||||||
if (label === undefined) continue;
|
|
||||||
catalog.push({ ingredientId: ingredient.id, label });
|
|
||||||
for (const synonym of synonyms[ingredient.key] ?? []) {
|
|
||||||
catalog.push({ ingredientId: ingredient.id, label: synonym });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return catalog;
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — the caller (`sources.service.ts`/`recipe-translation.ts`)
|
|
||||||
// already handles/logs failures centrally; this function just isn't
|
|
||||||
// allowed a bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s for `locale` (default `"en"`) — one entry per key with authored synonyms in that locale (see {@link UNIT_LABELS_BY_LOCALE}); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */
|
|
||||||
export async function loadUnitCatalog(locale = "en"): Promise<UnitMatchEntry[]> {
|
|
||||||
try {
|
|
||||||
const labels = UNIT_LABELS_BY_LOCALE[locale] ?? {};
|
|
||||||
const units = await prisma.unit.findMany({
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
const catalog: UnitMatchEntry[] = [];
|
|
||||||
for (const unit of units) {
|
|
||||||
const synonyms = labels[unit.key];
|
|
||||||
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
|
||||||
}
|
|
||||||
return catalog;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadIngredientCatalog()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
import { env } from "../../config/env.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin fetch wrapper around `services/tech-step-intent-service`'s HTTP
|
|
||||||
* contract (`POST /v1/process`) — the microservice
|
|
||||||
* {@link TechStepClassifierService} (`tech-step-matcher.ts`) delegates NER +
|
|
||||||
* intent classification to, in place of the `node-nlp` `NlpManager` it used
|
|
||||||
* to own directly. See that service's own README for the full contract and
|
|
||||||
* why it never touches Postgres itself — it also owns its own training
|
|
||||||
* corpus now (`training_data.py`), trained once at its own startup, so
|
|
||||||
* `apps/api` never pushes anything to it; `process()` below is this
|
|
||||||
* client's only method.
|
|
||||||
*
|
|
||||||
* Authenticated with `INTENT_SERVICE_SECRET` — the inverse direction of
|
|
||||||
* `requireInternalWorker`'s `INTERNAL_WORKER_SECRET` (this time `apps/api`
|
|
||||||
* is the caller, not the callee), but the same "one flat shared secret"
|
|
||||||
* shape.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** One candidate mention one of the service's two `PhraseMatcher`s found — offsets `[start, end)`, same convention as `String.prototype.slice`. Mirrors `EntityPayload` (Python `schemas.py`). `kind` distinguishes a technique mention (`self._matcher`, the corpus-trained one) from a utensil mention (`self._utensil_matcher`, static — see `utensil_vocabulary.py`) — `tech-step-matcher.ts` resolves each against a different catalog (`TechStep`/`Utensil`). */
|
|
||||||
export interface IntentServiceEntity {
|
|
||||||
uid: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
kind: "technique" | "utensil";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A temperature the service recognized in the text — mirrors
|
|
||||||
* `TemperaturePayload` (Python `schemas.py`), `gas_mark` camelCased to
|
|
||||||
* `gasMark`. A separate list from {@link IntentServiceEntity} (not another
|
|
||||||
* `kind`) because it carries structured fields that don't fit an entity's
|
|
||||||
* `uid`. At least one of `celsius` / `gasMark` / `qualitative` is non-null.
|
|
||||||
*/
|
|
||||||
export interface IntentServiceTemperature {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
celsius: number | null;
|
|
||||||
gasMark: number | null;
|
|
||||||
qualitative: "low" | "medium" | "high" | null;
|
|
||||||
raw: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). `temperatures` is regex-extracted and independent of the model/training (empty when none, not omitted). */
|
|
||||||
export interface IntentServiceProcessResult {
|
|
||||||
entities: IntentServiceEntity[];
|
|
||||||
intent: string | null;
|
|
||||||
score: number;
|
|
||||||
temperatures: IntentServiceTemperature[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client for `services/tech-step-intent-service` — a real class (not a
|
|
||||||
* plain object of functions) per this repo's service-style-logic
|
|
||||||
* convention, even though it holds no state of its own: it's used as the
|
|
||||||
* one shared {@link intentServiceClient} singleton below, same reasoning as
|
|
||||||
* `TechStepClassifierService` itself.
|
|
||||||
*/
|
|
||||||
export class IntentServiceClient {
|
|
||||||
/**
|
|
||||||
* Performs a JSON request against the intent service and returns the
|
|
||||||
* parsed body.
|
|
||||||
*
|
|
||||||
* @throws {Error} if the response status is not in the 2xx range, or the
|
|
||||||
* request itself fails (network error, service down) — left as a plain
|
|
||||||
* `Error` rather than a typed `HttpError`: this is an internal
|
|
||||||
* service-to-service call, not a request `apps/api`'s own HTTP layer
|
|
||||||
* needs to map to a client-facing status code (see
|
|
||||||
* `TechStepClassifierService.warmUp`'s retry in `server.ts` for how a
|
|
||||||
* failure here is actually handled).
|
|
||||||
*/
|
|
||||||
private async _request<TResponseBody>(
|
|
||||||
path: string,
|
|
||||||
init: RequestInit = {},
|
|
||||||
): Promise<TResponseBody> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${env.INTENT_SERVICE_BASE_URL}${path}`, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Intent-Service-Secret": env.INTENT_SERVICE_SECRET,
|
|
||||||
...init.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text().catch(() => "");
|
|
||||||
throw new Error(`${init.method ?? "GET"} ${path} failed: ${response.status} ${body}`);
|
|
||||||
}
|
|
||||||
return (await response.json()) as TResponseBody;
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — every caller (`TechStepClassifierService`) already
|
|
||||||
// wraps its own `await`s per the repo's try/catch convention; this is
|
|
||||||
// just where the `await` itself has to sit inside one.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Equivalent to the old `NlpManager.process(locale, text)` — returns every
|
|
||||||
* candidate technique mention (NER) plus the intent classifier's verdict
|
|
||||||
* for `text` as a whole, whether `text` is a full step description or a
|
|
||||||
* single clause `TechStepClassifierService` already cut out of one (this
|
|
||||||
* service doesn't know or care which, exactly like `NlpManager` before
|
|
||||||
* it).
|
|
||||||
*/
|
|
||||||
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
|
|
||||||
try {
|
|
||||||
// The wire shape uses `gas_mark` (Python field name); everything else
|
|
||||||
// is already camelCase / single-word. Map that one key at the
|
|
||||||
// boundary so the rest of `apps/api` only ever sees `gasMark`.
|
|
||||||
const wire = await this._request<
|
|
||||||
Omit<IntentServiceProcessResult, "temperatures"> & {
|
|
||||||
temperatures: (Omit<IntentServiceTemperature, "gasMark"> & { gas_mark: number | null })[];
|
|
||||||
}
|
|
||||||
>("/v1/process", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ locale, text }),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
entities: wire.entities,
|
|
||||||
intent: wire.intent,
|
|
||||||
score: wire.score,
|
|
||||||
temperatures: (wire.temperatures ?? []).map(({ gas_mark, ...rest }) => ({
|
|
||||||
...rest,
|
|
||||||
gasMark: gas_mark,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Single shared instance — stateless, no reason for more than one (same reasoning as `techStepClassifier`/`prisma`). */
|
|
||||||
export const intentServiceClient = new IntentServiceClient();
|
|
||||||
|
|
@ -1,331 +0,0 @@
|
||||||
import type { UnitType } from "@batch-cooking/shared";
|
|
||||||
import type {
|
|
||||||
ParsedRecipe,
|
|
||||||
ParsedRecipeIngredient,
|
|
||||||
ParsedRecipeStep,
|
|
||||||
} from "../recipe-sources/recipe-source-adapter.js";
|
|
||||||
import {
|
|
||||||
extractQuantity,
|
|
||||||
type IngredientMatchEntry,
|
|
||||||
loadIngredientCatalog,
|
|
||||||
loadUnitCatalog,
|
|
||||||
matchIngredientName,
|
|
||||||
matchUnit,
|
|
||||||
type UnitMatchEntry,
|
|
||||||
} from "./ingredient-matcher.js";
|
|
||||||
import { techStepClassifier } from "./tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "Traduction en étapes" stage of the import pipeline described in
|
|
||||||
* specs/batch-cooking-architecture.md (Import depuis source → **Traduction
|
|
||||||
* en étapes** → Sauvegarde) — takes a source-agnostic {@link ParsedRecipe}
|
|
||||||
* (recipe-source-adapter.ts's `parse()` output) and declares each step's
|
|
||||||
* technique sequence, the same `techStepIds: number[]` shape
|
|
||||||
* `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
|
|
||||||
*
|
|
||||||
* Also resolves ingredients — matching each free-text `ParsedRecipeIngredient`
|
|
||||||
* line against our `Ingredient`/`Unit` catalogs (`ingredient-matcher.ts`),
|
|
||||||
* the same "free source text -> our catalog id" idea as tech-step
|
|
||||||
* detection, just for ingredients/units/quantities instead of technique
|
|
||||||
* verbs. Like tech-step matching, this doesn't turn the result into a
|
|
||||||
* saveable `Recipe` either (no `dietIds`/`visibility`/author, a source
|
|
||||||
* can't know those) — this is one step of the pipeline, not the whole
|
|
||||||
* thing.
|
|
||||||
*
|
|
||||||
* `translateRecipeIngredients` stays pure (takes its matching data as plain
|
|
||||||
* arguments, same convention `matchIngredientName` itself has) so it's
|
|
||||||
* unit-testable without a database. `translateRecipeSteps` no longer is —
|
|
||||||
* technique detection now goes through `techStepClassifier`'s trained
|
|
||||||
* model (`tech-step-matcher.ts`), which needs an async call — but is still
|
|
||||||
* exported separately from `translateRecipe` for callers/tests that only
|
|
||||||
* care about step translation, not ingredients too.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */
|
|
||||||
export interface TranslatedRecipeStep extends ParsedRecipeStep {
|
|
||||||
/** Ordered sequence of detected `TechStep` ids (see `TechStepClassifierService.matchTechSteps`) — empty if this step doesn't mention any known technique. */
|
|
||||||
techStepIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A {@link ParsedRecipeIngredient}, after ingredient/unit matching — `quantity` is filled in from `rawText` when the source itself left it `null` (see `extractQuantity`); `ingredientId`/`unitId` are `null` when nothing in the catalog matched. */
|
|
||||||
export interface TranslatedRecipeIngredient extends ParsedRecipeIngredient {
|
|
||||||
ingredientId: number | null;
|
|
||||||
unitId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A {@link ParsedRecipe} whose `steps`/`ingredients` have been translated — everything else (name, portions, …) passes through unchanged. */
|
|
||||||
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps" | "ingredients"> {
|
|
||||||
steps: TranslatedRecipeStep[];
|
|
||||||
ingredients: TranslatedRecipeIngredient[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Declares each of `recipe`'s steps' technique sequence for `locale`,
|
|
||||||
* leaving everything else about the recipe untouched — including
|
|
||||||
* ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
|
|
||||||
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
|
|
||||||
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
|
|
||||||
* separate the same way tech-step and ingredient matching are two
|
|
||||||
* independent concerns everywhere else in this module. Async — technique
|
|
||||||
* detection now runs against `techStepClassifier`'s trained model rather
|
|
||||||
* than a caller-supplied mapping list (see `tech-step-matcher.ts`), so this
|
|
||||||
* can no longer stay a plain synchronous function the way it used to.
|
|
||||||
*/
|
|
||||||
export async function translateRecipeSteps(
|
|
||||||
recipe: ParsedRecipe,
|
|
||||||
locale: string,
|
|
||||||
): Promise<TranslatedRecipe> {
|
|
||||||
try {
|
|
||||||
const steps = await Promise.all(
|
|
||||||
recipe.steps.map(async (step) => ({
|
|
||||||
...step,
|
|
||||||
techStepIds: await techStepClassifier.matchTechSteps(step.description, locale),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...recipe,
|
|
||||||
ingredients: recipe.ingredients.map((ingredient) => ({
|
|
||||||
...ingredient,
|
|
||||||
ingredientId: null,
|
|
||||||
unitId: null,
|
|
||||||
})),
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — the caller (`sources.service.ts`) already
|
|
||||||
// handles/logs failures centrally; this function just isn't allowed a
|
|
||||||
// bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locale-specific label {@link matchUnit} is fed when a quantity was found
|
|
||||||
* but no unit word was — see the `unitId` fallback below. Each is the
|
|
||||||
* catalog's generic "counted, no further unit" entry (`Unit.key` `"piece"`)
|
|
||||||
* in that locale's own label table (`UNIT_LABELS_EN`/`UNIT_LABELS_FR`,
|
|
||||||
* `packages/shared`). A locale with neither entry (anything but
|
|
||||||
* `"en"`/`"fr"`) falls back to the English spelling — harmless, since
|
|
||||||
* `unitCatalog` itself is already empty for an unsupported locale (see
|
|
||||||
* `loadUnitCatalog`), so this fallback lookup finds nothing either way.
|
|
||||||
*/
|
|
||||||
const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
|
|
||||||
en: "piece",
|
|
||||||
fr: "unité",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
|
|
||||||
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
|
|
||||||
* Pure — testable with hand-built catalogs, no database involved (see
|
|
||||||
* `translateRecipe` for the DB-backed loader). `quantity` falls back to
|
|
||||||
* `extractQuantity(rawText)` only when the source itself left it `null`;
|
|
||||||
* same for `unit` falling back to `extractQuantity`'s `remainder` before
|
|
||||||
* being matched against `unitCatalog` — a source that already states a
|
|
||||||
* clean unit/quantity is trusted over re-deriving it from `rawText`.
|
|
||||||
*
|
|
||||||
* When a quantity was found but nothing in the remaining text matched a
|
|
||||||
* unit (e.g. `"4 Egg Yolks"` — quantity `4`, remainder `"Egg Yolks"`, no
|
|
||||||
* unit word anywhere in it), `unitId` falls back to the catalog's generic
|
|
||||||
* `piece` unit rather than staying `null`: a bare count with no explicit
|
|
||||||
* unit word is overwhelmingly "N of them" (eggs, onions, cloves not
|
|
||||||
* spelled out as "clove") in practice, not a genuinely missing unit — see
|
|
||||||
* issue #53, where this previously left the import review form's submit
|
|
||||||
* button disabled with no indication why on almost any recipe with a
|
|
||||||
* whole-item ingredient. No fallback when `quantity` itself is `null`
|
|
||||||
* (e.g. `"To taste"`) — there's nothing to count, so nothing to default.
|
|
||||||
*
|
|
||||||
* `locale` (default `"en"`, matching every pre-existing caller) must agree
|
|
||||||
* with whichever locale `ingredientCatalog`/`unitCatalog` were loaded in
|
|
||||||
* (see `loadIngredientCatalog`/`loadUnitCatalog`) — it's threaded through to
|
|
||||||
* `matchIngredientName`/`matchUnit` for stemming, and picks the right
|
|
||||||
* spelling of the "piece" fallback below.
|
|
||||||
*/
|
|
||||||
export function translateRecipeIngredients(
|
|
||||||
ingredients: ParsedRecipeIngredient[],
|
|
||||||
ingredientCatalog: IngredientMatchEntry[],
|
|
||||||
unitCatalog: UnitMatchEntry[],
|
|
||||||
locale = "en",
|
|
||||||
): TranslatedRecipeIngredient[] {
|
|
||||||
const fallbackCountUnitLabel = FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE[locale] ?? "piece";
|
|
||||||
return ingredients.map((ingredient) => {
|
|
||||||
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog, locale);
|
|
||||||
const extracted = extractQuantity(ingredient.rawText);
|
|
||||||
const quantity = ingredient.quantity ?? extracted.quantity;
|
|
||||||
const unitText = ingredient.unit ?? extracted.remainder;
|
|
||||||
const unitId =
|
|
||||||
matchUnit(unitText, unitCatalog, locale) ??
|
|
||||||
(quantity !== null ? matchUnit(fallbackCountUnitLabel, unitCatalog, locale) : null);
|
|
||||||
return { ...ingredient, quantity, ingredientId, unitId };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What {@link mergeDuplicateIngredients} needs to know about a `Unit` to combine two of them — the `type`/`toBaseFactor` slice of `UnitView` (`packages/shared`). */
|
|
||||||
export interface UnitConversionEntry {
|
|
||||||
id: number;
|
|
||||||
type: UnitType;
|
|
||||||
toBaseFactor: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combines `a`/`b` — two lines already confirmed to resolve to the same
|
|
||||||
* ingredient — into one, summing their quantities, or returns `null` when
|
|
||||||
* that can't be done safely. `null` (quantity or unit missing on either
|
|
||||||
* side, unit not found in `unitById`, mismatched `UnitType`, or either side
|
|
||||||
* a `COUNT` unit) means "don't merge", not "error" — see
|
|
||||||
* {@link mergeDuplicateIngredients}.
|
|
||||||
*
|
|
||||||
* Same unit on both sides sums directly. Different units of the same
|
|
||||||
* measurable *type* (`MASS`/`VOLUME`) convert `b`'s quantity into `a`'s
|
|
||||||
* unit via `toBaseFactor` first — the groundwork that field's own doc
|
|
||||||
* comment (`UnitView`, `packages/shared`) already anticipated ("a future
|
|
||||||
* conversion feature ... summing '500g' + '0.5kg'"). `COUNT` units are
|
|
||||||
* never converted against each other even when `toBaseFactor` matches — a
|
|
||||||
* "pincée" isn't a fixed fraction of a "gousse" (same doc comment) — so two
|
|
||||||
* different `COUNT` units for the same ingredient are left unmerged.
|
|
||||||
* Rounded to 2 decimal places (`RecipeIngredient.quantity` is
|
|
||||||
* `Decimal(10, 2)`, schema.prisma) to avoid floating-point noise from the
|
|
||||||
* conversion.
|
|
||||||
*/
|
|
||||||
function combineIngredientLines(
|
|
||||||
a: TranslatedRecipeIngredient,
|
|
||||||
b: TranslatedRecipeIngredient,
|
|
||||||
unitById: Map<number, UnitConversionEntry>,
|
|
||||||
): TranslatedRecipeIngredient | null {
|
|
||||||
if (a.quantity === null || b.quantity === null || a.unitId === null || b.unitId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (a.unitId === b.unitId) {
|
|
||||||
return {
|
|
||||||
...a,
|
|
||||||
quantity: a.quantity + b.quantity,
|
|
||||||
rawText: `${a.rawText} + ${b.rawText}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const unitA = unitById.get(a.unitId);
|
|
||||||
const unitB = unitById.get(b.unitId);
|
|
||||||
if (!unitA || !unitB) return null;
|
|
||||||
if (unitA.type !== unitB.type || unitA.type === "COUNT") return null;
|
|
||||||
|
|
||||||
const combinedInBaseUnit = a.quantity * unitA.toBaseFactor + b.quantity * unitB.toBaseFactor;
|
|
||||||
const quantity = Math.round((combinedInBaseUnit / unitA.toBaseFactor) * 100) / 100;
|
|
||||||
return { ...a, quantity, rawText: `${a.rawText} + ${b.rawText}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Folds `ingredients` down to one line per resolved `ingredientId`,
|
|
||||||
* concatenating (summing the quantity of) every duplicate into the first
|
|
||||||
* line it matches — see issue #53's follow-up: two raw source lines (e.g.
|
|
||||||
* TheMealDB's "Egg Yolks"/"Eggs", or "100g Sugar" used in two different
|
|
||||||
* steps) can independently resolve to the same catalog `Ingredient`, and
|
|
||||||
* `RecipeIngredient`'s primary key (`recipeId`, `ingredientId`) only
|
|
||||||
* allows one row per ingredient per recipe — the review form used to
|
|
||||||
* either crash on submit (before `createRecipeSchema` rejected it) or
|
|
||||||
* require the person to manually delete every extra line by hand.
|
|
||||||
*
|
|
||||||
* Unresolved lines (`ingredientId: null`) are never merged with one
|
|
||||||
* another or with anything else — nothing reliable to key them on. Two
|
|
||||||
* lines that resolve to the same ingredient but can't be combined safely
|
|
||||||
* (see {@link combineIngredientLines} — mismatched quantity/unit, or
|
|
||||||
* genuinely incompatible units) are left as separate, still-duplicate
|
|
||||||
* lines: `createRecipeSchema` still rejects the result, and
|
|
||||||
* `RecipeImportForm` still highlights them, same safety net as before this
|
|
||||||
* merge step existed — merging never *invents* a number it isn't confident
|
|
||||||
* in.
|
|
||||||
*
|
|
||||||
* Pure — testable with a hand-built `unitCatalog`, no database involved.
|
|
||||||
* Order-preserving: a merged line keeps its first occurrence's position.
|
|
||||||
*/
|
|
||||||
export function mergeDuplicateIngredients(
|
|
||||||
ingredients: TranslatedRecipeIngredient[],
|
|
||||||
unitCatalog: UnitConversionEntry[],
|
|
||||||
): TranslatedRecipeIngredient[] {
|
|
||||||
const unitById = new Map(unitCatalog.map((unit) => [unit.id, unit]));
|
|
||||||
const merged: TranslatedRecipeIngredient[] = [];
|
|
||||||
const mergedIndexByIngredientId = new Map<number, number>();
|
|
||||||
|
|
||||||
for (const line of ingredients) {
|
|
||||||
const existingIndex =
|
|
||||||
line.ingredientId !== null ? mergedIndexByIngredientId.get(line.ingredientId) : undefined;
|
|
||||||
|
|
||||||
const existingLine = existingIndex !== undefined ? merged[existingIndex] : undefined;
|
|
||||||
if (existingIndex === undefined || existingLine === undefined) {
|
|
||||||
if (line.ingredientId !== null) {
|
|
||||||
mergedIndexByIngredientId.set(line.ingredientId, merged.length);
|
|
||||||
}
|
|
||||||
merged.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const combined = combineIngredientLines(existingLine, line, unitById);
|
|
||||||
if (combined === null) {
|
|
||||||
merged.push(line);
|
|
||||||
} else {
|
|
||||||
merged[existingIndex] = combined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience wrapper around {@link translateRecipeSteps}/
|
|
||||||
* {@link translateRecipeIngredients} that loads every catalog itself —
|
|
||||||
* what a caller reaches for when translating a single recipe on its own
|
|
||||||
* (e.g. the eventual "import this one recipe" endpoint). A caller
|
|
||||||
* translating many recipes at once should load the catalogs once and reuse
|
|
||||||
* them across calls instead, the same "don't requery per item" reasoning
|
|
||||||
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` already follow for
|
|
||||||
* manually-authored recipes.
|
|
||||||
*
|
|
||||||
* No user- or recipe-level language preference exists anywhere in the app
|
|
||||||
* yet (see `tech-step-matcher.ts`'s `TechStepClassifierService`) — callers
|
|
||||||
* pass a locale explicitly rather than this module guessing one. Note that
|
|
||||||
* an English-language source (e.g. TheMealDB) translated against `"fr"`
|
|
||||||
* will currently get an empty (or nonsensical) `techStepIds` sequence on
|
|
||||||
* every step — the classifier is trained per-locale, so calling it with a
|
|
||||||
* locale that doesn't match the actual text's language doesn't degrade
|
|
||||||
* gracefully, it just gets things wrong.
|
|
||||||
*
|
|
||||||
* Ingredient/unit matching has data for `"en"` and `"fr"` today
|
|
||||||
* (`INGREDIENT_LABELS_EN`/`_FR`, `UNIT_LABELS_EN`/`_FR`, `packages/shared`)
|
|
||||||
* — `loadIngredientCatalog(locale)`/`loadUnitCatalog(locale)` are always
|
|
||||||
* called, never specially skipped for a particular locale: a locale with no
|
|
||||||
* label table of its own (anything but `"en"`/`"fr"`) just gets back empty
|
|
||||||
* catalogs from those two loaders, and `translateRecipeIngredients` over an
|
|
||||||
* empty catalog naturally leaves every ingredient's `ingredientId`/`unitId`
|
|
||||||
* at the neutral `null` `translateRecipeSteps` already stubs in — the same
|
|
||||||
* "no matching-language data" degradation tech-step matching already has
|
|
||||||
* for a locale with no mappings, just arrived at by *not* special-casing
|
|
||||||
* which locales are "supported" here at all (that's `INGREDIENT_LABELS_BY_LOCALE`/
|
|
||||||
* `UNIT_LABELS_BY_LOCALE`'s job, in `ingredient-matcher.ts` — this function
|
|
||||||
* doesn't need its own copy of that list to stay in sync with).
|
|
||||||
*/
|
|
||||||
export async function translateRecipe(
|
|
||||||
recipe: ParsedRecipe,
|
|
||||||
locale: string,
|
|
||||||
): Promise<TranslatedRecipe> {
|
|
||||||
try {
|
|
||||||
const translated = await translateRecipeSteps(recipe, locale);
|
|
||||||
|
|
||||||
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
|
||||||
loadIngredientCatalog(locale),
|
|
||||||
loadUnitCatalog(locale),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
...translated,
|
|
||||||
ingredients: translateRecipeIngredients(
|
|
||||||
recipe.ingredients,
|
|
||||||
ingredientCatalog,
|
|
||||||
unitCatalog,
|
|
||||||
locale,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — the caller (`sources.service.ts`) already
|
|
||||||
// handles/logs failures centrally; this function just isn't allowed a
|
|
||||||
// bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
/**
|
|
||||||
* Hand-labeled evaluation set for {@link techStepClassifier} — what
|
|
||||||
* `tech-step-eval.test.ts` runs the real classifier against to compute
|
|
||||||
* precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any
|
|
||||||
* future change to `services/tech-step-intent-service`'s `training_data.py`
|
|
||||||
* must clear (see that module's own doc comment).
|
|
||||||
*
|
|
||||||
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
|
|
||||||
* verbatim — scoring the classifier against the exact sentences it was
|
|
||||||
* trained on would measure memorization, not generalization. Every
|
|
||||||
* description below is original phrasing; where a case still needs to name
|
|
||||||
* a technique's own verb to be labeled with confidence (most of them, see
|
|
||||||
* this file's own limits below), it's at least a different sentence shape
|
|
||||||
* than anything in the training corpus.
|
|
||||||
*
|
|
||||||
* `expectedKeys` is a multiset in reading order (see
|
|
||||||
* `TechStepEvalOutcome`'s doc comment in `tech-step-evaluator.ts` for why
|
|
||||||
* order isn't scored but repetition is) of `TechStep.key`s — resolved to
|
|
||||||
* real DB ids and back by `tech-step-eval-runner.ts`, this file only ever
|
|
||||||
* deals in stable keys so it doesn't need DB access to author or read.
|
|
||||||
*
|
|
||||||
* Known limit of this dataset, confirmed against a real run (see
|
|
||||||
* `MIN_OVERALL_F1`'s own doc comment, `tech-step-eval-runner.ts`): most
|
|
||||||
* cases anchor on a technique's own registered synonym, but `_classifyClause`
|
|
||||||
* only falls back to that anchor when the intent classifier's own score is
|
|
||||||
* *below* `CONFIDENCE_THRESHOLD` — a confidently *wrong* whole-clause
|
|
||||||
* classification (e.g. "Blanchissez les haricots verts..." scoring
|
|
||||||
* confidently as `peel` despite the correct `blanch` anchor) overrides the
|
|
||||||
* anchor just as readily as a confidently *right* one does, so this
|
|
||||||
* dataset genuinely does measure real classifier failures, not just a
|
|
||||||
* synthetic floor. A handful of such real mismatches are expected and
|
|
||||||
* intentionally left uncorrected here (see `MIN_OVERALL_F1`'s doc comment)
|
|
||||||
* — fixing the classifier's actual behavior on them is corpus work for a
|
|
||||||
* future change, not something to hide by loosening this dataset's own
|
|
||||||
* expectations to match whatever it currently outputs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TechStepEvalCase {
|
|
||||||
description: string;
|
|
||||||
locale: "fr" | "en";
|
|
||||||
expectedKeys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
|
|
||||||
// --- One straightforward case per technique (fr), covering all 26 ---
|
|
||||||
{
|
|
||||||
description: "Faites cuire les pâtes al dente dans une grande casserole d'eau bien salée.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["cook"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites bouillir l'eau dans une grande casserole avant d'y plonger les pâtes.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["boil"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Plongez les beignets dans l'huile très chaude pour les faire frire.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["fry"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites fondre le chocolat noir au bain-marie en remuant.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["melt"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Déglacez la casserole avec un trait de vinaigre balsamique.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["deglaze"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Laissez frémir la sauce tomate vingt minutes à couvert.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["simmer"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites rôtir la volaille entière sur la broche du four.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["roast"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites griller les brochettes de poulet quelques minutes de chaque côté.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["grill"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites sauter les champignons à feu vif dans une poêle très chaude.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["panFry"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Blanchissez les haricots verts trois minutes avant de les refroidir.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["blanch"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Laissez mariner les brochettes de poulet deux heures au frais.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["marinate"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Hachez grossièrement le persil frais avant de le parsemer.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["chop"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Épluchez les carottes avant de les couper en rondelles.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["peel"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Émincez finement l'échalote pour la vinaigrette.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["mince"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Mélangez la farine, le sucre et les œufs dans un grand saladier.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["mix"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Fouettez énergiquement la crème jusqu'à ce qu'elle épaississe.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["whisk"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Incorporez délicatement la farine tamisée à la préparation.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["foldIn"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Réservez la pâte au réfrigérateur pendant que vous préparez la garniture.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["setAside"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Assaisonnez le poisson avec du sel, du poivre et un filet de citron.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["season"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Égouttez soigneusement le riz dans une passoire fine.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["drain"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Faites dorer les morceaux de veau sur toutes leurs faces avant de mouiller avec le bouillon.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["brown"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Laissez reposer la viande dix minutes avant de la trancher.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["rest"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Préchauffez le four à 200 degrés avant d'y glisser le gratin.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["preheat"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Enfournez la tarte pendant trente-cinq minutes jusqu'à ce qu'elle soit dorée.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["bake"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Dressez harmonieusement les légumes autour de la pièce de viande.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["plate"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Nappez le fond du moule d'une fine couche de caramel.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["coat"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- English coverage (same technique verbs, distinct sentences) ---
|
|
||||||
{
|
|
||||||
description: "Simmer the stock gently for forty minutes, skimming occasionally.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["simmer"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Peel the potatoes and rinse them under cold water.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["peel"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Whisk the eggs with a pinch of salt until frothy.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["whisk"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Season the soup generously with black pepper before serving.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["season"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Make sure the chicken is cooked through before serving.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["cook"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Multi-technique sentences, in reading order ---
|
|
||||||
{
|
|
||||||
description: "Préchauffez le four, puis faites rôtir le poulet pendant une heure.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["preheat", "roast"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites revenir les oignons, puis déglacez la poêle avec du vin blanc.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["brown", "deglaze"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Faites cuire les légumes à la vapeur, puis assaisonnez-les avec des herbes fraîches.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["cook", "season"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Émincez l'oignon, faites-le suer, puis mouillez avec le bouillon et laissez mijoter.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["mince", "simmer"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- No technique mentioned at all ---
|
|
||||||
{
|
|
||||||
description: "Répartissez les convives autour de la table avant de commencer le repas.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Rangez les couverts propres dans le tiroir de la cuisine.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Take the dishes and glasses out of the cupboard.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Documented false-positive traps, re-verified with fresh wording ---
|
|
||||||
// `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not
|
|
||||||
// bare "brown" — precisely so this doesn't false-positive (see that
|
|
||||||
// entry's own comment in training_data.py).
|
|
||||||
{
|
|
||||||
description: "This recipe calls for two tablespoons of brown sugar.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
// `rest`'s EN synonyms are anchored phrases ("let it rest"/"resting
|
|
||||||
// for"...), not bare "rest" — so a sentence using the word in its
|
|
||||||
// "remainder" sense must not anchor `rest` at all.
|
|
||||||
{
|
|
||||||
description: "There is no time to rest before the guests arrive.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { TECH_STEP_EVAL_DATASET } from "./tech-step-eval-dataset.js";
|
|
||||||
import {
|
|
||||||
computeTechStepMetrics,
|
|
||||||
type TechStepEvalOutcome,
|
|
||||||
type TechStepEvalResult,
|
|
||||||
} from "./tech-step-evaluator.js";
|
|
||||||
import { techStepClassifier } from "./tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression floor (not a target) both `runTechStepEvalSuite`'s consumers
|
|
||||||
* gate on — exported from here (not defined separately in each consumer)
|
|
||||||
* so the CI regression gate and `scripts/retrain-tech-steps.ts`'s
|
|
||||||
* pre-backfill gate can never silently drift to different thresholds.
|
|
||||||
*
|
|
||||||
* Calibrated against a real run: the classifier trained on the corpus as
|
|
||||||
* of this constant's introduction scored **0.815** aggregate F1
|
|
||||||
* (33 TP / 9 FP / 6 FN) against `TECH_STEP_EVAL_DATASET` — `0.8` leaves a
|
|
||||||
* small margin below that for run-to-run noise while still catching a
|
|
||||||
* real regression (not a floor picked blind before ever running this
|
|
||||||
* suite — see this feature's plan document for that earlier state). The
|
|
||||||
* mismatches this run surfaced (e.g. "Blanchissez les haricots verts..."
|
|
||||||
* misclassified as `peel`, a handful of anchor-less sentences expected to
|
|
||||||
* match nothing instead scoring confidently as some technique) are real,
|
|
||||||
* known classifier weaknesses — evidence this harness is doing its job,
|
|
||||||
* not something to quietly paper over by loosening the dataset's own
|
|
||||||
* expectations. Improving them is corpus work for a future change, gated
|
|
||||||
* by this same suite.
|
|
||||||
*/
|
|
||||||
export const MIN_OVERALL_F1 = 0.8;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs {@link TECH_STEP_EVAL_DATASET} against the real, currently-trained
|
|
||||||
* `techStepClassifier` and returns the aggregate/per-technique metrics
|
|
||||||
* (`computeTechStepMetrics`, `tech-step-evaluator.ts`) — the one place this
|
|
||||||
* DB-touching "resolve ids to keys, then score" logic lives, shared by
|
|
||||||
* `test/recipe-matching/tech-step-eval.test.ts` (this feature's CI
|
|
||||||
* regression gate) and `scripts/retrain-tech-steps.ts` (the same gate, run
|
|
||||||
* by a maintainer before applying a corpus change). Kept out of
|
|
||||||
* `tech-step-evaluator.ts` itself, which is deliberately pure/DB-free (see
|
|
||||||
* that module's own doc comment) so its scoring logic stays unit-testable
|
|
||||||
* without a database.
|
|
||||||
*/
|
|
||||||
export async function runTechStepEvalSuite(): Promise<TechStepEvalResult> {
|
|
||||||
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
|
||||||
const keyById = new Map(techSteps.map((techStep) => [techStep.id, techStep.key]));
|
|
||||||
|
|
||||||
const outcomes: TechStepEvalOutcome[] = [];
|
|
||||||
for (const evalCase of TECH_STEP_EVAL_DATASET) {
|
|
||||||
const techStepIds = await techStepClassifier.matchTechSteps(
|
|
||||||
evalCase.description,
|
|
||||||
evalCase.locale,
|
|
||||||
);
|
|
||||||
const actualKeys = techStepIds.map((id) => {
|
|
||||||
const key = keyById.get(id);
|
|
||||||
// A `techStepId` the classifier resolved that isn't in the seeded
|
|
||||||
// catalog would be a bug in the classifier or the seed data, not
|
|
||||||
// this dataset — fail loudly rather than silently dropping it (see
|
|
||||||
// `_train`'s own comment in `tech-step-matcher.ts` on the
|
|
||||||
// equivalent, deliberately silent `undefined` case it has to
|
|
||||||
// tolerate for a different reason).
|
|
||||||
if (key === undefined) {
|
|
||||||
throw new Error(`Unknown TechStep id ${id} returned for "${evalCase.description}"`);
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
});
|
|
||||||
outcomes.push({ expectedKeys: evalCase.expectedKeys, actualKeys });
|
|
||||||
}
|
|
||||||
|
|
||||||
return computeTechStepMetrics(outcomes);
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
/**
|
|
||||||
* Precision/recall/F1 for {@link techStepClassifier}'s output against a
|
|
||||||
* hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective
|
|
||||||
* counterpart to the "inspected by eye" verdict every corpus change used to
|
|
||||||
* get before this module existed. Every future edit to
|
|
||||||
* `services/tech-step-intent-service`'s `training_data.py` (including the
|
|
||||||
* LLM-assisted suggestions the worker in `services/tech-step-llm-worker`
|
|
||||||
* proposes) is expected to run
|
|
||||||
* through `tech-step-eval.test.ts`'s regression gate, which calls
|
|
||||||
* {@link computeTechStepMetrics} — a corpus change that raises recall on one
|
|
||||||
* technique but silently tanks another's precision should fail loudly here,
|
|
||||||
* not get merged on the strength of a few manually-checked examples.
|
|
||||||
*
|
|
||||||
* Pure (no DB/model access) so it's unit-testable on its own — same
|
|
||||||
* convention as `tech-step-matcher.ts`'s own pure helpers (`normalizeText`,
|
|
||||||
* `splitIntoClauses`): this module only ever receives already-resolved
|
|
||||||
* `TechStep.key` strings, never DB ids or a live classifier instance, so it
|
|
||||||
* has nothing to mock to test.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** True/false-positive/negative counts for one technique (or the aggregate across all of them), plus the precision/recall/F1 derived from them. */
|
|
||||||
export interface TechStepMetrics {
|
|
||||||
truePositives: number;
|
|
||||||
falsePositives: number;
|
|
||||||
falseNegatives: number;
|
|
||||||
precision: number;
|
|
||||||
recall: number;
|
|
||||||
f1: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One evaluation case's outcome — what {@link TechStepEvalCase.expectedKeys}
|
|
||||||
* said should be found, against what the classifier actually returned for
|
|
||||||
* that case (already mapped from `TechStepMatch.techStepId` back to
|
|
||||||
* `TechStep.key`, see `tech-step-eval.test.ts`).
|
|
||||||
*
|
|
||||||
* Both lists are *multisets*, not sets — a description that names the same
|
|
||||||
* technique twice (rare, but not impossible: "faire cuire, puis... remettre
|
|
||||||
* à cuire") is expected to produce two matches, and comparing as plain sets
|
|
||||||
* would silently treat a classifier that only found one of them as a
|
|
||||||
* perfect match.
|
|
||||||
*/
|
|
||||||
export interface TechStepEvalOutcome {
|
|
||||||
expectedKeys: string[];
|
|
||||||
actualKeys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@link computeTechStepMetrics}'s result — the aggregate across every case, plus a breakdown per technique so a regression hiding behind a healthy overall F1 (one technique's recall collapsing, offset by another's improving) is still visible. */
|
|
||||||
export interface TechStepEvalResult {
|
|
||||||
overall: TechStepMetrics;
|
|
||||||
byKey: Record<string, TechStepMetrics>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawCounts {
|
|
||||||
tp: number;
|
|
||||||
fp: number;
|
|
||||||
fn: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyCounts(): RawCounts {
|
|
||||||
return { tp: 0, fp: 0, fn: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Counts occurrences of each key in a multiset, e.g. `["cook", "cook", "bake"]` -> `{cook: 2, bake: 1}`. */
|
|
||||||
function countByKey(keys: string[]): Map<string, number> {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const key of keys) {
|
|
||||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard vacuous-truth convention for the `0/0` cases: precision defaults
|
|
||||||
* to `1` when nothing was predicted for a key (`tp + fp === 0` — no false
|
|
||||||
* accusation to be precise about), recall defaults to `1` when nothing was
|
|
||||||
* expected (`tp + fn === 0` — nothing to have missed). Neither inflates F1
|
|
||||||
* on its own: a technique the classifier fully misses still has `recall =
|
|
||||||
* 0` (there *were* expected occurrences, just none matched), which is what
|
|
||||||
* pulls F1 down to `0` for that case regardless of precision's vacuous `1`.
|
|
||||||
*/
|
|
||||||
function toMetrics(counts: RawCounts): TechStepMetrics {
|
|
||||||
const { tp, fp, fn } = counts;
|
|
||||||
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
|
|
||||||
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
|
|
||||||
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
|
|
||||||
return { truePositives: tp, falsePositives: fp, falseNegatives: fn, precision, recall, f1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregates every {@link TechStepEvalOutcome} into one overall
|
|
||||||
* precision/recall/F1 plus a per-technique breakdown.
|
|
||||||
*
|
|
||||||
* Counted key by key, multiset-style, per outcome: for a given technique,
|
|
||||||
* `min(expectedCount, actualCount)` true positives, any actual occurrences
|
|
||||||
* beyond that are false positives, any expected occurrences short of that
|
|
||||||
* are false negatives — generalizes the usual set-based TP/FP/FN definition
|
|
||||||
* to handle a technique mentioned (or matched) more than once in the same
|
|
||||||
* step without over- or under-counting it.
|
|
||||||
*/
|
|
||||||
export function computeTechStepMetrics(outcomes: TechStepEvalOutcome[]): TechStepEvalResult {
|
|
||||||
const overallCounts = emptyCounts();
|
|
||||||
const countsByKey = new Map<string, RawCounts>();
|
|
||||||
|
|
||||||
for (const outcome of outcomes) {
|
|
||||||
const expectedCounts = countByKey(outcome.expectedKeys);
|
|
||||||
const actualCounts = countByKey(outcome.actualKeys);
|
|
||||||
const allKeys = new Set([...expectedCounts.keys(), ...actualCounts.keys()]);
|
|
||||||
|
|
||||||
for (const key of allKeys) {
|
|
||||||
const expected = expectedCounts.get(key) ?? 0;
|
|
||||||
const actual = actualCounts.get(key) ?? 0;
|
|
||||||
const tp = Math.min(expected, actual);
|
|
||||||
const fp = Math.max(0, actual - expected);
|
|
||||||
const fn = Math.max(0, expected - actual);
|
|
||||||
|
|
||||||
overallCounts.tp += tp;
|
|
||||||
overallCounts.fp += fp;
|
|
||||||
overallCounts.fn += fn;
|
|
||||||
|
|
||||||
const keyCounts = countsByKey.get(key) ?? emptyCounts();
|
|
||||||
keyCounts.tp += tp;
|
|
||||||
keyCounts.fp += fp;
|
|
||||||
keyCounts.fn += fn;
|
|
||||||
countsByKey.set(key, keyCounts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const byKey: Record<string, TechStepMetrics> = {};
|
|
||||||
for (const [key, counts] of countsByKey) {
|
|
||||||
byKey[key] = toMetrics(counts);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { overall: toMetrics(overallCounts), byKey };
|
|
||||||
}
|
|
||||||
|
|
@ -1,636 +0,0 @@
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import {
|
|
||||||
findIngredientMentions,
|
|
||||||
type IngredientMention,
|
|
||||||
loadIngredientCatalog,
|
|
||||||
loadUnitCatalog,
|
|
||||||
} from "./ingredient-matcher.js";
|
|
||||||
import { type IntentServiceTemperature, intentServiceClient } from "./intent-service-client.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
|
||||||
* step description corresponds to — groundwork for a future batch-cooking
|
|
||||||
* optimization algorithm, and (via `matchTechStepSpans`) what
|
|
||||||
* `recipe.service.ts` persists as `StepTechStep.start`/`end` so the recipe
|
|
||||||
* UI can highlight the exact matched words (see `StepView` in
|
|
||||||
* `packages/shared`).
|
|
||||||
*
|
|
||||||
* Regex-only matching used to live here (matching literal verb-form
|
|
||||||
* patterns from a DB-backed `TechStepMapping` table) but couldn't
|
|
||||||
* generalize past its own vocabulary — a step describing melting butter as
|
|
||||||
* "jusqu'à ce que le beurre ait disparu dans la poêle" mentions no verb any
|
|
||||||
* regex could anchor on, yet unmistakably *means* `melt`. Replaced with a
|
|
||||||
* small hybrid pipeline (originally built on `node-nlp`, now entirely
|
|
||||||
* delegated to `services/tech-step-intent-service` — a spaCy-based
|
|
||||||
* microservice, see {@link IntentServiceClient} and that service's own
|
|
||||||
* README):
|
|
||||||
*
|
|
||||||
* 1. **NER** (the intent service's `PhraseMatcher`, built from its own
|
|
||||||
* `training_data.py`'s `synonyms`) finds every *candidate* technique
|
|
||||||
* mention in the whole description, each with its exact character span —
|
|
||||||
* mechanically the same job the old regexes did, just as flat synonym
|
|
||||||
* lists instead of hand-written patterns. This step alone is *not* the
|
|
||||||
* final answer — see step 3.
|
|
||||||
* 2. The description is cut into clauses around those candidate spans
|
|
||||||
* ({@link splitIntoClauses}) — a step naming two techniques ("Dans une
|
|
||||||
* poêle chaude, faire chauffer une noix de beurre" is both `preheat`
|
|
||||||
* and `melt`) needs each judged on its own surrounding context, not the
|
|
||||||
* whole step lumped into one classification.
|
|
||||||
* 3. **NLP intent classification** (the intent service's `textcat`, trained
|
|
||||||
* on its own `training_data.py`'s `utterances`) then classifies each
|
|
||||||
* clause on its own — this is what actually delivers "meaning, not
|
|
||||||
* keywords": the classifier was deliberately trained on paraphrases that
|
|
||||||
* never use the technique's own verb (e.g. "jusqu'à ce que le beurre ait
|
|
||||||
* disparu" for `melt`), so a clause reaching it gets labeled by what it
|
|
||||||
* was trained to recognize as *meaning* a technique, not by which
|
|
||||||
* literal word the NER step happened to anchor on. The NER-implied
|
|
||||||
* technique is kept only as a fallback for a clause the classifier
|
|
||||||
* isn't confident about (see `CONFIDENCE_THRESHOLD`) — a clearly
|
|
||||||
* keyword-anchored clause a small model merely isn't sure how to
|
|
||||||
* classify shouldn't be dropped outright.
|
|
||||||
*
|
|
||||||
* A single instruction can genuinely involve more than one technique — see
|
|
||||||
* point 2 above — so `matchTechSteps`/`matchTechStepSpans` both return the
|
|
||||||
* whole *ordered sequence* they find, not a single winner, matching
|
|
||||||
* `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table).
|
|
||||||
*
|
|
||||||
* `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
|
|
||||||
* access) so they stay unit-testable in isolation (see
|
|
||||||
* `test/tech-step-matcher.test.ts`); this class only ever needs a
|
|
||||||
* `TechStep.key -> id` lookup from the DB, memoized on the shared
|
|
||||||
* {@link techStepClassifier} singleton rather than repeated per call — the
|
|
||||||
* NLP model itself trains once, inside `services/tech-step-intent-service`'s
|
|
||||||
* own startup, entirely independently of this class (see that service's
|
|
||||||
* README — this repo no longer pushes any corpus to it over HTTP).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lowercases and strips diacritics (NFD decomposition + removal of
|
|
||||||
* combining marks, e.g. "Déglacer" -> "deglacer"). Still used by
|
|
||||||
* `ingredient-matcher.ts` for its own, unrelated free-text matching — kept
|
|
||||||
* here and exported rather than duplicated, this module owned it first.
|
|
||||||
*/
|
|
||||||
const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu;
|
|
||||||
|
|
||||||
export function normalizeText(text: string): string {
|
|
||||||
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One technique {@link matchTechStepSpans} found, alongside exactly where in
|
|
||||||
* `description` it matched — two nested spans, both `[start, end)` (same
|
|
||||||
* convention as `String.prototype.slice`):
|
|
||||||
*
|
|
||||||
* - `start`/`end` — the tight *keyword* span (e.g. "préchauffer") that
|
|
||||||
* directly triggered the match, or (when no NER anchor exists at all —
|
|
||||||
* see {@link splitIntoClauses}'s zero-candidate case) the whole clause,
|
|
||||||
* same as `contextStart`/`contextEnd` below.
|
|
||||||
* - `contextStart`/`contextEnd` — the wider *clause* the keyword was found
|
|
||||||
* in (e.g. "Dans une poêle chaude" for a `preheat` keyword of "poêle
|
|
||||||
* chaude") — what actually got fed to the classifier (see this file's
|
|
||||||
* doc comment, point 3), kept alongside the tight span so a caller can
|
|
||||||
* show *both*: the exact trigger word(s), and how much of the sentence
|
|
||||||
* is understood to be about that technique. Always contains `start`/`end`
|
|
||||||
* (`contextStart <= start`, `end <= contextEnd`).
|
|
||||||
*
|
|
||||||
* Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
|
|
||||||
* (`recipe.service.ts`) so the recipe detail view can highlight both spans,
|
|
||||||
* not just know a technique was mentioned somewhere.
|
|
||||||
*
|
|
||||||
* `ingredients`/`utensils` are the metadata found in this match's own
|
|
||||||
* *clause* (see this file's doc comment, point 2) — an ingredient/utensil
|
|
||||||
* mentioned in a different clause of the same description belongs to
|
|
||||||
* *that* clause's own match, never this one, the same "judged on its own
|
|
||||||
* surrounding context" rule the technique itself is judged by. Always `[]`
|
|
||||||
* rather than omitted when nothing was found, so every caller can iterate
|
|
||||||
* unconditionally. Persisted as `StepTechStepIngredient`/`StepTechStepUtensil`
|
|
||||||
* rows (`recipe.service.ts`).
|
|
||||||
*/
|
|
||||||
export interface TechStepMatch {
|
|
||||||
techStepId: number;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
contextStart: number;
|
|
||||||
contextEnd: number;
|
|
||||||
ingredients: IngredientMention[];
|
|
||||||
utensils: UtensilMention[];
|
|
||||||
temperatures: TemperatureMention[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A utensil mention found by the intent service's utensil `PhraseMatcher`
|
|
||||||
* (`kind: "utensil"` entities in `IntentServiceProcessResult`, see
|
|
||||||
* `intent-service-client.ts`), resolved to a local `Utensil.id` and
|
|
||||||
* attributed to whichever clause its span falls inside — same
|
|
||||||
* `[start, end)` convention as every other span in this file.
|
|
||||||
*/
|
|
||||||
export interface UtensilMention {
|
|
||||||
utensilId: number;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A temperature the intent service's regex pass found (`temperatures` in
|
|
||||||
* `IntentServiceProcessResult`), attributed to whichever clause its span
|
|
||||||
* falls inside — same rule as {@link UtensilMention}. No DB id to resolve
|
|
||||||
* (unlike a technique/ingredient/utensil): the structured value *is* the
|
|
||||||
* data. Persisted verbatim as a `StepTechStepTemperature` row.
|
|
||||||
*/
|
|
||||||
export type TemperatureMention = IntentServiceTemperature;
|
|
||||||
|
|
||||||
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
|
|
||||||
export interface TechniqueCandidate {
|
|
||||||
/** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
|
|
||||||
uid: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One clause {@link splitIntoClauses} produced — `anchor` is `null` only for the single "whole description, no candidate found at all" fallback clause (see that function's doc comment). */
|
|
||||||
export interface TechStepClause {
|
|
||||||
/** `[start, end)` into the original description — the text handed to the classifier for this clause, and (see {@link TechStepMatch}) what ends up as a match's `contextStart`/`contextEnd`. */
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
/** The candidate this clause was cut around, if any — its own (tighter) span is what gets persisted as a match's `start`/`end` for the keyword highlight, the wider clause span is always its `contextStart`/`contextEnd`. */
|
|
||||||
anchor: TechniqueCandidate | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Matches a sentence-ending punctuation mark, for {@link findGapSplitPoint}'s preferred split points. */
|
|
||||||
const SENTENCE_END_PATTERN = /[.!?]/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picks where to cut the gap `[gapStart, gapEnd)` between two consecutive
|
|
||||||
* candidates — preferring a *sentence* boundary (right after `.`/`!`/`?`)
|
|
||||||
* nearest the gap's midpoint when one exists in the gap, otherwise any
|
|
||||||
* whitespace nearest the midpoint, so a clause boundary (surfaced to users
|
|
||||||
* as `contextStart`/`contextEnd`, unlike a keyword's own `[start, end)`
|
|
||||||
* which always lands on a real word by construction) never slices through
|
|
||||||
* the middle of a word — found while testing a context span that cut
|
|
||||||
* "poêle" into "poêl"/"e" across two clauses.
|
|
||||||
*
|
|
||||||
* The sentence-boundary preference matters beyond cosmetics: a description
|
|
||||||
* with two techniques in two different sentences ("Préchauffer le four à
|
|
||||||
* 180°C. Dans un saladier, mettre le beurre... et mélanger.") used to only
|
|
||||||
* get a plain nearest-midpoint whitespace split, which for a long first
|
|
||||||
* sentence lands *inside* the second one — handing the classifier a clause
|
|
||||||
* like "...(thermostat 6). Dans un saladier, mettre" that trails off
|
|
||||||
* mid-instruction with no object. That garbled, incomplete text is nothing
|
|
||||||
* like the short, complete training utterances, and was found to
|
|
||||||
* misclassify real recipe steps with high (>0.65) confidence in both
|
|
||||||
* halves — "Préchauffer..." scored as `mix`, its actual "mélanger" clause
|
|
||||||
* as `melt`. Splitting at the real sentence boundary instead hands the
|
|
||||||
* classifier two complete, grammatical clauses, each far closer to what it
|
|
||||||
* was trained on.
|
|
||||||
*
|
|
||||||
* Falls back to the raw midpoint when the gap has no whitespace at all
|
|
||||||
* (adjacent candidates, or a gap that's pure punctuation with no space) —
|
|
||||||
* same "some split point, however imperfect" fallback a plain midpoint
|
|
||||||
* always was.
|
|
||||||
*/
|
|
||||||
function findGapSplitPoint(description: string, gapStart: number, gapEnd: number): number {
|
|
||||||
if (gapStart >= gapEnd) return gapStart;
|
|
||||||
const midpoint = Math.floor((gapStart + gapEnd) / 2);
|
|
||||||
|
|
||||||
let bestSentenceEnd: number | null = null;
|
|
||||||
let bestSentenceEndDistance = Number.POSITIVE_INFINITY;
|
|
||||||
let bestWhitespace: number | null = null;
|
|
||||||
let bestWhitespaceDistance = Number.POSITIVE_INFINITY;
|
|
||||||
for (let i = gapStart; i < gapEnd; i++) {
|
|
||||||
if (!/\s/.test(description[i] ?? "")) continue;
|
|
||||||
const distance = Math.abs(i - midpoint);
|
|
||||||
if (distance < bestWhitespaceDistance) {
|
|
||||||
bestWhitespace = i;
|
|
||||||
bestWhitespaceDistance = distance;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
i > gapStart &&
|
|
||||||
SENTENCE_END_PATTERN.test(description[i - 1] ?? "") &&
|
|
||||||
distance < bestSentenceEndDistance
|
|
||||||
) {
|
|
||||||
bestSentenceEnd = i;
|
|
||||||
bestSentenceEndDistance = distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestSentenceEnd ?? bestWhitespace ?? midpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cuts `description` into clauses around `candidates` (NER's found
|
|
||||||
* technique mentions, already sorted or not — sorted internally), one
|
|
||||||
* clause per candidate, so each can be judged by the classifier on its own
|
|
||||||
* surrounding context rather than the whole (possibly multi-technique)
|
|
||||||
* description at once.
|
|
||||||
*
|
|
||||||
* - **Zero candidates**: the whole description is one clause with no
|
|
||||||
* anchor — still worth classifying (a description mentioning no literal
|
|
||||||
* keyword at all can still *mean* a technique, the entire point of the
|
|
||||||
* classification step), just with no tight span to highlight, so callers
|
|
||||||
* fall back to highlighting the whole thing.
|
|
||||||
* - **One candidate**: the whole description is one clause too (nothing to
|
|
||||||
* cut around a single mention), but *with* that candidate as its anchor
|
|
||||||
* — callers get its tight span for highlighting.
|
|
||||||
* - **Two or more**: split points fall at the whitespace nearest the
|
|
||||||
* midpoint of each consecutive pair's `[end, nextStart]` gap (see
|
|
||||||
* {@link findGapSplitPoint} — never mid-word), producing that many
|
|
||||||
* contiguous, non-overlapping clauses covering the whole description —
|
|
||||||
* clause *i* is anchored on candidate *i*.
|
|
||||||
*
|
|
||||||
* Pure and DB/model-free — unit-tested directly (see
|
|
||||||
* `test/tech-step-matcher.test.ts`) without needing a trained classifier.
|
|
||||||
*/
|
|
||||||
export function splitIntoClauses(
|
|
||||||
description: string,
|
|
||||||
candidates: TechniqueCandidate[],
|
|
||||||
): TechStepClause[] {
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return [{ start: 0, end: description.length, anchor: null }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = [...candidates].sort((a, b) => a.start - b.start);
|
|
||||||
const [first, ...rest] = sorted;
|
|
||||||
if (first === undefined) {
|
|
||||||
// Unreachable — `candidates.length === 0` already returned above, so
|
|
||||||
// `sorted` (same length) always has a first element here. Satisfies
|
|
||||||
// `noUncheckedIndexedAccess`, which can't see that from the length
|
|
||||||
// check alone.
|
|
||||||
return [{ start: 0, end: description.length, anchor: null }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single pass, pairing each candidate with the next one as it goes —
|
|
||||||
// avoids re-indexing a separately-built `splitPoints` array afterward
|
|
||||||
// (also awkward under `noUncheckedIndexedAccess` for no real benefit,
|
|
||||||
// since every split point is only ever read once, right after it's
|
|
||||||
// computed).
|
|
||||||
const clauses: TechStepClause[] = [];
|
|
||||||
let clauseStart = 0;
|
|
||||||
let anchor = first;
|
|
||||||
for (const next of rest) {
|
|
||||||
const splitPoint = findGapSplitPoint(description, anchor.end, next.start);
|
|
||||||
clauses.push({ start: clauseStart, end: splitPoint, anchor });
|
|
||||||
clauseStart = splitPoint;
|
|
||||||
anchor = next;
|
|
||||||
}
|
|
||||||
clauses.push({ start: clauseStart, end: description.length, anchor });
|
|
||||||
return clauses;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Below this confidence, a clause's classifier verdict isn't trusted on its
|
|
||||||
* own — falls back to its NER anchor's own technique instead (see this
|
|
||||||
* file's doc comment, point 3). Tuned empirically against
|
|
||||||
* `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the
|
|
||||||
* cases this threshold was picked to pass.
|
|
||||||
*
|
|
||||||
* Recalibrated for the migration off `node-nlp` to
|
|
||||||
* `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes)
|
|
||||||
* — its score distribution is meaningfully different from node-nlp's own
|
|
||||||
* classifier, and shifts again every time the corpus' technique count
|
|
||||||
* changes (more exclusive classes generally means a *lower* natural
|
|
||||||
* confidence ceiling, softmax mass spread thinner).
|
|
||||||
*
|
|
||||||
* Currently `0.25`, set against the corpus as expanded to ~74 techniques
|
|
||||||
* (`services/tech-step-intent-service/intent_service/training_data.py`,
|
|
||||||
* `_TRAINING_ITERATIONS = 25`, `textcat` trained on each technique's own
|
|
||||||
* `synonyms` in addition to its `utterances` — see that constant's own
|
|
||||||
* comment for the calibration history) from manual spot-checks, not yet a
|
|
||||||
* real `calibrate-tech-step-threshold.ts` sweep against
|
|
||||||
* `TECH_STEP_EVAL_DATASET` (needs Postgres — see that script's own doc
|
|
||||||
* comment): observed real-case scores ranged `0.31`-`0.89` (`simmer`
|
|
||||||
* lowest, still correct in argmax and anchored anyway; `melt` highest, the
|
|
||||||
* motivating anchor-less case), against a noise floor around `0.02`
|
|
||||||
* (English text through the French classifier). `0.25` sits with real
|
|
||||||
* margin above the noise floor and below every real case seen so far, but
|
|
||||||
* **this is a placeholder pending the real eval-dataset sweep** — do not
|
|
||||||
* treat it as load-bearing precision the way the original `0.45`
|
|
||||||
* (calibrated against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET`
|
|
||||||
* F1 plateauing exactly there) was.
|
|
||||||
*/
|
|
||||||
export const CONFIDENCE_THRESHOLD = 0.25;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One clause's full classification detail — the finer-grained sibling of
|
|
||||||
* {@link TechStepMatch}, exposing the raw intent/score
|
|
||||||
* `TechStepClassifierService`'s private `_classifyClause` normally
|
|
||||||
* collapses into a single accepted-or-fallback verdict. Nothing on the
|
|
||||||
* interactive save/read path needs this (that's exactly what
|
|
||||||
* `_classifyClause`'s threshold + fallback logic is for) — it exists for
|
|
||||||
* `services/tech-step-llm-worker`'s "audit low-confidence clauses" job
|
|
||||||
* (`modules/internal/tech-step-worker.service.ts`'s `getAuditBatch`), which
|
|
||||||
* needs to see *which* clauses the classifier itself wasn't sure about, not
|
|
||||||
* just its final best-effort verdict.
|
|
||||||
*/
|
|
||||||
export interface TechStepClauseClassification {
|
|
||||||
/** The clause's own text (`description.slice(start, end)`, trimmed). */
|
|
||||||
clauseText: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
/** The clause's NER anchor's own implied technique `uid`, if it had one — same as `TechStepClause.anchor.uid`. */
|
|
||||||
anchorUid: string | null;
|
|
||||||
/** The intent classifier's own top guess for this clause, whatever its score — `null` only when the intent service had nothing trained for `locale`, or the clause text was blank. Unlike {@link TechStepMatch}, never silently replaced by the anchor's uid — the whole point of this type is to expose the classifier's raw opinion, confident or not. */
|
|
||||||
intentUid: string | null;
|
|
||||||
/** The intent classifier's own confidence for `intentUid` — `0` when `intentUid` is `null` (nothing to have a score about). */
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the `TechStep.key -> id` lookup behind {@link matchTechStepSpans} —
|
|
||||||
* a real class (not a plain object of functions) per this repo's
|
|
||||||
* service-style-logic convention, even though it's only ever used as the
|
|
||||||
* one shared {@link techStepClassifier} singleton below: it holds real
|
|
||||||
* state (the memoized lookup promise), not just grouped stateless helpers.
|
|
||||||
* The actual NER/intent-classification model lives entirely in
|
|
||||||
* `services/tech-step-intent-service` (a separate process, trained from
|
|
||||||
* its own `training_data.py` at its own startup) — this class never
|
|
||||||
* trains or pushes anything to it, it only calls `POST /v1/process` and
|
|
||||||
* resolves whatever `uid` comes back to a local DB id.
|
|
||||||
*/
|
|
||||||
export class TechStepClassifierService {
|
|
||||||
/** Memoized `TechStep.key -> id` lookup — resolved from the DB once, reused by every call rather than queried per request. `undefined` until the first call starts loading it, after which every caller (concurrent or not) awaits the same promise. */
|
|
||||||
private _techStepIdsLoaded: Promise<void> | undefined;
|
|
||||||
private _techStepIdByUid: Map<string, number> | undefined;
|
|
||||||
|
|
||||||
/** Same memoized-lookup shape as {@link _techStepIdsLoaded}/{@link _techStepIdByUid}, for `Utensil.key -> id` instead — a `kind: "utensil"` entity from the intent service resolves through this map, never `_techStepIdByUid`. */
|
|
||||||
private _utensilIdsLoaded: Promise<void> | undefined;
|
|
||||||
private _utensilIdByUid: Map<string, number> | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forces the `TechStep.key -> id` lookup to load now, synchronously with
|
|
||||||
* server startup (see `server.ts`, which also retries this against a
|
|
||||||
* not-yet-reachable intent service), rather than stalling whichever
|
|
||||||
* request happens to be first to save/preview a recipe. Doesn't wait on
|
|
||||||
* `services/tech-step-intent-service` finishing its own training — that
|
|
||||||
* service is only ever considered "up" by Docker Compose/CI once it
|
|
||||||
* already is (see that service's `GET /health`), so by the time this
|
|
||||||
* runs in a real deployment it's already trained; a request racing an
|
|
||||||
* intent service that's genuinely still starting just gets an empty
|
|
||||||
* match list back (see `IntentServiceProcessResult`'s own doc comment),
|
|
||||||
* not an error.
|
|
||||||
*/
|
|
||||||
public async warmUp(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.matchTechStepSpans("faire cuire à feu doux", "fr");
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see matchTechStepSpans()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects every technique `description` means, as an ordered sequence of
|
|
||||||
* matches (each carrying *where* it matched) — empty if none apply. See
|
|
||||||
* this file's doc comment for the full NER -> split -> classify
|
|
||||||
* pipeline.
|
|
||||||
*
|
|
||||||
* @param locale Which of `TECH_STEP_TRAINING_DATA`'s locales to match
|
|
||||||
* against — same "caller already knows/validated this" contract the
|
|
||||||
* old `matchTechStepSpans(description, mappings)` had via its
|
|
||||||
* pre-filtered `mappings` argument, just as an explicit parameter now
|
|
||||||
* that the training data isn't pre-filtered by the caller anymore.
|
|
||||||
*/
|
|
||||||
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
|
|
||||||
try {
|
|
||||||
await Promise.all([this._ensureTechStepIdsLoaded(), this._ensureUtensilIdsLoaded()]);
|
|
||||||
if (description.trim().length === 0) return [];
|
|
||||||
|
|
||||||
// Loaded fresh per call (once per step, see `recipe.service.ts`'s
|
|
||||||
// `matchStepsTechSteps`) rather than memoized like the id lookups
|
|
||||||
// above — same "cheap enough, and reference data can change between
|
|
||||||
// calls without a restart" posture `loadIngredientCatalog`/
|
|
||||||
// `loadUnitCatalog`'s own doc comments already describe for their
|
|
||||||
// other callers (`ingredient-matcher.ts`, `sources.service.ts`).
|
|
||||||
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
|
||||||
loadIngredientCatalog(locale),
|
|
||||||
loadUnitCatalog(locale),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// The intent service returns two kinds of candidate (see `kind` on
|
|
||||||
// `IntentServiceEntity`): technique mentions (its corpus-trained
|
|
||||||
// `PhraseMatcher`) and utensil mentions (its static one, see
|
|
||||||
// `utensil_vocabulary.py`). Only the former ever anchor a clause —
|
|
||||||
// `splitIntoClauses` cuts a description around *techniques*, a
|
|
||||||
// mentioned utensil doesn't introduce a clause boundary of its own,
|
|
||||||
// it just gets attributed to whichever clause its span falls inside
|
|
||||||
// (see the loop below). Its `start`/`end` are already `[start, end)`
|
|
||||||
// (matching `String.prototype.slice`), unlike node-nlp's inclusive
|
|
||||||
// `end` — no `+ 1` needed either.
|
|
||||||
const nerResult = await intentServiceClient.process(locale, description);
|
|
||||||
const candidates: TechniqueCandidate[] = nerResult.entities
|
|
||||||
.filter((entity) => entity.kind === "technique")
|
|
||||||
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
|
||||||
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
|
|
||||||
const temperatureMentions = nerResult.temperatures;
|
|
||||||
|
|
||||||
const clauses = splitIntoClauses(description, candidates);
|
|
||||||
const matches: TechStepMatch[] = [];
|
|
||||||
for (const clause of clauses) {
|
|
||||||
const uid = await this._classifyClause(description, clause, locale);
|
|
||||||
if (uid === null) continue;
|
|
||||||
const techStepId = this._techStepIdByUid?.get(uid);
|
|
||||||
// A `uid` the classifier/NER was trained on but that no longer has
|
|
||||||
// a matching `TechStep` row (e.g. training data and
|
|
||||||
// `reference-seed-data.ts` drifted apart) — skip rather than
|
|
||||||
// persist a dangling id.
|
|
||||||
if (techStepId === undefined) continue;
|
|
||||||
const span = clause.anchor ?? { start: clause.start, end: clause.end };
|
|
||||||
|
|
||||||
const ingredients = findIngredientMentions(
|
|
||||||
description.slice(clause.start, clause.end),
|
|
||||||
ingredientCatalog,
|
|
||||||
unitCatalog,
|
|
||||||
locale,
|
|
||||||
).map((mention) => ({
|
|
||||||
...mention,
|
|
||||||
start: mention.start + clause.start,
|
|
||||||
end: mention.end + clause.start,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const utensils: UtensilMention[] = utensilEntities.flatMap((entity) => {
|
|
||||||
if (entity.start < clause.start || entity.end > clause.end) return [];
|
|
||||||
const utensilId = this._utensilIdByUid?.get(entity.uid);
|
|
||||||
// Same drift guard as `techStepId` above.
|
|
||||||
return utensilId === undefined
|
|
||||||
? []
|
|
||||||
: [{ utensilId, start: entity.start, end: entity.end }];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Temperatures carry no id to resolve — kept as-is, just attributed
|
|
||||||
// to the clause whose span contains them (same rule as utensils).
|
|
||||||
const temperatures: TemperatureMention[] = temperatureMentions.filter(
|
|
||||||
(temp) => temp.start >= clause.start && temp.end <= clause.end,
|
|
||||||
);
|
|
||||||
|
|
||||||
matches.push({
|
|
||||||
techStepId,
|
|
||||||
start: span.start,
|
|
||||||
end: span.end,
|
|
||||||
contextStart: clause.start,
|
|
||||||
contextEnd: clause.end,
|
|
||||||
ingredients,
|
|
||||||
utensils,
|
|
||||||
temperatures,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
matches.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
|
|
||||||
return matches;
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles
|
|
||||||
// it, this service layer just isn't allowed a bare `await` without a
|
|
||||||
// try/catch per the repo's convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits `description` into clauses exactly like {@link matchTechStepSpans}
|
|
||||||
* does, but returns each clause's *raw* classification detail
|
|
||||||
* ({@link TechStepClauseClassification}) instead of the threshold-applied,
|
|
||||||
* anchor-fallback-resolved `TechStepMatch` — see that type's doc comment
|
|
||||||
* for why/who needs this. Deliberately a separate traversal rather than a
|
|
||||||
* shared refactor with `matchTechStepSpans`/`_classifyClause`: this method
|
|
||||||
* exists purely to add a new, additive read path without risking a
|
|
||||||
* behavior change to the two already-relied-on methods above.
|
|
||||||
*/
|
|
||||||
public async classifyClauses(
|
|
||||||
description: string,
|
|
||||||
locale: string,
|
|
||||||
): Promise<TechStepClauseClassification[]> {
|
|
||||||
try {
|
|
||||||
await this._ensureTechStepIdsLoaded();
|
|
||||||
if (description.trim().length === 0) return [];
|
|
||||||
|
|
||||||
const nerResult = await intentServiceClient.process(locale, description);
|
|
||||||
const candidates: TechniqueCandidate[] = nerResult.entities
|
|
||||||
.filter((entity) => entity.kind === "technique")
|
|
||||||
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
|
||||||
|
|
||||||
const clauses = splitIntoClauses(description, candidates);
|
|
||||||
const results: TechStepClauseClassification[] = [];
|
|
||||||
for (const clause of clauses) {
|
|
||||||
const clauseText = description.slice(clause.start, clause.end).trim();
|
|
||||||
const anchorUid = clause.anchor?.uid ?? null;
|
|
||||||
if (clauseText.length === 0) {
|
|
||||||
results.push({
|
|
||||||
clauseText,
|
|
||||||
start: clause.start,
|
|
||||||
end: clause.end,
|
|
||||||
anchorUid,
|
|
||||||
intentUid: null,
|
|
||||||
score: 0,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const result = await intentServiceClient.process(locale, clauseText);
|
|
||||||
results.push({
|
|
||||||
clauseText,
|
|
||||||
start: clause.start,
|
|
||||||
end: clause.end,
|
|
||||||
anchorUid,
|
|
||||||
intentUid: result.intent,
|
|
||||||
score: result.intent === null ? 0 : result.score,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see matchTechStepSpans()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience wrapper around {@link matchTechStepSpans} for callers that
|
|
||||||
* only care about *which* techniques matched, not where — e.g.
|
|
||||||
* `recipe-translation.ts`'s `translateRecipeSteps`, which declares a
|
|
||||||
* step's technique sequence for an imported recipe that isn't saved (and
|
|
||||||
* so has no `StepTechStep` row to persist a span into) yet.
|
|
||||||
*/
|
|
||||||
public async matchTechSteps(description: string, locale: string): Promise<number[]> {
|
|
||||||
try {
|
|
||||||
return (await this.matchTechStepSpans(description, locale)).map((match) => match.techStepId);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see matchTechStepSpans()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classifies one clause, returning the technique `uid` it means (or
|
|
||||||
* `null` if none applies) — the classifier's own verdict when it's
|
|
||||||
* confident enough ({@link CONFIDENCE_THRESHOLD}), otherwise the
|
|
||||||
* clause's NER anchor (if it has one) as a floor: a clearly
|
|
||||||
* keyword-anchored clause a small model merely isn't sure how to
|
|
||||||
* classify shouldn't be dropped outright, only a genuinely
|
|
||||||
* anchor-less/low-confidence one should.
|
|
||||||
*/
|
|
||||||
private async _classifyClause(
|
|
||||||
description: string,
|
|
||||||
clause: TechStepClause,
|
|
||||||
locale: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const clauseText = description.slice(clause.start, clause.end).trim();
|
|
||||||
if (clauseText.length === 0) return clause.anchor?.uid ?? null;
|
|
||||||
|
|
||||||
const result = await intentServiceClient.process(locale, clauseText);
|
|
||||||
if (result.intent !== null && result.score >= CONFIDENCE_THRESHOLD) {
|
|
||||||
return result.intent;
|
|
||||||
}
|
|
||||||
return clause.anchor?.uid ?? null;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see matchTechStepSpans()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the `uid -> TechStep.id` lookup exactly once — memoized on
|
|
||||||
* `_techStepIdsLoaded` so a burst of concurrent calls (several steps of
|
|
||||||
* the same recipe save, awaited via the same event loop tick) all await
|
|
||||||
* the one in-flight DB query rather than each firing their own.
|
|
||||||
*/
|
|
||||||
private async _ensureTechStepIdsLoaded(): Promise<void> {
|
|
||||||
if (this._techStepIdsLoaded === undefined) {
|
|
||||||
this._techStepIdsLoaded = this._loadTechStepIds();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this._techStepIdsLoaded;
|
|
||||||
} catch (err) {
|
|
||||||
// A failed load must be retried by the *next* call, not leave every
|
|
||||||
// future call permanently rejecting against a stale failed promise.
|
|
||||||
this._techStepIdsLoaded = undefined;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _loadTechStepIds(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
|
||||||
this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see matchTechStepSpans()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `Utensil.key -> id` counterpart of {@link _ensureTechStepIdsLoaded} — same memoize-once-retry-on-failure shape. */
|
|
||||||
private async _ensureUtensilIdsLoaded(): Promise<void> {
|
|
||||||
if (this._utensilIdsLoaded === undefined) {
|
|
||||||
this._utensilIdsLoaded = this._loadUtensilIds();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this._utensilIdsLoaded;
|
|
||||||
} catch (err) {
|
|
||||||
this._utensilIdsLoaded = undefined;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _loadUtensilIds(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const utensils = await prisma.utensil.findMany({ select: { id: true, key: true } });
|
|
||||||
this._utensilIdByUid = new Map(utensils.map((utensil) => [utensil.key, utensil.id]));
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see matchTechStepSpans()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Single shared instance — every caller reuses the one memoized `TechStep.key -> id` lookup rather than re-querying the DB. The actual model training (expensive — a couple of minutes, both locales combined) happens entirely inside `services/tech-step-intent-service`'s own startup, not here — see that service's `_TRAINING_ITERATIONS`. */
|
|
||||||
export const techStepClassifier = new TechStepClassifierService();
|
|
||||||
|
|
@ -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,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
* user has already brought in as if they were new. A separate, pure
|
* user has already brought in as if they were new. A separate, pure
|
||||||
* step rather than something `list()` itself does: an adapter only
|
* step rather than something `list()` itself does: an adapter only
|
||||||
* knows its source, never our database — same reasoning as
|
* knows its source, never our database — same reasoning as
|
||||||
* `tech-step-matcher.ts`'s split between pure `splitIntoClauses` and
|
* `tech-step-matcher.ts`'s split between pure `matchTechStep` and its
|
||||||
* its DB/model-touching `TechStepClassifierService`. Whichever future
|
* DB-touching `loadTechStepMappingRules`. Whichever future layer
|
||||||
* layer queries "which externalIds from this source do we already have"
|
* queries "which externalIds from this source do we already have"
|
||||||
* (not yet decided — it needs a place to persist that link,
|
* (not yet decided — it needs a place to persist that link,
|
||||||
* see {@link RecipeSourceListItem.externalId}) calls this to annotate
|
* see {@link RecipeSourceListItem.externalId}) calls this to annotate
|
||||||
* the page before returning it.
|
* the page before returning it.
|
||||||
|
|
@ -146,7 +146,6 @@ export interface ParsedRecipe {
|
||||||
* name: "Some Recipe Site",
|
* name: "Some Recipe Site",
|
||||||
* official: false,
|
* official: false,
|
||||||
* iconUrl: "https://somerecipesite.example/favicon.svg",
|
* iconUrl: "https://somerecipesite.example/favicon.svg",
|
||||||
* locale: "fr",
|
|
||||||
* async list(params) { ... },
|
* async list(params) { ... },
|
||||||
* async fetchDetail(externalId) { ... },
|
* async fetchDetail(externalId) { ... },
|
||||||
* parse(raw) { ... },
|
* parse(raw) { ... },
|
||||||
|
|
@ -172,16 +171,6 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
|
||||||
official: boolean;
|
official: boolean;
|
||||||
/** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */
|
/** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
/**
|
|
||||||
* Language of the text this source produces (`ParsedRecipe.description`/
|
|
||||||
* `steps[].description`/`ingredients[].name`) — e.g. `"en"` for
|
|
||||||
* TheMealDB. Not a user preference: the language the source's own
|
|
||||||
* content is actually written in, regardless of who's browsing it.
|
|
||||||
* Determines which trained-classifier/ingredient-label locale
|
|
||||||
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
|
|
||||||
* recipes against when previewing/importing one.
|
|
||||||
*/
|
|
||||||
locale: string;
|
|
||||||
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
|
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
|
||||||
fetchDetail(externalId: string): Promise<TRawDetail>;
|
fetchDetail(externalId: string): Promise<TRawDetail>;
|
||||||
parse(raw: TRawDetail): ParsedRecipe;
|
parse(raw: TRawDetail): ParsedRecipe;
|
||||||
|
|
@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
146
apps/api/src/lib/tech-step-matcher.ts
Normal file
146
apps/api/src/lib/tech-step-matcher.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { prisma } from "../db/prisma.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
||||||
|
* step description corresponds to, using the static `TechStepMapping`
|
||||||
|
* catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) — groundwork for a
|
||||||
|
* future batch-cooking optimization algorithm, not surfaced in the recipe
|
||||||
|
* UI yet (see `StepView` in `packages/shared`).
|
||||||
|
*
|
||||||
|
* A single instruction can genuinely involve more than one technique (e.g.
|
||||||
|
* "Dans une poêle chaude, faire chauffer une noix de beurre" is both
|
||||||
|
* `preheat` and `melt`) — `matchTechSteps` returns the whole *ordered
|
||||||
|
* sequence* it finds, not a single winner, matching `Step.techSteps`
|
||||||
|
* (schema.prisma's `StepTechStep`, an ordered join table).
|
||||||
|
*
|
||||||
|
* `normalizeText`/`matchTechSteps` are pure (no DB access) so they can be
|
||||||
|
* unit-tested in isolation (see `test/tech-step-matcher.test.ts`).
|
||||||
|
* `loadTechStepMappingRules` is the only DB-touching piece, kept separate
|
||||||
|
* so callers (`recipe.service.ts`) fetch the whole mapping list once per
|
||||||
|
* request and pass it to `matchTechSteps` per step, rather than querying
|
||||||
|
* once per step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One `TechStepMapping` row, trimmed to what {@link matchTechSteps} needs. */
|
||||||
|
export interface TechStepMappingRule {
|
||||||
|
techStepId: number;
|
||||||
|
/**
|
||||||
|
* Regex source, matched against the normalized description (see
|
||||||
|
* {@link normalizeText}) — may itself contain accented characters,
|
||||||
|
* normalized the same way before compiling.
|
||||||
|
*/
|
||||||
|
expression: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercases and strips diacritics (NFD decomposition + removal of
|
||||||
|
* combining marks, e.g. "Déglacer" -> "deglacer") — recipe step text and
|
||||||
|
* mapping expressions are both run through this before matching, so
|
||||||
|
* expressions can be authored with natural French accents in
|
||||||
|
* `reference-seed-data.ts` while matching stays accent/case-insensitive.
|
||||||
|
*/
|
||||||
|
const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu;
|
||||||
|
|
||||||
|
export function normalizeText(text: string): string {
|
||||||
|
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where in the (normalized) description one mapping matched, alongside the rule that matched — the raw material {@link matchTechSteps} resolves into a final sequence. */
|
||||||
|
interface MatchCandidate extends TechStepMappingRule {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two candidates' matched spans share any character position — the case where two *different* techniques' expressions matched the same words (e.g. generic `cook`'s "cuire" inside specific `bake`'s "cuire au four"), meaning only one of them should survive. */
|
||||||
|
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
|
||||||
|
return a.start < b.end && b.start < a.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects every technique `description` mentions among `mappings`, as an
|
||||||
|
* ordered sequence of `techStepId`s — empty if none match. The algorithm:
|
||||||
|
*
|
||||||
|
* 1. Test every mapping against the normalized description; each one that
|
||||||
|
* matches becomes a candidate carrying *where* it matched (so
|
||||||
|
* overlapping matches can be compared).
|
||||||
|
* 2. Within a single technique, several of its own mappings might all
|
||||||
|
* match (different phrasings for the same `techStepId`) — keep only
|
||||||
|
* that technique's best candidate (highest weight, ties broken by
|
||||||
|
* earliest match), the same tie-break this function always used for a
|
||||||
|
* single winner.
|
||||||
|
* 3. Across *different* techniques, two candidates can still overlap (a
|
||||||
|
* generic pattern matching inside a more specific one's span, e.g.
|
||||||
|
* `cook` vs `bake` both matching "cuire au four") — resolve greedily by
|
||||||
|
* weight: take candidates highest-weight first, accept a candidate only
|
||||||
|
* if it doesn't overlap one already accepted. This is what keeps
|
||||||
|
* `bake` and drops the redundant `cook` for that phrase, while letting
|
||||||
|
* two genuinely distinct, non-overlapping techniques (e.g. `preheat`
|
||||||
|
* and `melt` in "Dans une poêle chaude, faire chauffer une noix de
|
||||||
|
* beurre") both survive.
|
||||||
|
* 4. Sort what's left by where it appears in the text — the sequence
|
||||||
|
* reads in the same order as the instruction itself.
|
||||||
|
*
|
||||||
|
* Pure — takes `mappings` as a plain argument rather than querying Prisma
|
||||||
|
* itself, so it's testable without a database (see
|
||||||
|
* `loadTechStepMappingRules` for the DB-backed loader). `mappings` should
|
||||||
|
* already be filtered to the locale the caller cares about — this function
|
||||||
|
* has no notion of locale, it just tests the rules it's given.
|
||||||
|
*/
|
||||||
|
export function matchTechSteps(description: string, mappings: TechStepMappingRule[]): number[] {
|
||||||
|
const normalizedDescription = normalizeText(description);
|
||||||
|
|
||||||
|
const candidates: MatchCandidate[] = [];
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
const pattern = new RegExp(normalizeText(mapping.expression), "i");
|
||||||
|
const match = pattern.exec(normalizedDescription);
|
||||||
|
if (match === null) continue;
|
||||||
|
candidates.push({ ...mapping, start: match.index, end: match.index + match[0].length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: one best candidate per techStepId.
|
||||||
|
const bestByTechStep = new Map<number, MatchCandidate>();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const current = bestByTechStep.get(candidate.techStepId);
|
||||||
|
if (
|
||||||
|
current === undefined ||
|
||||||
|
candidate.weight > current.weight ||
|
||||||
|
(candidate.weight === current.weight && candidate.start < current.start)
|
||||||
|
) {
|
||||||
|
bestByTechStep.set(candidate.techStepId, candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: resolve cross-technique overlaps, highest weight first.
|
||||||
|
const byWeightDesc = [...bestByTechStep.values()].sort(
|
||||||
|
(a, b) => b.weight - a.weight || a.techStepId - b.techStepId,
|
||||||
|
);
|
||||||
|
const accepted: MatchCandidate[] = [];
|
||||||
|
for (const candidate of byWeightDesc) {
|
||||||
|
if (accepted.some((other) => overlaps(candidate, other))) continue;
|
||||||
|
accepted.push(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: reading order.
|
||||||
|
accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
|
||||||
|
return accepted.map((candidate) => candidate.techStepId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads every `TechStepMapping` row for `locale` as
|
||||||
|
* {@link TechStepMappingRule}s — meant to be fetched once per request by
|
||||||
|
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` and reused across
|
||||||
|
* every step of the recipe being saved, not re-queried per step.
|
||||||
|
*
|
||||||
|
* No user-language preference exists anywhere in the app yet (a single
|
||||||
|
* `"fr"` translation file, no locale field on `User`/`UserProfile`) —
|
||||||
|
* callers pass a hardcoded locale for now; this parameter exists so that
|
||||||
|
* plugging in a real user preference later doesn't require touching this
|
||||||
|
* module.
|
||||||
|
*/
|
||||||
|
export async function loadTechStepMappingRules(locale: string): Promise<TechStepMappingRule[]> {
|
||||||
|
return prisma.techStepMapping.findMany({
|
||||||
|
where: { locale },
|
||||||
|
select: { techStepId: true, expression: true, weight: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { errorHandlerService } from "@batch-cooking/error-tools";
|
|
||||||
import type { NextFunction, Request, Response } from "express";
|
|
||||||
import { logger } from "../lib/logger.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs every error that reaches Express's error-handling chain, then
|
|
||||||
* passes it straight on (`next(err)`) to the real error-to-response
|
|
||||||
* middleware (`createErrorMiddleware`, `@batch-cooking/express-tools`) —
|
|
||||||
* mounted immediately after this one in `app.ts`. Reuses
|
|
||||||
* `errorHandlerService.handle()` (`@batch-cooking/error-tools`) just to
|
|
||||||
* classify the error for logging purposes (its `status`/`body.code`) —
|
|
||||||
* `.handle()` is pure/stateless, so calling it here and then again in
|
|
||||||
* `createErrorMiddleware` right after is harmless, and it's the one place
|
|
||||||
* that already knows a bare `ZodError` maps to `400 VALIDATION_ERROR`, an
|
|
||||||
* `HttpError` maps to its own `status`/`code`, and anything else is a
|
|
||||||
* `500`. Doesn't build the actual response itself — that's still
|
|
||||||
* `createErrorMiddleware`'s job.
|
|
||||||
*
|
|
||||||
* A resulting `4xx` is routine, expected operation (a validation failure,
|
|
||||||
* a 404, an unauthenticated request) — logged at `warn`, not `error`, so a
|
|
||||||
* genuine `5xx` (an unhandled exception, a bug) stands out instead of
|
|
||||||
* being buried under normal client mistakes.
|
|
||||||
*/
|
|
||||||
export function errorLogger(err: unknown, req: Request, _res: Response, next: NextFunction): void {
|
|
||||||
const meta = { method: req.method, path: req.originalUrl };
|
|
||||||
const { status, body } = errorHandlerService.handle(err);
|
|
||||||
|
|
||||||
if (status >= 500) {
|
|
||||||
logger.error(err instanceof Error ? err.message : body.message, {
|
|
||||||
...meta,
|
|
||||||
status,
|
|
||||||
code: body.code,
|
|
||||||
stack: err instanceof Error ? err.stack : undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.warn(body.message, { ...meta, status, code: body.code });
|
|
||||||
}
|
|
||||||
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import type { NextFunction, Request, Response } from "express";
|
|
||||||
import { logger } from "../lib/logger.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs one line per request once it finishes — method, path, status code,
|
|
||||||
* and duration. Mounted first in `app.ts` (before every route, and before
|
|
||||||
* the error handler) so it wraps the whole request/response cycle,
|
|
||||||
* including requests that end in a 404 or an error response.
|
|
||||||
*
|
|
||||||
* Listens on `res`'s `"finish"` event rather than wrapping `next()` in a
|
|
||||||
* `try`/`finally`: this middleware calls `next()` immediately and returns,
|
|
||||||
* so it never itself sits on the stack waiting for the rest of the
|
|
||||||
* pipeline to resolve — `"finish"` fires once Express has actually flushed
|
|
||||||
* the response, whichever handler (or the error middleware) produced it.
|
|
||||||
*
|
|
||||||
* `4xx`/`5xx` responses log at `warn`/`error` respectively (status alone
|
|
||||||
* decides the level — this middleware has no idea *why* a request failed,
|
|
||||||
* just that it did); everything else logs at `info`. A route's own
|
|
||||||
* handler/the error middleware may log more detail about *why* separately
|
|
||||||
* (see `error-middleware` wiring in `app.ts`) — this line is just the
|
|
||||||
* "a request happened, here's the outcome" operational trace.
|
|
||||||
*/
|
|
||||||
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
|
|
||||||
const startedAt = process.hrtime.bigint();
|
|
||||||
|
|
||||||
res.on("finish", () => {
|
|
||||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
||||||
const meta = {
|
|
||||||
method: req.method,
|
|
||||||
path: req.originalUrl,
|
|
||||||
status: res.statusCode,
|
|
||||||
durationMs: Math.round(durationMs * 100) / 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (res.statusCode >= 500) {
|
|
||||||
logger.error("Request completed", meta);
|
|
||||||
} else if (res.statusCode >= 400) {
|
|
||||||
logger.warn("Request completed", meta);
|
|
||||||
} else {
|
|
||||||
logger.info("Request completed", meta);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { timingSafeEqual } from "node:crypto";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import type { NextFunction, Request, Response } from "express";
|
|
||||||
import { env } from "../config/env.js";
|
|
||||||
|
|
||||||
/** Header `services/tech-step-llm-worker` sends its shared secret on. Not `Authorization`/a bearer scheme — this isn't a user session, just one internal caller authenticating to another, same "one flat shared secret" shape as e.g. a webhook signing header. */
|
|
||||||
const INTERNAL_WORKER_SECRET_HEADER = "x-internal-worker-secret";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Express middleware guarding `/internal/tech-steps/*` — the surface
|
|
||||||
* `services/tech-step-llm-worker` (a process outside this monorepo, no
|
|
||||||
* Prisma access of its own, see that service's own README) reads
|
|
||||||
* low-confidence NLP clauses and pending `StepTechStepCorrection`s from,
|
|
||||||
* and posts `TechStepTrainingSuggestion`s back to. Never reachable by an
|
|
||||||
* end user's session cookie — deliberately a *different* auth mechanism
|
|
||||||
* than {@link requireAuth} (`require-auth.ts`), not layered on top of it,
|
|
||||||
* since the worker has no `UserProfile`/session of its own to authenticate
|
|
||||||
* as.
|
|
||||||
*
|
|
||||||
* Fails closed: an unset `INTERNAL_WORKER_SECRET` (the default in any
|
|
||||||
* environment that doesn't run the worker, see `config/env.ts`) rejects
|
|
||||||
* every request rather than leaving the surface open, same posture as a
|
|
||||||
* misconfigured `JWT_SECRET` would if it had a working fallback.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` if the header is missing,
|
|
||||||
* wrong, or the server has no secret configured at all — never
|
|
||||||
* distinguishes the reason, same posture as {@link requireAuth}.
|
|
||||||
*/
|
|
||||||
export function requireInternalWorker(req: Request, _res: Response, next: NextFunction): void {
|
|
||||||
const provided = req.header(INTERNAL_WORKER_SECRET_HEADER);
|
|
||||||
if (env.INTERNAL_WORKER_SECRET === undefined || provided === undefined) {
|
|
||||||
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `timingSafeEqual` throws on mismatched buffer lengths rather than
|
|
||||||
// returning `false` — checked separately first. A length mismatch alone
|
|
||||||
// already means "not equal", so this loses no timing-attack protection
|
|
||||||
// (an attacker learns nothing beyond what a differing length itself
|
|
||||||
// already reveals, no different from `!==` on the common case where the
|
|
||||||
// secret's real length isn't a secret worth protecting).
|
|
||||||
const expected = Buffer.from(env.INTERNAL_WORKER_SECRET);
|
|
||||||
const actual = Buffer.from(provided);
|
|
||||||
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
||||||
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import {
|
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||||
type AuthResponse,
|
|
||||||
deleteAccountSchema,
|
|
||||||
loginSchema,
|
|
||||||
signupSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import type { CookieOptions, Response } from "express";
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import type { CookieOptions, Response } from "express";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import { deleteAccount, login, signup } from "./auth.service.js";
|
import { deleteAccount, login, signup } from "./auth.service.js";
|
||||||
|
|
@ -48,11 +43,7 @@ authRouter.post(
|
||||||
const input = signupSchema.parse(req.body);
|
const input = signupSchema.parse(req.body);
|
||||||
const { profile, token } = await signup(input);
|
const { profile, token } = await signup(input);
|
||||||
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
||||||
// Token is echoed in the body too, not just the cookie — see
|
res.status(201).json(profile);
|
||||||
// `AuthResponse` (packages/shared) for why (Capacitor WebView origin
|
|
||||||
// can't receive a SameSite cookie). Purely additive: every existing
|
|
||||||
// profile field stays exactly where it was.
|
|
||||||
res.status(201).json({ ...profile, token } satisfies AuthResponse);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -63,7 +54,7 @@ authRouter.post(
|
||||||
const input = loginSchema.parse(req.body);
|
const input = loginSchema.parse(req.body);
|
||||||
const { profile, token } = await login(input);
|
const { profile, token } = await login(input);
|
||||||
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
||||||
res.status(200).json({ ...profile, token } satisfies AuthResponse);
|
res.status(200).json(profile);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
import argon2 from "argon2";
|
import argon2 from "argon2";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
import { signAuthToken } from "../../lib/jwt.js";
|
import { signAuthToken } from "../../lib/jwt.js";
|
||||||
import { toSafeProfile } from "../../lib/safe-profile.js";
|
import { toSafeProfile } from "../../lib/safe-profile.js";
|
||||||
import { leaveCurrentHouse } from "../house/house.service.js";
|
import { leaveCurrentHouse } from "../house/house.service.js";
|
||||||
|
|
@ -36,43 +35,28 @@ const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
||||||
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
||||||
*/
|
*/
|
||||||
export async function signup(input: SignupInput): Promise<AuthResult> {
|
export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||||
try {
|
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||||
const existing = await prisma.userProfile.findUnique({
|
if (existing) {
|
||||||
where: { email: input.email },
|
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await argon2.hash(input.password, hashOptions);
|
|
||||||
|
|
||||||
// No household is created here — it's now an optional step of the
|
|
||||||
// onboarding wizard (create or join one, or skip — see
|
|
||||||
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
|
|
||||||
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
|
|
||||||
const profile = await prisma.userProfile.create({
|
|
||||||
data: {
|
|
||||||
firstName: input.firstName,
|
|
||||||
lastName: input.lastName,
|
|
||||||
email: input.email,
|
|
||||||
passwordHash,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.recordEvent("user.signup", { actorId: profile.id });
|
|
||||||
|
|
||||||
const token = signAuthToken({
|
|
||||||
userProfileId: profile.id,
|
|
||||||
tokenVersion: profile.tokenVersion,
|
|
||||||
});
|
|
||||||
return { profile: toSafeProfile(profile), token };
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which already
|
|
||||||
// logs it, see `error-logger.ts`) is what actually handles it, this
|
|
||||||
// service layer just isn't allowed a bare `await` per the repo's
|
|
||||||
// async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const passwordHash = await argon2.hash(input.password, hashOptions);
|
||||||
|
|
||||||
|
// No household is created here — it's now an optional step of the
|
||||||
|
// onboarding wizard (create or join one, or skip — see
|
||||||
|
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
|
||||||
|
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
|
||||||
|
const profile = await prisma.userProfile.create({
|
||||||
|
data: {
|
||||||
|
firstName: input.firstName,
|
||||||
|
lastName: input.lastName,
|
||||||
|
email: input.email,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||||
|
return { profile: toSafeProfile(profile), token };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -90,21 +74,15 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||||
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
||||||
*/
|
*/
|
||||||
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
||||||
try {
|
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
|
||||||
const profile = await prisma.userProfile.findUnique({
|
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
||||||
where: { id: profileId },
|
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
||||||
});
|
|
||||||
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
|
||||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.houseId !== null) {
|
|
||||||
await leaveCurrentHouse(profile.id, profile.houseId);
|
|
||||||
}
|
|
||||||
await prisma.userProfile.delete({ where: { id: profile.id } });
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see signup()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (profile.houseId !== null) {
|
||||||
|
await leaveCurrentHouse(profile.id, profile.houseId);
|
||||||
|
}
|
||||||
|
await prisma.userProfile.delete({ where: { id: profile.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -115,21 +93,12 @@ export async function deleteAccount(profileId: number, password: string): Promis
|
||||||
* caller can never learn whether a given email has an account.
|
* caller can never learn whether a given email has an account.
|
||||||
*/
|
*/
|
||||||
export async function login(input: LoginInput): Promise<AuthResult> {
|
export async function login(input: LoginInput): Promise<AuthResult> {
|
||||||
try {
|
const profile = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||||
const profile = await prisma.userProfile.findUnique({
|
|
||||||
where: { email: input.email },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
||||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
||||||
}
|
|
||||||
|
|
||||||
const token = signAuthToken({
|
|
||||||
userProfileId: profile.id,
|
|
||||||
tokenVersion: profile.tokenVersion,
|
|
||||||
});
|
|
||||||
return { profile: toSafeProfile(profile), token };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see signup()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||||
|
return { profile: toSafeProfile(profile), token };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import {
|
import {
|
||||||
createHouseSchema,
|
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
createHouseSchema,
|
||||||
joinHouseSchema,
|
joinHouseSchema,
|
||||||
renameHouseSchema,
|
renameHouseSchema,
|
||||||
updateHouseSourcesSchema,
|
updateHouseSourcesSchema,
|
||||||
|
|
|
||||||
|
|
@ -44,18 +44,10 @@ const houseWithMembers = {
|
||||||
|
|
||||||
/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */
|
/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */
|
||||||
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
|
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return toHouseView(await findHouseOrThrow(houseId));
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
|
||||||
// this service layer just isn't allowed a bare `await` per the repo's
|
|
||||||
// async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
return toHouseView(await findHouseOrThrow(houseId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -66,20 +58,16 @@ export async function getCurrentHouse(houseId: number | null): Promise<HouseView
|
||||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||||
*/
|
*/
|
||||||
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
|
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
await findHouseOrThrow(houseId);
|
|
||||||
const house = await prisma.house.update({
|
|
||||||
where: { id: houseId },
|
|
||||||
data: { name },
|
|
||||||
include: houseWithMembers,
|
|
||||||
});
|
|
||||||
return toHouseView(house);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
await findHouseOrThrow(houseId);
|
||||||
|
const house = await prisma.house.update({
|
||||||
|
where: { id: houseId },
|
||||||
|
data: { name },
|
||||||
|
include: houseWithMembers,
|
||||||
|
});
|
||||||
|
return toHouseView(house);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -93,45 +81,30 @@ export async function createHouse(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<HouseView> {
|
): Promise<HouseView> {
|
||||||
try {
|
if (houseId !== null) {
|
||||||
if (houseId !== null) {
|
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
|
||||||
throw new HttpError(
|
|
||||||
409,
|
|
||||||
ErrorCode.ALREADY_HAS_HOUSE,
|
|
||||||
"Profile already belongs to a household",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Astronomically unlikely to collide (33^8 possibilities), but retried
|
|
||||||
// rather than assumed — a `@unique` constraint failure is the only fully
|
|
||||||
// reliable way to detect it.
|
|
||||||
const maxAttempts = 5;
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
const house = await prisma.$transaction(async (tx) => {
|
|
||||||
const created = await tx.house.create({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
adminId: profileId,
|
|
||||||
inviteCode: generateInviteCode(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.userProfile.update({
|
|
||||||
where: { id: profileId },
|
|
||||||
data: { houseId: created.id },
|
|
||||||
});
|
|
||||||
return created;
|
|
||||||
});
|
|
||||||
return await getCurrentHouseOrThrow(house.id);
|
|
||||||
} catch (err) {
|
|
||||||
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("Failed to generate a unique invite code after several attempts");
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Astronomically unlikely to collide (33^8 possibilities), but retried
|
||||||
|
// rather than assumed — a `@unique` constraint failure is the only fully
|
||||||
|
// reliable way to detect it.
|
||||||
|
const maxAttempts = 5;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const house = await prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.house.create({
|
||||||
|
data: { name, adminId: profileId, inviteCode: generateInviteCode() },
|
||||||
|
});
|
||||||
|
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } });
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
return getCurrentHouseOrThrow(house.id);
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Failed to generate a unique invite code after several attempts");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -145,32 +118,21 @@ export async function joinHouse(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
inviteCode: string,
|
inviteCode: string,
|
||||||
): Promise<HouseView> {
|
): Promise<HouseView> {
|
||||||
try {
|
if (houseId !== null) {
|
||||||
if (houseId !== null) {
|
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
|
||||||
throw new HttpError(
|
|
||||||
409,
|
|
||||||
ErrorCode.ALREADY_HAS_HOUSE,
|
|
||||||
"Profile already belongs to a household",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const house = await prisma.house.findUnique({ where: { inviteCode } });
|
|
||||||
if (!house) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.INVITE_CODE_NOT_FOUND,
|
|
||||||
"No household matches this invite code",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.userProfile.update({
|
|
||||||
where: { id: profileId },
|
|
||||||
data: { houseId: house.id },
|
|
||||||
});
|
|
||||||
return await getCurrentHouseOrThrow(house.id);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const house = await prisma.house.findUnique({ where: { inviteCode } });
|
||||||
|
if (!house) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.INVITE_CODE_NOT_FOUND,
|
||||||
|
"No household matches this invite code",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } });
|
||||||
|
return getCurrentHouseOrThrow(house.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -187,38 +149,28 @@ export async function joinHouse(
|
||||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
|
||||||
*/
|
*/
|
||||||
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
|
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
|
|
||||||
const house = await findHouseOrThrow(houseId);
|
|
||||||
const remainingMembers = house.members.filter((member) => member.id !== profileId);
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.userProfile.update({
|
|
||||||
where: { id: profileId },
|
|
||||||
data: { houseId: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (house.adminId !== profileId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (remainingMembers.length === 0) {
|
|
||||||
await tx.house.delete({ where: { id: house.id } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextAdmin = remainingMembers.reduce((oldest, member) =>
|
|
||||||
member.id < oldest.id ? member : oldest,
|
|
||||||
);
|
|
||||||
await tx.house.update({
|
|
||||||
where: { id: house.id },
|
|
||||||
data: { adminId: nextAdmin.id },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const house = await findHouseOrThrow(houseId);
|
||||||
|
const remainingMembers = house.members.filter((member) => member.id !== profileId);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } });
|
||||||
|
|
||||||
|
if (house.adminId !== profileId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (remainingMembers.length === 0) {
|
||||||
|
await tx.house.delete({ where: { id: house.id } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextAdmin = remainingMembers.reduce((oldest, member) =>
|
||||||
|
member.id < oldest.id ? member : oldest,
|
||||||
|
);
|
||||||
|
await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -231,32 +183,21 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
|
||||||
* @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
|
* @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
|
||||||
*/
|
*/
|
||||||
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
|
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
const house = await findHouseOrThrow(houseId);
|
|
||||||
if (house.adminId !== profileId) {
|
|
||||||
throw new HttpError(
|
|
||||||
403,
|
|
||||||
ErrorCode.NOT_HOUSE_ADMIN,
|
|
||||||
"Only the household's admin can delete it",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
|
|
||||||
// but clearing it explicitly first keeps the outcome obvious without
|
|
||||||
// relying on that FK behavior being read alongside this function.
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.userProfile.updateMany({
|
|
||||||
where: { houseId: house.id },
|
|
||||||
data: { houseId: null },
|
|
||||||
}),
|
|
||||||
prisma.house.delete({ where: { id: house.id } }),
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
const house = await findHouseOrThrow(houseId);
|
||||||
|
if (house.adminId !== profileId) {
|
||||||
|
throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
|
||||||
|
// but clearing it explicitly first keeps the outcome obvious without
|
||||||
|
// relying on that FK behavior being read alongside this function.
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }),
|
||||||
|
prisma.house.delete({ where: { id: house.id } }),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -274,37 +215,30 @@ export async function removeMember(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
targetMemberId: number,
|
targetMemberId: number,
|
||||||
): Promise<HouseView> {
|
): Promise<HouseView> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
const house = await findHouseOrThrow(houseId);
|
|
||||||
if (house.adminId !== profileId) {
|
|
||||||
throw new HttpError(
|
|
||||||
403,
|
|
||||||
ErrorCode.NOT_HOUSE_ADMIN,
|
|
||||||
"Only the household's admin can remove a member",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (targetMemberId === profileId) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
"Use POST /house/leave to remove yourself",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!house.members.some((member) => member.id === targetMemberId)) {
|
|
||||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.userProfile.update({
|
|
||||||
where: { id: targetMemberId },
|
|
||||||
data: { houseId: null },
|
|
||||||
});
|
|
||||||
return await getCurrentHouseOrThrow(house.id);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
const house = await findHouseOrThrow(houseId);
|
||||||
|
if (house.adminId !== profileId) {
|
||||||
|
throw new HttpError(
|
||||||
|
403,
|
||||||
|
ErrorCode.NOT_HOUSE_ADMIN,
|
||||||
|
"Only the household's admin can remove a member",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (targetMemberId === profileId) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Use POST /house/leave to remove yourself",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!house.members.some((member) => member.id === targetMemberId)) {
|
||||||
|
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userProfile.update({ where: { id: targetMemberId }, data: { houseId: null } });
|
||||||
|
return getCurrentHouseOrThrow(house.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -315,18 +249,14 @@ export async function removeMember(
|
||||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||||
*/
|
*/
|
||||||
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
|
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
const rows = await prisma.houseSource.findMany({
|
|
||||||
where: { houseId },
|
|
||||||
select: { sourceId: true },
|
|
||||||
});
|
|
||||||
return rows.map((row) => row.sourceId);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
const rows = await prisma.houseSource.findMany({
|
||||||
|
where: { houseId },
|
||||||
|
select: { sourceId: true },
|
||||||
|
});
|
||||||
|
return rows.map((row) => row.sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -343,46 +273,36 @@ export async function updateHouseSources(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
sourceIds: number[],
|
sourceIds: number[],
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
if (sourceIds.length > 0) {
|
|
||||||
const found = await prisma.source.findMany({
|
|
||||||
where: { id: { in: sourceIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((source) => source.id));
|
|
||||||
const missing = sourceIds.filter((id) => !foundIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.SOURCE_NOT_FOUND,
|
|
||||||
`Unknown source id(s): ${missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.houseSource.deleteMany({ where: { houseId } }),
|
|
||||||
prisma.houseSource.createMany({
|
|
||||||
data: sourceIds.map((sourceId) => ({ houseId, sourceId })),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return sourceIds;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
if (sourceIds.length > 0) {
|
||||||
|
const found = await prisma.source.findMany({
|
||||||
|
where: { id: { in: sourceIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const foundIds = new Set(found.map((source) => source.id));
|
||||||
|
const missing = sourceIds.filter((id) => !foundIds.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.SOURCE_NOT_FOUND,
|
||||||
|
`Unknown source id(s): ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.houseSource.deleteMany({ where: { houseId } }),
|
||||||
|
prisma.houseSource.createMany({ data: sourceIds.map((sourceId) => ({ houseId, sourceId })) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return sourceIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
|
/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
|
||||||
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
|
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
|
||||||
try {
|
return toHouseView(await findHouseOrThrow(houseId));
|
||||||
return toHouseView(await findHouseOrThrow(houseId));
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
|
/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
|
||||||
|
|
@ -404,16 +324,12 @@ function isUniqueInviteCodeViolation(err: unknown): boolean {
|
||||||
* `HOUSE_NOT_FOUND` HttpError.
|
* `HOUSE_NOT_FOUND` HttpError.
|
||||||
*/
|
*/
|
||||||
async function findHouseOrThrow(houseId: number) {
|
async function findHouseOrThrow(houseId: number) {
|
||||||
try {
|
const house = await prisma.house.findUnique({
|
||||||
const house = await prisma.house.findUnique({
|
where: { id: houseId },
|
||||||
where: { id: houseId },
|
include: houseWithMembers,
|
||||||
include: houseWithMembers,
|
});
|
||||||
});
|
if (!house) {
|
||||||
if (!house) {
|
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
||||||
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
|
||||||
}
|
|
||||||
return house;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
return house;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import {
|
|
||||||
auditBatchQuerySchema,
|
|
||||||
submitTrainingSuggestionsSchema,
|
|
||||||
workerBatchQuerySchema,
|
|
||||||
workerHeartbeatSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
|
|
||||||
import {
|
|
||||||
getAuditBatch,
|
|
||||||
getPendingCorrections,
|
|
||||||
recordWorkerHeartbeat,
|
|
||||||
submitTrainingSuggestions,
|
|
||||||
} from "./tech-step-worker.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/internal/tech-steps` in app.ts — every route requires
|
|
||||||
* {@link requireInternalWorker}, never {@link requireAuth}
|
|
||||||
* (`middlewares/require-auth.ts`): this is `services/tech-step-llm-worker`
|
|
||||||
* authenticating as itself, not a user session. See that middleware's own
|
|
||||||
* doc comment for why the two are deliberately separate mechanisms.
|
|
||||||
*/
|
|
||||||
export const techStepWorkerRouter = Router();
|
|
||||||
|
|
||||||
techStepWorkerRouter.get(
|
|
||||||
"/audit-batch",
|
|
||||||
requireInternalWorker,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const input = auditBatchQuerySchema.parse(req.query);
|
|
||||||
res.status(200).json(await getAuditBatch(input.locale, input.limit));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
techStepWorkerRouter.get(
|
|
||||||
"/pending-corrections",
|
|
||||||
requireInternalWorker,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const input = workerBatchQuerySchema.parse(req.query);
|
|
||||||
res.status(200).json(await getPendingCorrections(input.limit));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
techStepWorkerRouter.post(
|
|
||||||
"/training-suggestions",
|
|
||||||
requireInternalWorker,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const input = submitTrainingSuggestionsSchema.parse(req.body);
|
|
||||||
res.status(201).json(await submitTrainingSuggestions(input));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liveness ping from the worker (which has no inbound HTTP surface of its
|
|
||||||
* own) — upserts its `WorkerHeartbeat` row so the admin monitoring board
|
|
||||||
* can show it as up / stale / down and surface its last job result. Sent
|
|
||||||
* on boot, on every scheduler tick, and after each job.
|
|
||||||
*/
|
|
||||||
techStepWorkerRouter.post(
|
|
||||||
"/heartbeat",
|
|
||||||
requireInternalWorker,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const input = workerHeartbeatSchema.parse(req.body);
|
|
||||||
await recordWorkerHeartbeat(input);
|
|
||||||
res.status(200).json({ ok: true });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import {
|
|
||||||
ErrorCode,
|
|
||||||
type PendingTechStepCorrectionView,
|
|
||||||
type SubmitTrainingSuggestionsInput,
|
|
||||||
type TechStepAuditClauseView,
|
|
||||||
type WorkerHeartbeatInput,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import {
|
|
||||||
CONFIDENCE_THRESHOLD,
|
|
||||||
techStepClassifier,
|
|
||||||
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read/write surface `services/tech-step-llm-worker` calls through
|
|
||||||
* `/internal/tech-steps/*` (`tech-step-worker.routes.ts`, guarded by
|
|
||||||
* `requireInternalWorker`) — the worker has no Prisma client or database
|
|
||||||
* credentials of its own (see that service's own README), so every
|
|
||||||
* corrections/audit-sample read and every suggestion write goes through
|
|
||||||
* here rather than the worker touching this schema directly. Keeps
|
|
||||||
* `apps/api` the single owner of the schema/migrations, and keeps the
|
|
||||||
* worker a pure "read some text, run inference, post a suggestion" process
|
|
||||||
* with nothing to keep in sync if the schema changes shape.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many of the most recently created `Step`s {@link getAuditBatch} scans
|
|
||||||
* per call before filtering down to low-confidence clauses — a fixed
|
|
||||||
* recency-biased sample, not every `Step` in the database, to keep this
|
|
||||||
* endpoint's cost bounded regardless of how large the recipe catalog gets.
|
|
||||||
* Recently-added steps are also the steps most likely to still use
|
|
||||||
* vocabulary the training corpus hasn't caught up with yet, which is
|
|
||||||
* exactly what this audit is for. A smarter sampling strategy (e.g.
|
|
||||||
* weighted by how often a recipe is actually viewed/planned) is future
|
|
||||||
* work, not needed for this feature's first version.
|
|
||||||
*/
|
|
||||||
const AUDIT_SAMPLE_SIZE = 200;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every low-confidence clause found across a recency-biased sample of
|
|
||||||
* existing `Step`s (see {@link AUDIT_SAMPLE_SIZE}), for
|
|
||||||
* `services/tech-step-llm-worker`'s `audit-low-confidence` job to get a
|
|
||||||
* second opinion on. "Low-confidence" mirrors exactly what
|
|
||||||
* `TechStepClassifierService._classifyClause` itself distrusts (a clause
|
|
||||||
* with an NER anchor but a classifier score under
|
|
||||||
* {@link CONFIDENCE_THRESHOLD}) — the same clauses that pipeline already
|
|
||||||
* has to fall back to keyword-anchor guessing for, not an arbitrary
|
|
||||||
* separate cutoff.
|
|
||||||
*/
|
|
||||||
export async function getAuditBatch(
|
|
||||||
locale: string,
|
|
||||||
limit: number,
|
|
||||||
): Promise<TechStepAuditClauseView[]> {
|
|
||||||
try {
|
|
||||||
const steps = await prisma.step.findMany({
|
|
||||||
orderBy: { id: "desc" },
|
|
||||||
take: AUDIT_SAMPLE_SIZE,
|
|
||||||
select: { id: true, recipeId: true, description: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const results: TechStepAuditClauseView[] = [];
|
|
||||||
for (const step of steps) {
|
|
||||||
if (results.length >= limit) break;
|
|
||||||
const clauses = await techStepClassifier.classifyClauses(step.description, locale);
|
|
||||||
for (const clause of clauses) {
|
|
||||||
if (results.length >= limit) break;
|
|
||||||
const isLowConfidence = clause.anchorUid !== null && clause.score < CONFIDENCE_THRESHOLD;
|
|
||||||
if (!isLowConfidence) continue;
|
|
||||||
results.push({
|
|
||||||
stepId: step.id,
|
|
||||||
recipeId: step.recipeId,
|
|
||||||
clauseText: clause.clauseText,
|
|
||||||
anchorKey: clause.anchorUid,
|
|
||||||
intentKey: clause.intentUid,
|
|
||||||
score: clause.score,
|
|
||||||
locale,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every `StepTechStepCorrection` not yet turned into a
|
|
||||||
* `TechStepTrainingSuggestion` (`consumedAt IS NULL`), oldest first — a
|
|
||||||
* FIFO queue the worker's `transform-corrections` job drains, `limit` at a
|
|
||||||
* time.
|
|
||||||
*
|
|
||||||
* `correctedTechStepId IS NOT NULL` on top of `consumedAt IS NULL`: a
|
|
||||||
* correction that *removes* a match ("no technique belongs here",
|
|
||||||
* `correctedTechStepId: null` — see `StepTechStepCorrection`'s schema doc
|
|
||||||
* comment) has no technique to propose new positive training data *for*.
|
|
||||||
* Surfacing it here would leave it permanently unconsumable (the worker
|
|
||||||
* has nothing to submit a suggestion for, so it would never stamp
|
|
||||||
* `consumedAt`, and it would keep re-appearing in every future batch
|
|
||||||
* forever) — excluded at the source instead, not filtered/skipped
|
|
||||||
* downstream by the worker.
|
|
||||||
*/
|
|
||||||
export async function getPendingCorrections(
|
|
||||||
limit: number,
|
|
||||||
): Promise<PendingTechStepCorrectionView[]> {
|
|
||||||
try {
|
|
||||||
const corrections = await prisma.stepTechStepCorrection.findMany({
|
|
||||||
where: { consumedAt: null, correctedTechStepId: { not: null } },
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
take: limit,
|
|
||||||
include: {
|
|
||||||
step: { select: { id: true, recipeId: true, description: true } },
|
|
||||||
previousTechStep: { select: { key: true } },
|
|
||||||
correctedTechStep: { select: { key: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return corrections.map((correction) => ({
|
|
||||||
id: correction.id,
|
|
||||||
stepId: correction.step.id,
|
|
||||||
recipeId: correction.step.recipeId,
|
|
||||||
clauseText: correction.step.description.slice(correction.start, correction.end),
|
|
||||||
start: correction.start,
|
|
||||||
end: correction.end,
|
|
||||||
previousTechStepKey: correction.previousTechStep?.key ?? null,
|
|
||||||
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists a batch of `TechStepTrainingSuggestion`s and, for every
|
|
||||||
* suggestion sourced from a correction, stamps that correction's
|
|
||||||
* `consumedAt` in the same transaction — so a worker run that crashes
|
|
||||||
* partway through never leaves a correction consumed with no matching
|
|
||||||
* suggestion, or a suggestion created against a correction still (wrongly)
|
|
||||||
* eligible to be picked up again by the next run.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 TECH_STEP_NOT_FOUND` if any `techStepKey` in the
|
|
||||||
* batch doesn't match a reference `TechStep` — rejects the *whole* batch
|
|
||||||
* rather than skipping the bad entries, on the theory that a worker
|
|
||||||
* sending an unknown key is more likely a version-skew bug (its own
|
|
||||||
* taxonomy copy, `services/tech-step-llm-worker/src/tech-step-taxonomy.ts`,
|
|
||||||
* drifting from this API's `TechStep` catalog) than a one-off it should
|
|
||||||
* silently tolerate.
|
|
||||||
*/
|
|
||||||
export async function submitTrainingSuggestions(
|
|
||||||
input: SubmitTrainingSuggestionsInput,
|
|
||||||
): Promise<{ created: number }> {
|
|
||||||
try {
|
|
||||||
const techStepKeys = [
|
|
||||||
...new Set(input.suggestions.map((suggestion) => suggestion.techStepKey)),
|
|
||||||
];
|
|
||||||
const techSteps = await prisma.techStep.findMany({
|
|
||||||
where: { key: { in: techStepKeys } },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
const techStepIdByKey = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
|
||||||
const missingKeys = techStepKeys.filter((key) => !techStepIdByKey.has(key));
|
|
||||||
if (missingKeys.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.TECH_STEP_NOT_FOUND,
|
|
||||||
`Unknown techStepKey(s): ${missingKeys.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
for (const suggestion of input.suggestions) {
|
|
||||||
// Non-null by construction — every key in `input.suggestions` was
|
|
||||||
// just confirmed present in `techStepIdByKey` above (the `missingKeys`
|
|
||||||
// check would have thrown otherwise).
|
|
||||||
const techStepId = techStepIdByKey.get(suggestion.techStepKey);
|
|
||||||
if (techStepId === undefined) continue;
|
|
||||||
|
|
||||||
await tx.techStepTrainingSuggestion.create({
|
|
||||||
data: {
|
|
||||||
techStepId,
|
|
||||||
locale: suggestion.locale,
|
|
||||||
suggestedSynonyms: suggestion.suggestedSynonyms,
|
|
||||||
suggestedUtterances: suggestion.suggestedUtterances,
|
|
||||||
sourceType: suggestion.sourceType,
|
|
||||||
sourceCorrectionId: suggestion.sourceCorrectionId ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (suggestion.sourceCorrectionId !== null && suggestion.sourceCorrectionId !== undefined) {
|
|
||||||
await tx.stepTechStepCorrection.update({
|
|
||||||
where: { id: suggestion.sourceCorrectionId },
|
|
||||||
data: { consumedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { created: input.suggestions.length };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one worker with a `WorkerHeartbeat` row today — a fixed key, not
|
|
||||||
* something the caller supplies (only one worker exists, and letting it
|
|
||||||
* name itself would just be a spoofing surface behind the same shared
|
|
||||||
* secret).
|
|
||||||
*/
|
|
||||||
const WORKER_KEY = "tech-step-llm-worker";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upserts `services/tech-step-llm-worker`'s heartbeat row (see
|
|
||||||
* `POST /internal/tech-steps/heartbeat`). Every ping bumps `lastSeenAt`; a
|
|
||||||
* `"job"` ping also records `lastRunAt` + a small `lastResult` summary so
|
|
||||||
* the admin monitoring board can show what the worker last did and whether
|
|
||||||
* it worked.
|
|
||||||
*/
|
|
||||||
export async function recordWorkerHeartbeat(input: WorkerHeartbeatInput): Promise<void> {
|
|
||||||
try {
|
|
||||||
const now = new Date();
|
|
||||||
const jobResult: Prisma.InputJsonValue | undefined =
|
|
||||||
input.event === "job"
|
|
||||||
? { job: input.job ?? null, ok: input.ok ?? null, counts: input.counts ?? {} }
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await prisma.workerHeartbeat.upsert({
|
|
||||||
where: { workerKey: WORKER_KEY },
|
|
||||||
create: {
|
|
||||||
workerKey: WORKER_KEY,
|
|
||||||
lastSeenAt: now,
|
|
||||||
lastRunAt: input.event === "job" ? now : null,
|
|
||||||
lastResult: jobResult,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
lastSeenAt: now,
|
|
||||||
...(input.event === "job" ? { lastRunAt: now, lastResult: jobResult } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import {
|
import { ErrorCode, addPlanningItemSchema, getPlanningByDateSchema } from "@batch-cooking/shared";
|
||||||
addPlanningItemSchema,
|
|
||||||
assignLeftoverSchema,
|
|
||||||
ErrorCode,
|
|
||||||
getPlanningByDateSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import {
|
import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js";
|
||||||
addPlanningItem,
|
|
||||||
assignLeftoverPlanningItem,
|
|
||||||
discardLeftover,
|
|
||||||
getPlanningForDate,
|
|
||||||
removePlanningItem,
|
|
||||||
} from "./planning.service.js";
|
|
||||||
|
|
||||||
/** Router mounted at `/planning` in app.ts. */
|
/** Router mounted at `/planning` in app.ts. */
|
||||||
export const planningRouter = Router();
|
export const planningRouter = Router();
|
||||||
|
|
@ -80,44 +69,6 @@ planningRouter.post(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Serves part of another planning item's leftover pool on a slot — creates
|
|
||||||
* a "leftovers" `PlanningItem` (see {@link assignLeftoverPlanningItem}).
|
|
||||||
* `POST /items/leftover`, not `/items/:id/...`: the `:id` here would be the
|
|
||||||
* *target* slot, which doesn't exist yet — the source is in the body.
|
|
||||||
*/
|
|
||||||
planningRouter.post(
|
|
||||||
"/items/leftover",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const input = assignLeftoverSchema.parse(req.body);
|
|
||||||
const date = parseDateOnly(input.date);
|
|
||||||
if (date === null) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
`Not a real calendar date: ${input.date}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
|
||||||
const item = await assignLeftoverPlanningItem(houseId, viewerId, date, input);
|
|
||||||
res.status(201).json(item);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Throws away item `:id`'s whole remaining leftover pool (idempotent — see {@link discardLeftover}). */
|
|
||||||
planningRouter.patch(
|
|
||||||
"/items/:id/discard-leftover",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const id = parsePlanningItemId(req.params.id);
|
|
||||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
|
||||||
const item = await discardLeftover(id, viewerId, houseId);
|
|
||||||
res.status(200).json(item);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Removes one recipe from a planning slot. */
|
/** Removes one recipe from a planning slot. */
|
||||||
planningRouter.delete(
|
planningRouter.delete(
|
||||||
"/items/:id",
|
"/items/:id",
|
||||||
|
|
|
||||||
|
|
@ -2,46 +2,13 @@ import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-too
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import {
|
import {
|
||||||
type AddPlanningItemInput,
|
type AddPlanningItemInput,
|
||||||
type AssignLeftoverInput,
|
|
||||||
computeAvailableLeftover,
|
|
||||||
computeBatchedPortions,
|
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type PlanningItemView,
|
type PlanningItemView,
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
import { assertRecipeVisible } from "../recipe/recipe.service.js";
|
import { assertRecipeVisible } from "../recipe/recipe.service.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* One `PlanningItem` row (with its recipe resolved to `{id, name}`) shaped
|
|
||||||
* into the wire {@link PlanningItemView}. The three read/write paths below
|
|
||||||
* that return an item — `getPlanningForDate`, `addPlanningItem`,
|
|
||||||
* `assignLeftoverPlanningItem`, `discardLeftover` — all go through this so
|
|
||||||
* the shape is defined once.
|
|
||||||
*/
|
|
||||||
function toPlanningItemView(item: {
|
|
||||||
id: number;
|
|
||||||
weekDay: string;
|
|
||||||
meal: string;
|
|
||||||
portions: number;
|
|
||||||
portionsRequested: number;
|
|
||||||
discardedPortions: number;
|
|
||||||
sourcePlanningItemId: number | null;
|
|
||||||
recipe: { id: number; name: string };
|
|
||||||
}): PlanningItemView {
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
weekDay: item.weekDay,
|
|
||||||
meal: item.meal,
|
|
||||||
portions: item.portions,
|
|
||||||
portionsRequested: item.portionsRequested,
|
|
||||||
discardedPortions: item.discardedPortions,
|
|
||||||
sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null,
|
|
||||||
recipe: item.recipe,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the household's planning that covers `date` and shapes it into a
|
* Finds the household's planning that covers `date` and shapes it into a
|
||||||
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
|
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
|
||||||
|
|
@ -60,51 +27,49 @@ export async function getPlanningForDate(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
date: DateTime,
|
date: DateTime,
|
||||||
): Promise<PlanningView | null> {
|
): Promise<PlanningView | null> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
|
||||||
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
|
||||||
// how Postgres stores/returns them, regardless of the server's local
|
|
||||||
// timezone.
|
|
||||||
const dateOnly = toDateOnly(date).toJSDate();
|
|
||||||
|
|
||||||
const planning = await prisma.planning.findFirst({
|
|
||||||
where: {
|
|
||||||
houseId,
|
|
||||||
startDate: { lte: dateOnly },
|
|
||||||
finishDate: { gte: dateOnly },
|
|
||||||
},
|
|
||||||
// A household should never have two plannings covering the same day,
|
|
||||||
// but nothing in the schema enforces that yet — pick the most recently
|
|
||||||
// started one rather than letting the query fail if it ever happens.
|
|
||||||
orderBy: { startDate: "desc" },
|
|
||||||
include: {
|
|
||||||
items: {
|
|
||||||
include: { recipe: { select: { id: true, name: true } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!planning) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: planning.id,
|
|
||||||
startDate: planning.startDate.toISOString(),
|
|
||||||
finishDate: planning.finishDate.toISOString(),
|
|
||||||
items: planning.items.map(toPlanningItemView),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
|
||||||
// this service layer just isn't allowed a bare `await` per the repo's
|
|
||||||
// async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
||||||
|
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
||||||
|
// how Postgres stores/returns them, regardless of the server's local
|
||||||
|
// timezone.
|
||||||
|
const dateOnly = toDateOnly(date).toJSDate();
|
||||||
|
|
||||||
|
const planning = await prisma.planning.findFirst({
|
||||||
|
where: {
|
||||||
|
houseId,
|
||||||
|
startDate: { lte: dateOnly },
|
||||||
|
finishDate: { gte: dateOnly },
|
||||||
|
},
|
||||||
|
// A household should never have two plannings covering the same day,
|
||||||
|
// but nothing in the schema enforces that yet — pick the most recently
|
||||||
|
// started one rather than letting the query fail if it ever happens.
|
||||||
|
orderBy: { startDate: "desc" },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
include: { recipe: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!planning) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: planning.id,
|
||||||
|
startDate: planning.startDate.toISOString(),
|
||||||
|
finishDate: planning.finishDate.toISOString(),
|
||||||
|
items: planning.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
weekDay: item.weekDay,
|
||||||
|
meal: item.meal,
|
||||||
|
portions: item.portions,
|
||||||
|
recipe: item.recipe,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,24 +88,14 @@ export async function getPlanningForDate(
|
||||||
* scale rather than adding a migration + retry-on-conflict loop for it.
|
* scale rather than adding a migration + retry-on-conflict loop for it.
|
||||||
*/
|
*/
|
||||||
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
|
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
|
||||||
try {
|
const startDate = weekStart.toJSDate();
|
||||||
const startDate = weekStart.toJSDate();
|
const existing = await prisma.planning.findFirst({ where: { houseId, startDate } });
|
||||||
const existing = await prisma.planning.findFirst({
|
if (existing) {
|
||||||
where: { houseId, startDate },
|
return existing;
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
return await prisma.planning.create({
|
|
||||||
data: {
|
|
||||||
houseId,
|
|
||||||
startDate,
|
|
||||||
finishDate: weekStart.plus({ days: 6 }).toJSDate(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getPlanningForDate()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
return prisma.planning.create({
|
||||||
|
data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -164,52 +119,32 @@ export async function addPlanningItem(
|
||||||
date: DateTime,
|
date: DateTime,
|
||||||
input: AddPlanningItemInput,
|
input: AddPlanningItemInput,
|
||||||
): Promise<PlanningItemView> {
|
): Promise<PlanningItemView> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
|
|
||||||
|
|
||||||
// `assertRecipeVisible` already guaranteed the recipe exists and is
|
|
||||||
// visible — this second read just pulls its written yield, needed to
|
|
||||||
// turn "portions requested" into "portions to actually prepare"
|
|
||||||
// (a whole number of batches, see `computeBatchedPortions`). The
|
|
||||||
// `?? 1` never triggers in practice (the assert above threw otherwise);
|
|
||||||
// it only keeps TypeScript happy about `findUnique`'s `| null`.
|
|
||||||
const recipe = await prisma.recipe.findUnique({
|
|
||||||
where: { id: input.recipeId },
|
|
||||||
select: { portions: true },
|
|
||||||
});
|
|
||||||
const { portions } = computeBatchedPortions(recipe?.portions ?? 1, input.portionsRequested);
|
|
||||||
|
|
||||||
const weekStart = getWeekStart(toDateOnly(date));
|
|
||||||
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
|
||||||
|
|
||||||
const item = await prisma.planningItem.create({
|
|
||||||
data: {
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: input.weekDay,
|
|
||||||
meal: input.meal,
|
|
||||||
recipeId: input.recipeId,
|
|
||||||
portions,
|
|
||||||
portionsRequested: input.portionsRequested,
|
|
||||||
},
|
|
||||||
include: { recipe: { select: { id: true, name: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.recordEvent("planning.item_added", {
|
|
||||||
actorId: viewerId,
|
|
||||||
context: {
|
|
||||||
recipeId: input.recipeId,
|
|
||||||
portions,
|
|
||||||
portionsRequested: input.portionsRequested,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return toPlanningItemView(item);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getPlanningForDate()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
|
||||||
|
|
||||||
|
const weekStart = getWeekStart(toDateOnly(date));
|
||||||
|
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
||||||
|
|
||||||
|
const item = await prisma.planningItem.create({
|
||||||
|
data: {
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: input.weekDay,
|
||||||
|
meal: input.meal,
|
||||||
|
recipeId: input.recipeId,
|
||||||
|
portions: input.portions,
|
||||||
|
},
|
||||||
|
include: { recipe: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
weekDay: item.weekDay,
|
||||||
|
meal: item.meal,
|
||||||
|
portions: item.portions,
|
||||||
|
recipe: item.recipe,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -221,152 +156,12 @@ export async function addPlanningItem(
|
||||||
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
|
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
|
||||||
*/
|
*/
|
||||||
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
|
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
|
||||||
try {
|
|
||||||
const item = await prisma.planningItem.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: { planning: true },
|
|
||||||
});
|
|
||||||
if (!item || houseId === null || item.planning.houseId !== houseId) {
|
|
||||||
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
|
|
||||||
}
|
|
||||||
await prisma.planningItem.delete({ where: { id } });
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getPlanningForDate()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads a `PlanningItem` (scoped to `houseId`) with everything
|
|
||||||
* {@link computeAvailableLeftover} needs — its own portion fields plus how
|
|
||||||
* much of its pool has already been served elsewhere (`leftoverUses`) — or
|
|
||||||
* throws `404 PLANNING_ITEM_NOT_FOUND` (same "don't confirm what exists"
|
|
||||||
* posture as `removePlanningItem`). Shared by
|
|
||||||
* {@link assignLeftoverPlanningItem} and {@link discardLeftover}.
|
|
||||||
*/
|
|
||||||
async function loadItemWithLeftoverPool(id: number, houseId: number | null) {
|
|
||||||
const item = await prisma.planningItem.findUnique({
|
const item = await prisma.planningItem.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: { planning: true },
|
||||||
planning: { select: { houseId: true } },
|
|
||||||
leftoverUses: { select: { portions: true } },
|
|
||||||
recipe: { select: { id: true, name: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!item || houseId === null || item.planning.houseId !== houseId) {
|
if (!item || houseId === null || item.planning.houseId !== houseId) {
|
||||||
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
|
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
|
||||||
}
|
}
|
||||||
return item;
|
await prisma.planningItem.delete({ where: { id } });
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serves `input.portions` of another item's leftover pool on the
|
|
||||||
* `(date, weekDay, meal)` slot — creates a "leftovers" `PlanningItem`
|
|
||||||
* (`sourcePlanningItemId` set) on the same recipe as its source. Nothing
|
|
||||||
* new is cooked for it, so `portions === portionsRequested` and it excludes
|
|
||||||
* itself from the shopping list / cooking session (see those services'
|
|
||||||
* `where: { sourcePlanningItemId: null }`).
|
|
||||||
*
|
|
||||||
* The source's `recipeId` is copied, never taken from the client (a
|
|
||||||
* leftovers item can only be its source's recipe). `input.portions` is
|
|
||||||
* re-checked against the pool actually still available
|
|
||||||
* ({@link computeAvailableLeftover}) — trusting a client value here would
|
|
||||||
* let a household conjure portions it never cooked.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null`.
|
|
||||||
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `sourceItemId` doesn't exist, belongs to another household, or is itself a leftovers item (no leftovers-on-leftovers).
|
|
||||||
* @throws {HttpError} `409 LEFTOVER_QUANTITY_EXCEEDED` if `portions` exceeds the source's still-available pool.
|
|
||||||
*/
|
|
||||||
export async function assignLeftoverPlanningItem(
|
|
||||||
houseId: number | null,
|
|
||||||
viewerId: number,
|
|
||||||
date: DateTime,
|
|
||||||
input: AssignLeftoverInput,
|
|
||||||
): Promise<PlanningItemView> {
|
|
||||||
try {
|
|
||||||
if (houseId === null) {
|
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = await loadItemWithLeftoverPool(input.sourceItemId, houseId);
|
|
||||||
if (source.sourcePlanningItemId !== null) {
|
|
||||||
// A leftovers item has no pool of its own — serving leftovers cooks
|
|
||||||
// nothing, so it can't be a source for more.
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.PLANNING_ITEM_NOT_FOUND,
|
|
||||||
`Planning item ${input.sourceItemId} is not a leftovers source`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const available = computeAvailableLeftover(source);
|
|
||||||
if (input.portions > available) {
|
|
||||||
throw new HttpError(
|
|
||||||
409,
|
|
||||||
ErrorCode.LEFTOVER_QUANTITY_EXCEEDED,
|
|
||||||
`Item ${source.id} has ${available} leftover portion(s) available, asked for ${input.portions}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const weekStart = getWeekStart(toDateOnly(date));
|
|
||||||
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
|
||||||
|
|
||||||
const item = await prisma.planningItem.create({
|
|
||||||
data: {
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: input.weekDay,
|
|
||||||
meal: input.meal,
|
|
||||||
recipeId: source.recipeId,
|
|
||||||
portions: input.portions,
|
|
||||||
portionsRequested: input.portions,
|
|
||||||
sourcePlanningItemId: source.id,
|
|
||||||
},
|
|
||||||
include: { recipe: { select: { id: true, name: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.recordEvent("planning.leftover_assigned", {
|
|
||||||
actorId: viewerId,
|
|
||||||
context: { recipeId: source.recipeId, portions: input.portions, sourceItemId: source.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return toPlanningItemView(item);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getPlanningForDate()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Throws away the whole still-available leftover pool of item `id`
|
|
||||||
* (`discardedPortions += available`) — the basket's "jeter" button.
|
|
||||||
* Idempotent: an item with nothing left to discard is returned unchanged,
|
|
||||||
* no event recorded.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` — same guard as `removePlanningItem`.
|
|
||||||
*/
|
|
||||||
export async function discardLeftover(
|
|
||||||
id: number,
|
|
||||||
viewerId: number,
|
|
||||||
houseId: number | null,
|
|
||||||
): Promise<PlanningItemView> {
|
|
||||||
try {
|
|
||||||
const item = await loadItemWithLeftoverPool(id, houseId);
|
|
||||||
const available = computeAvailableLeftover(item);
|
|
||||||
if (available === 0) {
|
|
||||||
return toPlanningItemView(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await prisma.planningItem.update({
|
|
||||||
where: { id },
|
|
||||||
data: { discardedPortions: item.discardedPortions + available },
|
|
||||||
include: { recipe: { select: { id: true, name: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.recordEvent("planning.leftover_discarded", {
|
|
||||||
actorId: viewerId,
|
|
||||||
context: { recipeId: item.recipe.id, portions: available },
|
|
||||||
});
|
|
||||||
|
|
||||||
return toPlanningItemView(updated);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getPlanningForDate()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,8 @@ import { prisma } from "../../db/prisma.js";
|
||||||
* just to read it.
|
* just to read it.
|
||||||
*/
|
*/
|
||||||
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
||||||
try {
|
const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
|
||||||
const preferences = await prisma.userPreference.findUnique({
|
return { theme: preferences?.theme ?? "SYSTEM" };
|
||||||
where: { userProfileId },
|
|
||||||
});
|
|
||||||
return { theme: preferences?.theme ?? "SYSTEM" };
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
|
||||||
// this service layer just isn't allowed a bare `await` per the repo's
|
|
||||||
// async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,14 +22,10 @@ export async function updatePreferences(
|
||||||
userProfileId: number,
|
userProfileId: number,
|
||||||
theme: ThemePreference,
|
theme: ThemePreference,
|
||||||
): Promise<PreferencesView> {
|
): Promise<PreferencesView> {
|
||||||
try {
|
const preferences = await prisma.userPreference.upsert({
|
||||||
const preferences = await prisma.userPreference.upsert({
|
where: { userProfileId },
|
||||||
where: { userProfileId },
|
create: { userProfileId, theme },
|
||||||
create: { userProfileId, theme },
|
update: { theme },
|
||||||
update: { theme },
|
});
|
||||||
});
|
return { theme: preferences.theme };
|
||||||
return { theme: preferences.theme };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getPreferences()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
|
import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
import { toSafeProfile } from "../../lib/safe-profile.js";
|
import { toSafeProfile } from "../../lib/safe-profile.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,39 +14,27 @@ export async function updateDiet(
|
||||||
userProfileId: number,
|
userProfileId: number,
|
||||||
dietId: number | null,
|
dietId: number | null,
|
||||||
): Promise<SafeUserProfile> {
|
): Promise<SafeUserProfile> {
|
||||||
try {
|
if (dietId !== null) {
|
||||||
if (dietId !== null) {
|
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
|
||||||
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
|
if (!diet) {
|
||||||
if (!diet) {
|
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
|
||||||
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await prisma.userProfile.update({
|
|
||||||
where: { id: userProfileId },
|
|
||||||
data: { dietId },
|
|
||||||
});
|
|
||||||
return toSafeProfile(profile);
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
|
||||||
// this service layer just isn't allowed a bare `await` per the repo's
|
|
||||||
// async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profile = await prisma.userProfile.update({
|
||||||
|
where: { id: userProfileId },
|
||||||
|
data: { dietId },
|
||||||
|
});
|
||||||
|
return toSafeProfile(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */
|
/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */
|
||||||
export async function getAllergyIds(userProfileId: number): Promise<number[]> {
|
export async function getAllergyIds(userProfileId: number): Promise<number[]> {
|
||||||
try {
|
const rows = await prisma.userProfileAllergy.findMany({
|
||||||
const rows = await prisma.userProfileAllergy.findMany({
|
where: { userProfileId },
|
||||||
where: { userProfileId },
|
select: { allergyId: true },
|
||||||
select: { allergyId: true },
|
});
|
||||||
});
|
return rows.map((row) => row.allergyId);
|
||||||
return rows.map((row) => row.allergyId);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see updateDiet()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -62,47 +49,39 @@ export async function updateAllergies(
|
||||||
userProfileId: number,
|
userProfileId: number,
|
||||||
allergyIds: number[],
|
allergyIds: number[],
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
try {
|
if (allergyIds.length > 0) {
|
||||||
if (allergyIds.length > 0) {
|
const found = await prisma.allergy.findMany({
|
||||||
const found = await prisma.allergy.findMany({
|
where: { id: { in: allergyIds } },
|
||||||
where: { id: { in: allergyIds } },
|
select: { id: true },
|
||||||
select: { id: true },
|
});
|
||||||
});
|
const foundIds = new Set(found.map((allergy) => allergy.id));
|
||||||
const foundIds = new Set(found.map((allergy) => allergy.id));
|
const missing = allergyIds.filter((id) => !foundIds.has(id));
|
||||||
const missing = allergyIds.filter((id) => !foundIds.has(id));
|
if (missing.length > 0) {
|
||||||
if (missing.length > 0) {
|
throw new HttpError(
|
||||||
throw new HttpError(
|
404,
|
||||||
404,
|
ErrorCode.ALLERGY_NOT_FOUND,
|
||||||
ErrorCode.ALLERGY_NOT_FOUND,
|
`Unknown allergy id(s): ${missing.join(", ")}`,
|
||||||
`Unknown allergy id(s): ${missing.join(", ")}`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }),
|
|
||||||
prisma.userProfileAllergy.createMany({
|
|
||||||
data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return allergyIds;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see updateDiet()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }),
|
||||||
|
prisma.userProfileAllergy.createMany({
|
||||||
|
data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return allergyIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */
|
/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */
|
||||||
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
|
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
|
||||||
try {
|
const rows = await prisma.userProfileDislikedIngredient.findMany({
|
||||||
const rows = await prisma.userProfileDislikedIngredient.findMany({
|
where: { userProfileId },
|
||||||
where: { userProfileId },
|
select: { ingredientId: true },
|
||||||
select: { ingredientId: true },
|
});
|
||||||
});
|
return rows.map((row) => row.ingredientId);
|
||||||
return rows.map((row) => row.ingredientId);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see updateDiet()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -116,91 +95,28 @@ export async function updateDislikedIngredients(
|
||||||
userProfileId: number,
|
userProfileId: number,
|
||||||
dislikedIngredientIds: number[],
|
dislikedIngredientIds: number[],
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
try {
|
if (dislikedIngredientIds.length > 0) {
|
||||||
if (dislikedIngredientIds.length > 0) {
|
const found = await prisma.ingredient.findMany({
|
||||||
const found = await prisma.ingredient.findMany({
|
where: { id: { in: dislikedIngredientIds } },
|
||||||
where: { id: { in: dislikedIngredientIds } },
|
select: { id: true },
|
||||||
select: { id: true },
|
});
|
||||||
});
|
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
|
||||||
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
|
if (missing.length > 0) {
|
||||||
if (missing.length > 0) {
|
throw new HttpError(
|
||||||
throw new HttpError(
|
404,
|
||||||
404,
|
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
`Unknown ingredient id(s): ${missing.join(", ")}`,
|
||||||
`Unknown ingredient id(s): ${missing.join(", ")}`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.userProfileDislikedIngredient.deleteMany({
|
|
||||||
where: { userProfileId },
|
|
||||||
}),
|
|
||||||
prisma.userProfileDislikedIngredient.createMany({
|
|
||||||
data: dislikedIngredientIds.map((ingredientId) => ({
|
|
||||||
userProfileId,
|
|
||||||
ingredientId,
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return dislikedIngredientIds;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see updateDiet()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `key`s of every guided-tour scenario this profile has already seen
|
|
||||||
* (completed or skipped — both leave a `TourProgress` row). The web tour
|
|
||||||
* engine reads this once per session to decide which tours to auto-start
|
|
||||||
* (issues #42/#44).
|
|
||||||
*/
|
|
||||||
export async function getCompletedTourKeys(userProfileId: number): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.tourProgress.findMany({
|
|
||||||
where: { userProfileId },
|
|
||||||
select: { scenario: { select: { key: true } } },
|
|
||||||
});
|
|
||||||
return rows.map((row) => row.scenario.key);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see updateDiet()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a guided-tour scenario as seen by this profile — idempotent upsert,
|
|
||||||
* safe to call again (e.g. the engine retries after a flaky network). Emits
|
|
||||||
* `tour.completed` or `tour.skipped` for the admin dashboard (issue #44's
|
|
||||||
* analytics — the dashboard renders any new `AnalyticsEvent` type
|
|
||||||
* generically, no frontend change needed).
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `tourKey` doesn't match an
|
|
||||||
* **enabled** `TourScenario` (a disabled tour can't be completed).
|
|
||||||
*/
|
|
||||||
export async function markTourSeen(
|
|
||||||
userProfileId: number,
|
|
||||||
tourKey: string,
|
|
||||||
skipped: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const scenario = await prisma.tourScenario.findUnique({ where: { key: tourKey } });
|
|
||||||
if (!scenario?.enabled) {
|
|
||||||
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No enabled tour with key ${tourKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.tourProgress.upsert({
|
|
||||||
where: { userProfileId_scenarioId: { userProfileId, scenarioId: scenario.id } },
|
|
||||||
update: {},
|
|
||||||
create: { userProfileId, scenarioId: scenario.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.recordEvent(skipped ? "tour.skipped" : "tour.completed", {
|
|
||||||
actorId: userProfileId,
|
|
||||||
context: { tourKey },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see updateDiet()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId } }),
|
||||||
|
prisma.userProfileDislikedIngredient.createMany({
|
||||||
|
data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId })),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return dislikedIngredientIds;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,619 +0,0 @@
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import {
|
|
||||||
ErrorCode,
|
|
||||||
type StepTechStepCorrectionView,
|
|
||||||
type SubmitTechStepCorrectionInput,
|
|
||||||
type SubmitTechStepCorrectionResult,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User-submitted corrections to a step's detected techniques
|
|
||||||
* (`StepTechStepCorrection` in schema.prisma) — kept in its own module
|
|
||||||
* rather than folded into `recipe.service.ts`, same "one file per concern"
|
|
||||||
* split that file itself follows for `tech-step-matcher.ts`. Deliberately
|
|
||||||
* open to *any* viewer who can see the recipe, not just its author (unlike
|
|
||||||
* every write path in `recipe.service.ts`, which uses `assertIsAuthor`) —
|
|
||||||
* correcting a mislabeled technique isn't editing the recipe's own
|
|
||||||
* content, and restricting it to authors would starve the training-data
|
|
||||||
* feedback loop (`services/tech-step-llm-worker`) of the volume it needs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type CorrectionWithTechSteps = Prisma.StepTechStepCorrectionGetPayload<{
|
|
||||||
include: { previousTechStep: true; correctedTechStep: true };
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const correctionInclude = {
|
|
||||||
previousTechStep: true,
|
|
||||||
correctedTechStep: true,
|
|
||||||
} satisfies Prisma.StepTechStepCorrectionInclude;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads `stepId`'s current `description` (a correction needs its full text
|
|
||||||
* now, not just its length — see {@link submitTechStepCorrection}'s
|
|
||||||
* `raw` derivation for a manual temperature mention), or throws —
|
|
||||||
* `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't
|
|
||||||
* belong to `recipeId` (the route's own `:id`/`:stepId` nesting is
|
|
||||||
* meaningless otherwise — a request naming a real step under the wrong
|
|
||||||
* recipe should look identical to naming one that doesn't exist, same
|
|
||||||
* "don't leak which part was wrong" posture `assertRecipeVisible` already
|
|
||||||
* has for visibility). Otherwise whatever {@link assertRecipeVisible}
|
|
||||||
* throws (`404 RECIPE_NOT_FOUND`, never `403`) if the recipe exists but
|
|
||||||
* isn't visible to the viewer.
|
|
||||||
*/
|
|
||||||
async function loadVisibleStepOrThrow(
|
|
||||||
recipeId: number,
|
|
||||||
stepId: number,
|
|
||||||
viewerId: number,
|
|
||||||
viewerHouseId: number | null,
|
|
||||||
): Promise<{ id: number; description: string }> {
|
|
||||||
try {
|
|
||||||
const step = await prisma.step.findUnique({
|
|
||||||
where: { id: stepId },
|
|
||||||
select: { id: true, recipeId: true, description: true },
|
|
||||||
});
|
|
||||||
if (!step || step.recipeId !== recipeId) {
|
|
||||||
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
|
|
||||||
}
|
|
||||||
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
|
|
||||||
return { id: step.id, description: step.description };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Throws `404 TECH_STEP_NOT_FOUND` if any id in `ids` doesn't match a reference `TechStep` row — same shape as `recipe.service.ts`'s `assertIngredientsExist`/`assertUnitsExist` for the recipe payload's own reference ids. */
|
|
||||||
async function assertTechStepsExist(ids: number[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (ids.length === 0) return;
|
|
||||||
const found = await prisma.techStep.findMany({
|
|
||||||
where: { id: { in: ids } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((techStep) => techStep.id));
|
|
||||||
const missing = ids.filter((id) => !foundIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.TECH_STEP_NOT_FOUND,
|
|
||||||
`TechStep ids not found: ${missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Throws `404 INGREDIENT_NOT_FOUND` if any id in `ids` doesn't match a reference `Ingredient` row — same shape as {@link assertTechStepsExist}, checking `input.ingredients[].ingredientId` instead. */
|
|
||||||
async function assertIngredientsExist(ids: number[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const uniqueIds = [...new Set(ids)];
|
|
||||||
if (uniqueIds.length === 0) return;
|
|
||||||
const found = await prisma.ingredient.findMany({
|
|
||||||
where: { id: { in: uniqueIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
|
||||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
|
||||||
`Ingredient ids not found: ${missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Throws `404 UNIT_NOT_FOUND` if any id in `ids` doesn't match a reference `Unit` row — same shape as {@link assertIngredientsExist}, checking `input.ingredients[].unitId` instead. */
|
|
||||||
async function assertUnitsExist(ids: number[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const uniqueIds = [...new Set(ids)];
|
|
||||||
if (uniqueIds.length === 0) return;
|
|
||||||
const found = await prisma.unit.findMany({
|
|
||||||
where: { id: { in: uniqueIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((unit) => unit.id));
|
|
||||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.UNIT_NOT_FOUND,
|
|
||||||
`Unit ids not found: ${missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Throws `404 UTENSIL_NOT_FOUND` if any id in `ids` doesn't match a reference `Utensil` row — same shape as {@link assertIngredientsExist}, checking `input.utensils[].utensilId` instead. */
|
|
||||||
async function assertUtensilsExist(ids: number[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const uniqueIds = [...new Set(ids)];
|
|
||||||
if (uniqueIds.length === 0) return;
|
|
||||||
const found = await prisma.utensil.findMany({
|
|
||||||
where: { id: { in: uniqueIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((utensil) => utensil.id));
|
|
||||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.UTENSIL_NOT_FOUND,
|
|
||||||
`Utensil ids not found: ${missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
|
|
||||||
* ascending `start` (nulls-still-possible legacy rows, see that model's
|
|
||||||
* schema doc comment, sort last) — the dense, reading-order 0-based
|
|
||||||
* sequence `@@id([stepId, order])` requires, regardless of whether a
|
|
||||||
* caller just inserted, updated, or deleted a row. Simpler and less
|
|
||||||
* error-prone than shifting only the affected neighbors' `order` by hand.
|
|
||||||
*
|
|
||||||
* Two passes, through a disjoint negative range first: updating straight
|
|
||||||
* into the final 0..N-1 positions in one pass risks a transient
|
|
||||||
* `(stepId, order)` collision (e.g. the row destined for `order: 0` isn't
|
|
||||||
* necessarily the one already sitting there) — `order` is always `>= 0`
|
|
||||||
* in real usage, so a negative range can never collide with a live row.
|
|
||||||
*
|
|
||||||
* Exported for `scripts/backfill-tech-steps.ts` to reuse after it
|
|
||||||
* recomputes just the `"auto"` subset of a step's rows, so the combined
|
|
||||||
* `"auto"` + `"manual"` sequence still ends up in one coherent
|
|
||||||
* reading-order.
|
|
||||||
*/
|
|
||||||
export async function renumberStepTechSteps(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
stepId: number,
|
|
||||||
): Promise<void> {
|
|
||||||
const rows = await tx.stepTechStep.findMany({ where: { stepId } });
|
|
||||||
const sorted = [...rows].sort(
|
|
||||||
(a, b) => (a.start ?? Number.POSITIVE_INFINITY) - (b.start ?? Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
for (const [index, row] of sorted.entries()) {
|
|
||||||
await tx.stepTechStep.update({
|
|
||||||
where: { stepId_order: { stepId, order: row.order } },
|
|
||||||
data: { order: -(index + 1) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const [index] of sorted.entries()) {
|
|
||||||
await tx.stepTechStep.update({
|
|
||||||
where: { stepId_order: { stepId, order: -(index + 1) } },
|
|
||||||
data: { order: index },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One ingredient/utensil mention the viewer themselves selected, ready to persist — see {@link applyManualCorrection}'s own doc comment for the "manual replaces all" semantics these are written under. */
|
|
||||||
interface ManualIngredientMention {
|
|
||||||
ingredientId: number;
|
|
||||||
quantity: number | null;
|
|
||||||
unitId: number | null;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
interface ManualUtensilMention {
|
|
||||||
utensilId: number;
|
|
||||||
start: 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`
|
|
||||||
* sequence, immediately — not just recorded as a pending suggestion for
|
|
||||||
* `services/tech-step-llm-worker` to eventually process (see
|
|
||||||
* `StepTechStepCorrection`'s schema doc comment; this is *in addition to*
|
|
||||||
* that offline feedback loop, not instead of it). `previousTechStepId`/
|
|
||||||
* `correctedTechStepId` mean exactly what they do on
|
|
||||||
* `StepTechStepCorrection` itself (`SubmitTechStepCorrectionInput`'s doc
|
|
||||||
* comment, `packages/shared`):
|
|
||||||
*
|
|
||||||
* - `correctedTechStepId` set (add or relabel): a `"manual"` row is
|
|
||||||
* written at the correction's own `[start, end)` — updating the
|
|
||||||
* existing entry in place when one matching `previousTechStepId`
|
|
||||||
* overlaps this span, otherwise inserting a new one. No `contextStart`/
|
|
||||||
* `contextEnd` — a correction only ever carries the tight span the user
|
|
||||||
* themselves selected/clicked, nothing wider to highlight around it.
|
|
||||||
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
|
|
||||||
* matching existing entry is deleted outright (cascading away any
|
|
||||||
* ingredient/utensil metadata attached to it, auto or manual — nothing
|
|
||||||
* left to attach metadata to once the technique itself is gone). A
|
|
||||||
* no-op if none matches (nothing to remove).
|
|
||||||
*
|
|
||||||
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
|
|
||||||
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
|
|
||||||
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/
|
|
||||||
* `StepTechStepTemperature` row on this occurrence — `source: "auto"` (the
|
|
||||||
* classifier's own detection) and any earlier `"manual"` set alike — with
|
|
||||||
* the newly-submitted one. This is "le manuel remplace tout" (confirmed
|
|
||||||
* with the user): the resolved `order` this technique ends up at
|
|
||||||
* (whichever branch above produced it) is the same `techStepOrder` all
|
|
||||||
* three metadata tables key on, so the same `deleteMany` + `createMany`
|
|
||||||
* pair below is correct whether this call just updated an existing row
|
|
||||||
* (which may already carry auto-detected metadata) or created a brand new
|
|
||||||
* one (nothing to delete yet — a no-op `deleteMany`, not a special case).
|
|
||||||
*
|
|
||||||
* Runs inside the same transaction {@link submitTechStepCorrection} uses
|
|
||||||
* for the audit-trail insert, so a request never leaves any of these
|
|
||||||
* effects (the permanent correction record, the live sequence change, the
|
|
||||||
* metadata replacement) only partially applied.
|
|
||||||
*/
|
|
||||||
async function applyManualCorrection(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
stepId: number,
|
|
||||||
span: { start: number; end: number },
|
|
||||||
previousTechStepId: number | null,
|
|
||||||
correctedTechStepId: number | null,
|
|
||||||
metadata?: {
|
|
||||||
ingredients: ManualIngredientMention[];
|
|
||||||
utensils: ManualUtensilMention[];
|
|
||||||
temperatures: ManualTemperatureMention[];
|
|
||||||
},
|
|
||||||
): Promise<void> {
|
|
||||||
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
|
|
||||||
|
|
||||||
const target =
|
|
||||||
previousTechStepId !== null
|
|
||||||
? existing.find(
|
|
||||||
(row) =>
|
|
||||||
row.techStepId === previousTechStepId &&
|
|
||||||
row.start !== null &&
|
|
||||||
row.end !== null &&
|
|
||||||
row.start < span.end &&
|
|
||||||
span.start < row.end,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (correctedTechStepId !== null) {
|
|
||||||
const order = target
|
|
||||||
? target.order
|
|
||||||
: existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
|
|
||||||
if (target) {
|
|
||||||
await tx.stepTechStep.update({
|
|
||||||
where: { stepId_order: { stepId, order } },
|
|
||||||
data: {
|
|
||||||
techStepId: correctedTechStepId,
|
|
||||||
start: span.start,
|
|
||||||
end: span.end,
|
|
||||||
contextStart: null,
|
|
||||||
contextEnd: null,
|
|
||||||
source: "manual",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await tx.stepTechStep.create({
|
|
||||||
data: {
|
|
||||||
stepId,
|
|
||||||
techStepId: correctedTechStepId,
|
|
||||||
order,
|
|
||||||
start: span.start,
|
|
||||||
end: span.end,
|
|
||||||
source: "manual",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata !== undefined) {
|
|
||||||
await tx.stepTechStepIngredient.deleteMany({ where: { stepId, techStepOrder: order } });
|
|
||||||
await tx.stepTechStepUtensil.deleteMany({ where: { stepId, techStepOrder: order } });
|
|
||||||
if (metadata.ingredients.length > 0) {
|
|
||||||
await tx.stepTechStepIngredient.createMany({
|
|
||||||
data: metadata.ingredients.map((ingredient) => ({
|
|
||||||
stepId,
|
|
||||||
techStepOrder: order,
|
|
||||||
ingredientId: ingredient.ingredientId,
|
|
||||||
quantity: ingredient.quantity,
|
|
||||||
unitId: ingredient.unitId,
|
|
||||||
start: ingredient.start,
|
|
||||||
end: ingredient.end,
|
|
||||||
source: "manual",
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (metadata.utensils.length > 0) {
|
|
||||||
await tx.stepTechStepUtensil.createMany({
|
|
||||||
data: metadata.utensils.map((utensil) => ({
|
|
||||||
stepId,
|
|
||||||
techStepOrder: order,
|
|
||||||
utensilId: utensil.utensilId,
|
|
||||||
start: utensil.start,
|
|
||||||
end: utensil.end,
|
|
||||||
source: "manual",
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
|
|
||||||
}
|
|
||||||
|
|
||||||
await renumberStepTechSteps(tx, stepId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorrectionView {
|
|
||||||
return {
|
|
||||||
id: correction.id,
|
|
||||||
start: correction.start,
|
|
||||||
end: correction.end,
|
|
||||||
previousTechStep: correction.previousTechStep
|
|
||||||
? {
|
|
||||||
id: correction.previousTechStep.id,
|
|
||||||
key: correction.previousTechStep.key,
|
|
||||||
isPlaceholder: correction.previousTechStep.isPlaceholder,
|
|
||||||
displayName: correction.previousTechStep.displayName,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
correctedTechStep: correction.correctedTechStep
|
|
||||||
? {
|
|
||||||
id: correction.correctedTechStep.id,
|
|
||||||
key: correction.correctedTechStep.key,
|
|
||||||
isPlaceholder: correction.correctedTechStep.isPlaceholder,
|
|
||||||
displayName: correction.correctedTechStep.displayName,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
createdAt: correction.createdAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records one correction to `stepId`'s detected techniques, submitted by
|
|
||||||
* `correctorId`, and immediately applies its effect to the step's real
|
|
||||||
* `StepTechStep` sequence (a `"manual"`-tagged row — see
|
|
||||||
* {@link applyManualCorrection}) — see
|
|
||||||
* {@link SubmitTechStepCorrectionInput}'s doc comment (`packages/shared`)
|
|
||||||
* for what `previousTechStepId`/`correctedTechStepId` each mean. The audit
|
|
||||||
* record itself is never edited/deleted afterward (see
|
|
||||||
* `StepTechStepCorrection`'s schema doc comment) — only the live sequence
|
|
||||||
* changes on a later correction to the same span.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
|
|
||||||
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
|
|
||||||
* `start`/`end` (the correction's own span, or any of
|
|
||||||
* `input.ingredients`/`input.utensils`/`input.temperatures`' own spans)
|
|
||||||
* fall outside the step's current `description` (it may have been
|
|
||||||
* edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`/
|
|
||||||
* `404 INGREDIENT_NOT_FOUND`/`404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND`
|
|
||||||
* if any referenced id
|
|
||||||
* doesn't exist.
|
|
||||||
*/
|
|
||||||
export async function submitTechStepCorrection(
|
|
||||||
recipeId: number,
|
|
||||||
stepId: number,
|
|
||||||
input: SubmitTechStepCorrectionInput,
|
|
||||||
correctorId: number,
|
|
||||||
viewerHouseId: number | null,
|
|
||||||
): Promise<SubmitTechStepCorrectionResult> {
|
|
||||||
try {
|
|
||||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
|
||||||
const descriptionLength = step.description.length;
|
|
||||||
|
|
||||||
const spans = [
|
|
||||||
{ start: input.start, end: input.end },
|
|
||||||
...(input.ingredients ?? []),
|
|
||||||
...(input.utensils ?? []),
|
|
||||||
...(input.temperatures ?? []),
|
|
||||||
];
|
|
||||||
for (const span of spans) {
|
|
||||||
if (span.start >= descriptionLength || span.end > descriptionLength) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.INVALID_CORRECTION_SPAN,
|
|
||||||
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${descriptionLength})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
|
|
||||||
(id): id is number => id !== null && id !== undefined,
|
|
||||||
);
|
|
||||||
await assertTechStepsExist(techStepIds);
|
|
||||||
await assertIngredientsExist((input.ingredients ?? []).map((i) => i.ingredientId));
|
|
||||||
await assertUnitsExist(
|
|
||||||
(input.ingredients ?? []).flatMap((i) =>
|
|
||||||
i.unitId !== null && i.unitId !== undefined ? [i.unitId] : [],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
|
||||||
|
|
||||||
const { correction, techSteps, placeholderTechStepId } = await prisma.$transaction(
|
|
||||||
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({
|
|
||||||
data: {
|
|
||||||
stepId: step.id,
|
|
||||||
correctorId,
|
|
||||||
start: input.start,
|
|
||||||
end: input.end,
|
|
||||||
previousTechStepId: input.previousTechStepId ?? null,
|
|
||||||
correctedTechStepId: effectiveCorrectedTechStepId,
|
|
||||||
},
|
|
||||||
include: correctionInclude,
|
|
||||||
});
|
|
||||||
|
|
||||||
await applyManualCorrection(
|
|
||||||
tx,
|
|
||||||
step.id,
|
|
||||||
{ start: input.start, end: input.end },
|
|
||||||
input.previousTechStepId ?? null,
|
|
||||||
effectiveCorrectedTechStepId,
|
|
||||||
input.ingredients === undefined &&
|
|
||||||
input.utensils === undefined &&
|
|
||||||
input.temperatures === undefined
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
ingredients: (input.ingredients ?? []).map((ingredient) => ({
|
|
||||||
ingredientId: ingredient.ingredientId,
|
|
||||||
quantity: ingredient.quantity ?? null,
|
|
||||||
unitId: ingredient.unitId ?? null,
|
|
||||||
start: ingredient.start,
|
|
||||||
end: ingredient.end,
|
|
||||||
})),
|
|
||||||
utensils: (input.utensils ?? []).map((utensil) => ({
|
|
||||||
utensilId: utensil.utensilId,
|
|
||||||
start: utensil.start,
|
|
||||||
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
|
|
||||||
// `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews`
|
|
||||||
// (reused below) expects it, so the fresh sequence read right after a
|
|
||||||
// manual correction resolves exactly the same way a normal
|
|
||||||
// `GET /recipes/:id` would.
|
|
||||||
const freshTechSteps = await tx.stepTechStep.findMany({
|
|
||||||
where: { stepId: step.id },
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { correction: createdCorrection, techSteps: freshTechSteps, placeholderTechStepId };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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) };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every correction submitted so far for `stepId`, most recent first —
|
|
||||||
* mainly useful for a user checking what's already been submitted (by
|
|
||||||
* anyone) for a span before adding another (see `StepTechStepCorrectionView`'s
|
|
||||||
* doc comment, `packages/shared`).
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see {@link loadVisibleStepOrThrow}.
|
|
||||||
*/
|
|
||||||
export async function listTechStepCorrections(
|
|
||||||
recipeId: number,
|
|
||||||
stepId: number,
|
|
||||||
viewerId: number,
|
|
||||||
viewerHouseId: number | null,
|
|
||||||
): Promise<StepTechStepCorrectionView[]> {
|
|
||||||
try {
|
|
||||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, viewerId, viewerHouseId);
|
|
||||||
const corrections = await prisma.stepTechStepCorrection.findMany({
|
|
||||||
where: { stepId: step.id },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
include: correctionInclude,
|
|
||||||
});
|
|
||||||
return corrections.map(toCorrectionView);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
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 {
|
||||||
createRecipeSchema,
|
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
createRecipeSchema,
|
||||||
listRecipesSchema,
|
listRecipesSchema,
|
||||||
submitTechStepCorrectionSchema,
|
|
||||||
updateRecipeSchema,
|
updateRecipeSchema,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
@ -18,10 +17,6 @@ import {
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
updateRecipe,
|
updateRecipe,
|
||||||
} from "./recipe.service.js";
|
} from "./recipe.service.js";
|
||||||
import {
|
|
||||||
listTechStepCorrections,
|
|
||||||
submitTechStepCorrection,
|
|
||||||
} from "./recipe-tech-step-correction.service.js";
|
|
||||||
|
|
||||||
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
|
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
|
||||||
export const recipeRouter = Router();
|
export const recipeRouter = Router();
|
||||||
|
|
@ -35,15 +30,6 @@ function parseRecipeId(rawId: string | undefined): number {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same shape as {@link parseRecipeId}, for the `:stepId` route param of the tech-step-correction routes below — a distinct function only so the error message names the right param. */
|
|
||||||
function parseStepId(rawId: string | undefined): number {
|
|
||||||
const id = Number(rawId);
|
|
||||||
if (!Number.isInteger(id)) {
|
|
||||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "stepId must be an integer");
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
recipeRouter.get(
|
recipeRouter.get(
|
||||||
"/",
|
"/",
|
||||||
requireAuth,
|
requireAuth,
|
||||||
|
|
@ -123,29 +109,3 @@ recipeRouter.delete(
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Open to any authenticated viewer who can see the recipe, not just its
|
|
||||||
// author — see recipe-tech-step-correction.service.ts's own doc comment
|
|
||||||
// for why.
|
|
||||||
recipeRouter.post(
|
|
||||||
"/:id/steps/:stepId/corrections",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const id = parseRecipeId(req.params.id);
|
|
||||||
const stepId = parseStepId(req.params.stepId);
|
|
||||||
const input = submitTechStepCorrectionSchema.parse(req.body);
|
|
||||||
const { id: correctorId, houseId } = res.locals.userProfile;
|
|
||||||
res.status(201).json(await submitTechStepCorrection(id, stepId, input, correctorId, houseId));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
recipeRouter.get(
|
|
||||||
"/:id/steps/:stepId/corrections",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const id = parseRecipeId(req.params.id);
|
|
||||||
const stepId = parseStepId(req.params.stepId);
|
|
||||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
|
||||||
res.status(200).json(await listTechStepCorrections(id, stepId, viewerId, houseId));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,9 +6,7 @@ import {
|
||||||
getIngredients,
|
getIngredients,
|
||||||
getSources,
|
getSources,
|
||||||
getTechSteps,
|
getTechSteps,
|
||||||
getTours,
|
|
||||||
getUnits,
|
getUnits,
|
||||||
getUtensils,
|
|
||||||
} from "./reference.service.js";
|
} from "./reference.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,27 +55,9 @@ referenceRouter.get(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
referenceRouter.get(
|
|
||||||
"/utensils",
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json(await getUtensils());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
referenceRouter.get(
|
referenceRouter.get(
|
||||||
"/sources",
|
"/sources",
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
wrapAsyncHandler(async (_req, res) => {
|
||||||
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());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue