Compare commits
1 commit
main
...
feat/plann
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25ff6fb8ba |
608 changed files with 3735 additions and 81417 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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
72
.env.example
72
.env.example
|
|
@ -6,75 +6,11 @@ POSTGRES_PASSWORD=changeme
|
||||||
POSTGRES_DB=batchcooking
|
POSTGRES_DB=batchcooking
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
# Used by docker-compose.yml's "app" service (Docker-only — the native
|
# Used by docker-compose.yml's "api" service (Docker-only — the native
|
||||||
# `pnpm dev:api` workflow reads apps/api/.env instead, set both when using
|
# `pnpm dev:api` workflow reads apps/api/.env instead, set both when using
|
||||||
# both workflows). Required, no default on purpose — generate your own.
|
# both workflows). Required, no default on purpose — generate your own.
|
||||||
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 — host port for the Docker review stack's single app container
|
# Optional — host ports for the Docker review stack (docker-compose.yml)
|
||||||
# (docker-compose.yml), serving both the API and the built frontend.
|
# API_PORT=3000
|
||||||
# APP_PORT=3000
|
# WEB_PORT=8080
|
||||||
|
|
||||||
# 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
|
|
||||||
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
|
||||||
# requires HTTPS (Secure attribute) as it should for a real deployment;
|
|
||||||
# over plain HTTP a Secure cookie is silently never sent back by the
|
|
||||||
# browser, so login "succeeds" but every subsequent request 401s.
|
|
||||||
# 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
|
|
||||||
|
|
|
||||||
343
.github/workflows/ci.yml
vendored
343
.github/workflows/ci.yml
vendored
|
|
@ -1,259 +1,65 @@
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# Every push, on every branch — not just main — so build breakage shows up
|
|
||||||
# on the first commit of a branch, not only once a PR targets main. No
|
|
||||||
# separate pull_request trigger: for a PR from a branch on this same repo,
|
|
||||||
# push already fires on that branch, and GitHub attaches the run to the PR
|
|
||||||
# by commit SHA regardless of which event triggered it — adding
|
|
||||||
# pull_request too would just run every job twice per push. (Re-add it,
|
|
||||||
# scoped to forks, only if this repo starts accepting fork PRs — push
|
|
||||||
# events from a fork never reach here.)
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
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
|
lint-and-test:
|
||||||
# and reports as its own check, instead of the previous single chained
|
|
||||||
# "lint-and-test then e2e" pipeline.
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ci
|
||||||
|
POSTGRES_PASSWORD: ci
|
||||||
|
POSTGRES_DB: batchcooking_ci
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
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
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm lint
|
- run: pnpm lint
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# ~16-19 min of actual work (runs 117-124); the extra budget covers time
|
|
||||||
# spent queued in "Wait for any earlier test job" below. Also a runaway
|
|
||||||
# ceiling — Mocha has no `--exit` (`apps/api/.mocharc.json`), so one
|
|
||||||
# leaked handle would otherwise hang the job until the runner's global
|
|
||||||
# timeout (a `test` job zombied ~1h once). If a hung job hits this
|
|
||||||
# ceiling the runner removes its container, which unblocks the queue.
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Install curl and the docker CLI
|
|
||||||
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: https://github.com/actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
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 --filter api exec prisma migrate deploy
|
- run: pnpm --filter api exec prisma migrate deploy
|
||||||
- run: pnpm --filter api test
|
- run: pnpm --filter api test
|
||||||
|
- run: pnpm --filter api test:bdd
|
||||||
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:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
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
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm build
|
- run: pnpm build
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-test
|
||||||
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 +69,4 @@ 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
|
|
||||||
# server internally for component testing (see cypress.config.ts's
|
|
||||||
# `component.devServer`), unlike `e2e` above which needs the real app
|
|
||||||
# running first.
|
|
||||||
- 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
|
|
||||||
|
|
|
||||||
254
.github/workflows/release.yml
vendored
254
.github/workflows/release.yml
vendored
|
|
@ -1,254 +0,0 @@
|
||||||
name: Release
|
|
||||||
|
|
||||||
# On a `vX.Y.Z` tag: build the three service images and push them to this
|
|
||||||
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/
|
|
||||||
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag
|
|
||||||
# and `latest`), build+attach a signed Android APK, publish a Forgejo
|
|
||||||
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can
|
|
||||||
# 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:
|
|
||||||
push:
|
|
||||||
tags: ["v*.*.*"]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
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:
|
|
||||||
# Build the three service images at the tagged commit and push them to the
|
|
||||||
# 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
|
|
||||||
steps:
|
|
||||||
# This runner's job containers don't come with docker preinstalled —
|
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: Build et push des 3 images vers le registre Forgejo
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: forgejo-release
|
|
||||||
steps:
|
|
||||||
- name: Trigger Portainer redeploy
|
|
||||||
# Best-effort: does nothing (and doesn't fail the workflow) until
|
|
||||||
# the PORTAINER_WEBHOOK_URL repo secret is set — grab that URL from
|
|
||||||
# the stack's webhook setting in Portainer and add it as a secret
|
|
||||||
# named PORTAINER_WEBHOOK_URL (see README).
|
|
||||||
env:
|
|
||||||
PORTAINER_WEBHOOK_URL: ${{ secrets.PORTAINER_WEBHOOK_URL }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$PORTAINER_WEBHOOK_URL" ]; then
|
|
||||||
echo "PORTAINER_WEBHOOK_URL not set — skipping, Portainer will pick this up on its next poll."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
curl -fsS -X POST "$PORTAINER_WEBHOOK_URL"
|
|
||||||
25
.gitignore
vendored
25
.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
|
||||||
|
|
||||||
|
|
@ -161,14 +147,3 @@ Projet batch cooking.pdf
|
||||||
|
|
||||||
# Throwaway HTML mockups used to review a design before implementing it
|
# Throwaway HTML mockups used to review a design before implementing it
|
||||||
tmp-mockups/
|
tmp-mockups/
|
||||||
|
|
||||||
# IA
|
|
||||||
.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
|
|
||||||
576
README.md
576
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 + Cucumber/BDD)
|
||||||
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
|
||||||
|
|
@ -93,26 +75,11 @@ hors du repo).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Base de données Postgres locale
|
# Base de données Postgres locale
|
||||||
docker compose up -d postgres
|
docker compose up -d
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
|
@ -127,182 +94,43 @@ pnpm dev:web
|
||||||
> Dans ce cas, mets `POSTGRES_PORT=5433` (ou autre) dans ton `.env` **et** adapte le
|
> Dans ce cas, mets `POSTGRES_PORT=5433` (ou autre) dans ton `.env` **et** adapte le
|
||||||
> port dans la `DATABASE_URL` de `apps/api/.env`.
|
> port dans la `DATABASE_URL` de `apps/api/.env`.
|
||||||
|
|
||||||
> **Toujours cibler `postgres`, jamais `docker compose up -d` tout court.** Le même
|
|
||||||
> `docker-compose.yml` définit aussi le service `app` (voir [Déploiement](#déploiement)) —
|
|
||||||
> celui que Portainer construit en production. Sans nom de service, `docker compose up -d`
|
|
||||||
> démarre les deux : ça déclenche un `pnpm install` sur tout le monorepo (donc aussi le
|
|
||||||
> `cypress` d'`apps/web`, avec son téléchargement de binaire) rien que pour builder une
|
|
||||||
> image dont le dev local n'a pas besoin (on sert le front/back directement via
|
|
||||||
> `pnpm dev:web`/`pnpm dev:api`, pas ce conteneur).
|
|
||||||
|
|
||||||
## 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 api test:bdd # tests d'intégration BDD (Cucumber/Gherkin, apps/api)
|
||||||
pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress component testing)
|
pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
|
||||||
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 lint + tests + build sur chaque push/PR vers `main`, puis les tests e2e Cypress.
|
||||||
|
|
||||||
### Base de test isolée de la base de dev (`apps/api`)
|
### Cucumber (apps/api)
|
||||||
|
|
||||||
`pnpm --filter api test` exécute une `TRUNCATE ... CASCADE` sur presque tout le
|
Tests d'intégration lisibles en Gherkin, en complément de Mocha (qui reste pour les
|
||||||
schéma **avant chaque test** (`test-support/reset-db.ts`). Pour ne jamais
|
tests unitaires purs) :
|
||||||
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
|
- `apps/api/features/*.feature` — scénarios en Given/When/Then (`health.feature` sert
|
||||||
cp apps/api/.env.test.example apps/api/.env.test
|
d'exemple)
|
||||||
# puis édite-le : mêmes identifiants Postgres que ton .env, mais une base
|
- `apps/api/features/step-definitions/*.steps.ts` — implémentation des steps
|
||||||
# différente (ex. batchcooking_test) — .env.test.example documente les
|
- `apps/api/features/support/world.ts` — contexte partagé entre les steps d'un
|
||||||
# commandes exactes pour la créer et lui appliquer le schéma.
|
scénario (instancie l'app Express in-process via `createApp()`, comme le fait déjà
|
||||||
```
|
supertest côté Mocha — pas besoin de lancer un vrai serveur)
|
||||||
|
- `apps/api/cucumber.cjs` — config (extension `.cjs` volontaire, voir la remarque
|
||||||
|
TypeScript/ESM ci-dessous)
|
||||||
|
|
||||||
Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
|
Pour ajouter un scénario : écrire le `.feature`, lancer `pnpm --filter api test:bdd`,
|
||||||
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
implémenter les steps manquants (Cucumber affiche des snippets tout prêts pour ceux
|
||||||
seule base qu'il doit rejeter est ta vraie base de dev.
|
qui n'existent pas encore).
|
||||||
|
|
||||||
`services/tech-step-intent-service` doit aussi tourner en local avant
|
> **Piège TypeScript/ESM à connaître** (déjà rencontré avec `cypress.config.ts`) :
|
||||||
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
|
> les fichiers de config d'outils tiers qui font du chargement dynamique de TS
|
||||||
par le vrai service (jamais un mock, voir
|
> (`cucumber.cjs`, `cypress.config.ts`…) sont sensibles au `"type": "module"` du
|
||||||
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
|
> `package.json`. `cucumber.cjs` évite le problème *pour sa propre config* en étant
|
||||||
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
|
> explicitement CommonJS ; les steps/world restent en `.ts` ESM classique et sont
|
||||||
section [Développement](#développement) ci-dessus.
|
> chargés via `tsx` (`NODE_OPTIONS=--import=tsx`, voir le script `test:bdd`).
|
||||||
|
|
||||||
## Déploiement
|
|
||||||
|
|
||||||
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
|
|
||||||
buildé — plus de conteneur nginx séparé pour `apps/web`. Le stage `build` compile
|
|
||||||
`apps/api` **et** `apps/web` (`pnpm --filter web build`), le stage `runtime` copie
|
|
||||||
le résultat (`apps/web/dist`) à côté de l'API ; au démarrage, `apps/api/src/app.ts`
|
|
||||||
sert ce dossier statique (fallback SPA compris, pour le routing react-router côté
|
|
||||||
client) via `FRONTEND_DIST_DIR` — voir `packages/express-tools/src/express-server.ts`
|
|
||||||
(`serveStaticFrontend`). Cette variable n'est renseignée que dans l'image Docker :
|
|
||||||
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 `CMD` de l'image enchaîne trois étapes, chacune dans son propre processus
|
|
||||||
`node` : `prisma migrate deploy` (applique les migrations), puis
|
|
||||||
`node dist/scripts/seed-runtime.js` (seed des données de référence **et**
|
|
||||||
synchronisation de la table `sources` depuis le registre d'adaptateurs de code
|
|
||||||
— 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
|
|
||||||
applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
|
||||||
coordonner entre deux origines, le frontend et l'API sont désormais servis
|
|
||||||
depuis la même origine. Les deux autres services du fichier
|
|
||||||
(`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`)
|
|
||||||
|
|
||||||
Déclenchée par un tag `vX.Y.Z` :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag vX.Y.Z
|
|
||||||
git push --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
Le pipeline enchaîne quatre jobs : `build-and-push-images` (build des 3 images à
|
|
||||||
ce tag + push sur le registre Forgejo sous `vX.Y.Z` et `latest` — un push
|
|
||||||
réussi vaut aussi vérification que tout build encore à ce commit),
|
|
||||||
`build-android-apk` (APK signé, `versionName` dérivé du tag), `forgejo-release`
|
|
||||||
(crée la Release Forgejo `vX.Y.Z` et y attache `batchcooking.apk`), puis
|
|
||||||
`notify-portainer` — envoie une requête au webhook de redeploy de Portainer si
|
|
||||||
le secret de dépôt `PORTAINER_WEBHOOK_URL` est configuré (sinon Portainer se
|
|
||||||
resynchronise à son prochain polling). Pour l'activer : récupérer l'URL du
|
|
||||||
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 +144,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 —
|
||||||
|
|
@ -332,184 +156,179 @@ aujourd'hui, seulement la suppression de compte.
|
||||||
> concrètement (`argon2.hash(...)` dans un `node -e`) avant de merger, un `pnpm build`
|
> concrètement (`argon2.hash(...)` dans un `node -e`) avant de merger, un `pnpm build`
|
||||||
> qui passe ne suffit pas à détecter un crash runtime.
|
> qui passe ne suffit pas à détecter un crash runtime.
|
||||||
|
|
||||||
Les tests (Mocha) tournent avec un coût argon2 réduit
|
Les tests (Mocha + Cucumber) tournent avec un coût argon2 réduit
|
||||||
(`NODE_ENV=test`, voir `auth.service.ts`) — le coût par défaut est volontairement
|
(`NODE_ENV=test`, voir `auth.service.ts`) — le coût par défaut est volontairement
|
||||||
élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI
|
élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI
|
||||||
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`/`test:bdd` **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/`) : `smoke.cy.ts` + `auth.cy.ts` +
|
||||||
`@badeball/cypress-cucumber-preprocessor`) : mockent l'API via `cy.intercept`
|
`home-planning.cy.ts` + `onboarding.cy.ts` + `household.cy.ts` mockent l'API via
|
||||||
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne
|
`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
|
||||||
pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de
|
provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
|
||||||
l'API est couvert par la suite Mocha d'`apps/api` (contre une vraie base).
|
réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une
|
||||||
Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
|
vraie base).
|
||||||
[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
|
||||||
|
|
@ -517,16 +336,14 @@ Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
|
||||||
> branche de feature — pas un problème introduit par une modification du code.
|
> branche de feature — pas un problème introduit par une modification du code.
|
||||||
> `pnpm --filter web e2e` fonctionne normalement en CI (GitHub Actions) et sur une
|
> `pnpm --filter web e2e` fonctionne normalement en CI (GitHub Actions) et sur une
|
||||||
> machine de dev classique ; dans cet environnement précis, vérifier manuellement via
|
> machine de dev classique ; dans cet environnement précis, vérifier manuellement via
|
||||||
> le serveur de dev (`pnpm dev:web` + `pnpm dev:api` en local, pas le conteneur
|
> le serveur de dev (`pnpm dev:web` + `pnpm dev:api` en local, pas les conteneurs
|
||||||
> Docker — voir [Déploiement](#déploiement) — qui sert le frontend buildé, pas le
|
> Docker dont le `CORS_ORIGIN` cible `localhost:8080`, pas `localhost:5173`).
|
||||||
> serveur de dev Vite).
|
|
||||||
|
|
||||||
## 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 +353,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,23 +370,16 @@ 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
|
||||||
de test dans `test/*.test.ts` (Mocha) — jamais de nom/email qui ressemble à une
|
de test dans `test/auth.test.ts` (Mocha) et le "bruit" (prénom/nom de remplissage)
|
||||||
vraie personne en dur dans un fixture.
|
des steps Cucumber — jamais de nom/email qui ressemble à une vraie personne en dur
|
||||||
|
dans un fixture. Les valeurs *littérales* des scénarios `.feature` eux-mêmes
|
||||||
|
(ex. `alice@example.com`) restent volontairement statiques : c'est le point des
|
||||||
|
scénarios Gherkin lisibles (exemples illustratifs conventionnels en BDD, pas des
|
||||||
|
données réelles) — seules les données de remplissage hors du texte lisible du
|
||||||
|
scénario sont générées.
|
||||||
|
|
|
||||||
|
|
@ -11,36 +11,4 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# Optional — defaults shown (see src/config/env.ts)
|
# Optional — defaults shown (see src/config/env.ts)
|
||||||
# JWT_EXPIRES_IN=7d
|
# JWT_EXPIRES_IN=7d
|
||||||
# AUTH_COOKIE_NAME=session
|
# AUTH_COOKIE_NAME=session
|
||||||
# CORS_ORIGIN=http://localhost:5173,https://localhost
|
# CORS_ORIGIN=http://localhost:5173
|
||||||
# INTENT_SERVICE_BASE_URL=http://localhost:8000
|
|
||||||
|
|
||||||
# Required — services/tech-step-intent-service must be running locally (see
|
|
||||||
# that service's own README) for any recipe save/preview to detect
|
|
||||||
# techniques at all. Must match that service's own INTENT_SERVICE_SECRET.
|
|
||||||
# Generate your own the same way as JWT_SECRET above.
|
|
||||||
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Only needed if you're running services/tech-step-llm-worker locally —
|
|
||||||
# every /internal/tech-steps/* request is rejected outright while unset.
|
|
||||||
# Generate your own the same way as JWT_SECRET above; must match the
|
|
||||||
# worker's own INTERNAL_WORKER_SECRET.
|
|
||||||
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Only needed to use the admin surface (apps/web's /admin/* routes) — every
|
|
||||||
# /admin/* request 401s while unset (`requireAdmin` fails closed). MUST be a
|
|
||||||
# different value than JWT_SECRET. Generate your own the same way.
|
|
||||||
# ADMIN_JWT_SECRET=changeme-a-different-random-secret-at-least-32-chars
|
|
||||||
# Read only by `src/scripts/create-admin.ts` when its --email/--password/
|
|
||||||
# --name flags are omitted — never by the running server.
|
|
||||||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
|
||||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
|
||||||
# ADMIN_INITIAL_NAME=Ops
|
|
||||||
|
|
||||||
# Only needed to create a Forgejo issue from a contact message
|
|
||||||
# (apps/web's /admin/messages, issue #81). The button is hidden and the
|
|
||||||
# endpoint answers 503 while any of the three is unset. FORGEJO_TOKEN is a
|
|
||||||
# personal access token with the write:issue scope — generate it in Forgejo,
|
|
||||||
# never commit the real value.
|
|
||||||
# FORGEJO_BASE_URL=https://git.example.com
|
|
||||||
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
|
|
||||||
# FORGEJO_REPO=owner/repo
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,61 +10,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
|
|
||||||
# Single image serving both the API and the built frontend (apps/web) — one
|
|
||||||
# process, one container, no separate nginx/static host. Builds both so the
|
|
||||||
# runtime stage below can copy each app's build output independently.
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
# `pnpm install` (below) installs every workspace's dependencies, including
|
|
||||||
# apps/web's `cypress` devDependency — this image never runs it, so skip its
|
|
||||||
# (large, Electron-bundled) binary download: saves build time/bandwidth and
|
|
||||||
# removes a network dependency on Cypress's CDN from every production build.
|
|
||||||
# The `cypress` npm *package* itself still ends up in `node_modules` (it's a
|
|
||||||
# real lockfile entry) and gets copied into `runtime` below — a deliberate
|
|
||||||
# tradeoff. Tried to prune it out too (`pnpm prune --prod`, then a full
|
|
||||||
# wipe-and-reinstall with `--prod`), but in this pnpm workspace both proved
|
|
||||||
# actively unsafe rather than just ineffective: `prune` doesn't cascade into
|
|
||||||
# sibling workspace projects' `node_modules` at all, and even a *scoped*
|
|
||||||
# `pnpm --filter web prune --prod` emptied out apps/api's entire
|
|
||||||
# `node_modules` as a side effect; the wipe-and-reinstall variant silently
|
|
||||||
# dropped argon2's compiled native binding (no postinstall re-run to rebuild
|
|
||||||
# it), which only surfaced as a crash *after* deploy
|
|
||||||
# (`Cannot find module '.../argon2.node'`) — not something to risk on the
|
|
||||||
# real production image for what's ~10MB of inert JS once the binary
|
|
||||||
# download above is already skipped.
|
|
||||||
ENV CYPRESS_INSTALL_BINARY=0
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
RUN pnpm --filter api build
|
RUN pnpm --filter api build
|
||||||
RUN pnpm --filter web build
|
|
||||||
|
|
||||||
# Copies the monorepo structure as-is (not a flattened single package) so
|
# Copies the monorepo structure as-is (not a flattened single package) so
|
||||||
# pnpm's symlinked node_modules (root node_modules/.pnpm <- apps/api/node_modules)
|
# pnpm's symlinked node_modules (root node_modules/.pnpm <- apps/api/node_modules)
|
||||||
# stay valid — paths must match exactly between build and runtime stages.
|
# stay valid — paths must match exactly between build and runtime stages.
|
||||||
FROM base AS runtime
|
FROM base AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# Tells the API where to find the built frontend — see FRONTEND_DIST_DIR's
|
|
||||||
# doc comment in apps/api/src/config/env.ts.
|
|
||||||
ENV FRONTEND_DIST_DIR=/repo/apps/web/dist
|
|
||||||
COPY --from=build /repo/node_modules ./node_modules
|
COPY --from=build /repo/node_modules ./node_modules
|
||||||
COPY --from=build /repo/package.json ./package.json
|
COPY --from=build /repo/package.json ./package.json
|
||||||
COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=build /repo/packages/shared ./packages/shared
|
COPY --from=build /repo/packages/shared ./packages/shared
|
||||||
COPY --from=build /repo/packages/error-tools ./packages/error-tools
|
COPY --from=build /repo/packages/error-tools ./packages/error-tools
|
||||||
COPY --from=build /repo/packages/express-tools ./packages/express-tools
|
COPY --from=build /repo/packages/express-tools ./packages/express-tools
|
||||||
COPY --from=build /repo/packages/date-tools ./packages/date-tools
|
|
||||||
COPY --from=build /repo/apps/api/node_modules ./apps/api/node_modules
|
COPY --from=build /repo/apps/api/node_modules ./apps/api/node_modules
|
||||||
COPY --from=build /repo/apps/api/dist ./apps/api/dist
|
COPY --from=build /repo/apps/api/dist ./apps/api/dist
|
||||||
COPY --from=build /repo/apps/api/prisma ./apps/api/prisma
|
COPY --from=build /repo/apps/api/prisma ./apps/api/prisma
|
||||||
COPY --from=build /repo/apps/api/package.json ./apps/api/package.json
|
COPY --from=build /repo/apps/api/package.json ./apps/api/package.json
|
||||||
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 before starting — keeps the review environment's
|
||||||
# Allergy — see src/scripts/seed-runtime.ts), then runs any pending one-time
|
# schema in sync automatically, no manual step needed.
|
||||||
# data backfill (src/scripts/backfill-runtime.ts) before starting. migrate
|
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/server.js"]
|
||||||
# deploy only applies pending migrations and the seed upserts by unique
|
|
||||||
# name, so both are safe to repeat every start; the backfill step gates
|
|
||||||
# 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"]
|
|
||||||
|
|
|
||||||
11
apps/api/cucumber.cjs
Normal file
11
apps/api/cucumber.cjs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Explicit .cjs extension (not .js) so this loads as CommonJS regardless of
|
||||||
|
// the "type": "module" in package.json — avoids the same ESM/CJS config
|
||||||
|
// loader mismatch that broke apps/web/cypress.config.ts earlier.
|
||||||
|
module.exports = {
|
||||||
|
default: {
|
||||||
|
paths: ["features/**/*.feature"],
|
||||||
|
import: ["features/**/*.ts"],
|
||||||
|
format: ["progress-bar"],
|
||||||
|
formatOptions: { snippetInterface: "async-await" },
|
||||||
|
},
|
||||||
|
};
|
||||||
50
apps/api/features/auth.feature
Normal file
50
apps/api/features/auth.feature
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
Feature: Account creation and login
|
||||||
|
As a new user
|
||||||
|
I want to create a profile and log in
|
||||||
|
So that I can access my household's batch-cooking planning
|
||||||
|
|
||||||
|
Scenario: A visitor creates a new profile
|
||||||
|
When I sign up with the following details:
|
||||||
|
| firstName | Alice |
|
||||||
|
| lastName | Martin |
|
||||||
|
| email | alice@example.com |
|
||||||
|
| password | correct-horse-battery-staple |
|
||||||
|
Then the response status should be 201
|
||||||
|
And I am authenticated as "alice@example.com"
|
||||||
|
|
||||||
|
Scenario: A visitor cannot sign up twice with the same email
|
||||||
|
Given a profile already exists with email "alice@example.com"
|
||||||
|
When I sign up with the following details:
|
||||||
|
| firstName | Alice |
|
||||||
|
| lastName | Martin |
|
||||||
|
| email | alice@example.com |
|
||||||
|
| password | correct-horse-battery-staple |
|
||||||
|
Then the response status should be 409
|
||||||
|
And the response error code should be "EMAIL_ALREADY_IN_USE"
|
||||||
|
|
||||||
|
Scenario: A registered user logs in with correct credentials
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
Then the response status should be 200
|
||||||
|
And I am authenticated as "alice@example.com"
|
||||||
|
|
||||||
|
Scenario: A registered user cannot log in with the wrong password
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I log in with email "alice@example.com" and password "wrong-password"
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "INVALID_CREDENTIALS"
|
||||||
|
|
||||||
|
Scenario: A signed-in user cannot delete their account with the wrong password
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I delete my account with password "wrong-password"
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "INVALID_CREDENTIALS"
|
||||||
|
And I am authenticated as "alice@example.com"
|
||||||
|
|
||||||
|
Scenario: A signed-in user deletes their account
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I delete my account with password "correct-horse-battery-staple"
|
||||||
|
Then the response status should be 204
|
||||||
|
And I am no longer authenticated
|
||||||
12
apps/api/features/health.feature
Normal file
12
apps/api/features/health.feature
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
Feature: API health check
|
||||||
|
As a monitoring service
|
||||||
|
I want to query the API
|
||||||
|
So that I can verify it is up and responding correctly
|
||||||
|
|
||||||
|
Scenario: The API is available
|
||||||
|
When I send a GET request to "/health"
|
||||||
|
Then the response status should be 200
|
||||||
|
And the response body should be:
|
||||||
|
"""
|
||||||
|
{ "status": "ok" }
|
||||||
|
"""
|
||||||
67
apps/api/features/household.feature
Normal file
67
apps/api/features/household.feature
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
Feature: Household
|
||||||
|
As a signed-in user
|
||||||
|
I want to name my household, invite others to it, and manage its members
|
||||||
|
So that my whole household can share the same planning
|
||||||
|
|
||||||
|
Scenario: A visitor without a session cannot read the household
|
||||||
|
When I send a GET request to "/house/current"
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "NOT_AUTHENTICATED"
|
||||||
|
|
||||||
|
Scenario: A signed-in user creates a household
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I create a household named "Chez Alice"
|
||||||
|
Then the response status should be 201
|
||||||
|
And my household should be named "Chez Alice"
|
||||||
|
|
||||||
|
Scenario: A signed-in user renames their household
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I have a household named "Foyer de test"
|
||||||
|
When I rename my household to "Chez les Martin"
|
||||||
|
Then the response status should be 200
|
||||||
|
And my household should be named "Chez les Martin"
|
||||||
|
|
||||||
|
Scenario: A second user joins a household using its invite code
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I have a household named "Chez Alice"
|
||||||
|
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user joins my household using its invite code
|
||||||
|
Then the second user's response status should be 200
|
||||||
|
And the second user should be a member of my household
|
||||||
|
|
||||||
|
Scenario: A non-admin member cannot delete the household
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I have a household named "Chez Alice"
|
||||||
|
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user joins my household using its invite code
|
||||||
|
When the second user tries to delete the household
|
||||||
|
Then the second user's response status should be 403
|
||||||
|
And the second user's response error code should be "NOT_HOUSE_ADMIN"
|
||||||
|
|
||||||
|
Scenario: Adminship transfers to the remaining member when the admin leaves
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I have a household named "Chez Alice"
|
||||||
|
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user joins my household using its invite code
|
||||||
|
When I leave the household
|
||||||
|
Then the response status should be 204
|
||||||
|
And the second user should be the household's admin
|
||||||
|
|
||||||
|
Scenario: The admin removes a member
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I have a household named "Chez Alice"
|
||||||
|
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user joins my household using its invite code
|
||||||
|
When I remove the second user from my household
|
||||||
|
Then the response status should be 200
|
||||||
|
And the second user should have no household
|
||||||
24
apps/api/features/planning.feature
Normal file
24
apps/api/features/planning.feature
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
Feature: Household weekly planning
|
||||||
|
As a signed-in user
|
||||||
|
I want to see my household's current planning
|
||||||
|
So that I know what meals are planned this week
|
||||||
|
|
||||||
|
Scenario: A visitor without a session cannot view the planning
|
||||||
|
When I request the current planning
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "NOT_AUTHENTICATED"
|
||||||
|
|
||||||
|
Scenario: A signed-in user with no planning yet sees an empty state
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I request the current planning
|
||||||
|
Then the response status should be 200
|
||||||
|
And the current planning response should be empty
|
||||||
|
|
||||||
|
Scenario: A signed-in user sees their household's current planning
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And my household has a planning covering today with recipe "Ratatouille" on "monday" for "dinner"
|
||||||
|
When I request the current planning
|
||||||
|
Then the response status should be 200
|
||||||
|
And the current planning response should include recipe "Ratatouille" on "monday" for "dinner"
|
||||||
23
apps/api/features/preferences.feature
Normal file
23
apps/api/features/preferences.feature
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
Feature: User preferences (theme)
|
||||||
|
As a signed-in user
|
||||||
|
I want to choose a light, dark, or system theme
|
||||||
|
So that the app matches how I like to read it
|
||||||
|
|
||||||
|
Scenario: A visitor without a session cannot read preferences
|
||||||
|
When I send a GET request to "/preferences"
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "NOT_AUTHENTICATED"
|
||||||
|
|
||||||
|
Scenario: A signed-in user with no preferences yet defaults to SYSTEM
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I request my preferences
|
||||||
|
Then the response status should be 200
|
||||||
|
And my theme preference should be "SYSTEM"
|
||||||
|
|
||||||
|
Scenario: A signed-in user sets their theme preference
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I set my theme preference to "DARK"
|
||||||
|
Then the response status should be 200
|
||||||
|
And my theme preference should be "DARK"
|
||||||
28
apps/api/features/profile.feature
Normal file
28
apps/api/features/profile.feature
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
Feature: Profile regime and allergens
|
||||||
|
As a signed-in user
|
||||||
|
I want to set my dietary regime and allergens/intolerances
|
||||||
|
So that the household's meal planning can account for them later
|
||||||
|
|
||||||
|
Scenario: A visitor without a session cannot set a regime
|
||||||
|
When I send a PATCH request to "/profile/diet" with body:
|
||||||
|
"""
|
||||||
|
{ "dietId": 1 }
|
||||||
|
"""
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "NOT_AUTHENTICATED"
|
||||||
|
|
||||||
|
Scenario: A signed-in user sets their regime to a valid, seeded diet
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I set my regime to "Végétarien"
|
||||||
|
Then the response status should be 200
|
||||||
|
And my profile's regime should be "Végétarien"
|
||||||
|
|
||||||
|
Scenario: A signed-in user selects allergens, then replaces the selection
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I set my allergens to "Arachides, Gluten"
|
||||||
|
Then the response status should be 200
|
||||||
|
And my selected allergens should be "Arachides, Gluten"
|
||||||
|
When I set my allergens to "Lait"
|
||||||
|
Then my selected allergens should be "Lait"
|
||||||
14
apps/api/features/reference.feature
Normal file
14
apps/api/features/reference.feature
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
Feature: Reference data (diets, allergens)
|
||||||
|
As a visitor filling in the signup wizard, or a signed-in user editing their profile
|
||||||
|
I want to read the list of dietary regimes and allergens
|
||||||
|
So that I can pick from them — before an account necessarily exists
|
||||||
|
|
||||||
|
Scenario: A visitor without a session can read the list of dietary regimes
|
||||||
|
When I send a GET request to "/reference/diets"
|
||||||
|
Then the response status should be 200
|
||||||
|
And the reference list response should include "Végétarien"
|
||||||
|
|
||||||
|
Scenario: A visitor without a session can read the list of allergens
|
||||||
|
When I send a GET request to "/reference/allergies"
|
||||||
|
Then the response status should be 200
|
||||||
|
And the reference list response should include "Arachides"
|
||||||
67
apps/api/features/step-definitions/auth.steps.ts
Normal file
67
apps/api/features/step-definitions/auth.steps.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { DataTable } from "@cucumber/cucumber";
|
||||||
|
import { Given, Then, When } from "@cucumber/cucumber";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { signup } from "../../src/modules/auth/auth.service.js";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
// firstName/lastName/password below are filler for background state the
|
||||||
|
// scenario doesn't actually read (only the emails in the .feature file are
|
||||||
|
// part of what's being tested) — faker-generated rather than hardcoded so
|
||||||
|
// no test fixture ever looks like a real person's data.
|
||||||
|
|
||||||
|
Given("a profile already exists with email {string}", async (email: string) => {
|
||||||
|
await signup({
|
||||||
|
firstName: faker.person.firstName(),
|
||||||
|
lastName: faker.person.lastName(),
|
||||||
|
email,
|
||||||
|
password: faker.internet.password({ length: 16 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given(
|
||||||
|
"a profile already exists with email {string} and password {string}",
|
||||||
|
async (email: string, password: string) => {
|
||||||
|
await signup({
|
||||||
|
firstName: faker.person.firstName(),
|
||||||
|
lastName: faker.person.lastName(),
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When("I sign up with the following details:", async function (this: CustomWorld, table: DataTable) {
|
||||||
|
const details = table.rowsHash();
|
||||||
|
this.response = await this.agent.post("/auth/signup").send({
|
||||||
|
firstName: details.firstName,
|
||||||
|
lastName: details.lastName,
|
||||||
|
email: details.email,
|
||||||
|
password: details.password,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
When(
|
||||||
|
"I log in with email {string} and password {string}",
|
||||||
|
async function (this: CustomWorld, email: string, password: string) {
|
||||||
|
this.response = await this.agent.post("/auth/login").send({ email, password });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When(
|
||||||
|
"I delete my account with password {string}",
|
||||||
|
async function (this: CustomWorld, password: string) {
|
||||||
|
this.response = await this.agent.delete("/auth/me").send({ password });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then("I am authenticated as {string}", async function (this: CustomWorld, email: string) {
|
||||||
|
const res = await this.agent.get("/auth/me");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.email, email);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("I am no longer authenticated", async function (this: CustomWorld) {
|
||||||
|
const res = await this.agent.get("/auth/me");
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
});
|
||||||
38
apps/api/features/step-definitions/health.steps.ts
Normal file
38
apps/api/features/step-definitions/health.steps.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import { Then, When } from "@cucumber/cucumber";
|
||||||
|
import request from "supertest";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
When("I send a GET request to {string}", async function (this: CustomWorld, path: string) {
|
||||||
|
this.response = await request(this.app).get(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
When(
|
||||||
|
"I send a PATCH request to {string} with body:",
|
||||||
|
async function (this: CustomWorld, path: string, body: string) {
|
||||||
|
this.response = await request(this.app).patch(path).send(JSON.parse(body));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then("the response status should be {int}", function (this: CustomWorld, status: number) {
|
||||||
|
assert.equal(this.response.status, status);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the response body should be:", function (this: CustomWorld, expectedJson: string) {
|
||||||
|
assert.deepEqual(this.response.body, JSON.parse(expectedJson));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic enough to be reused by any feature asserting on the shared
|
||||||
|
// ApiErrorResponse contract's `code` field — not health-specific, but this
|
||||||
|
// file is where the other generic response-assertion steps already live.
|
||||||
|
//
|
||||||
|
// `code` here is the enum *member name* (readable in the .feature file,
|
||||||
|
// e.g. "EMAIL_ALREADY_IN_USE") — ErrorCode[name] resolves it to the real
|
||||||
|
// numeric value via TypeScript's reverse enum lookup, so this never
|
||||||
|
// compares against a hardcoded number.
|
||||||
|
Then("the response error code should be {string}", function (this: CustomWorld, code: string) {
|
||||||
|
const expected = ErrorCode[code as keyof typeof ErrorCode];
|
||||||
|
assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`);
|
||||||
|
assert.equal(this.response.body.code, expected);
|
||||||
|
});
|
||||||
90
apps/api/features/step-definitions/household.steps.ts
Normal file
90
apps/api/features/step-definitions/household.steps.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import { Given, Then, When } from "@cucumber/cucumber";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
Given("I have a household named {string}", async function (this: CustomWorld, name: string) {
|
||||||
|
const res = await this.agent.post("/house").send({ name });
|
||||||
|
assert.equal(res.status, 201, JSON.stringify(res.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I create a household named {string}", async function (this: CustomWorld, name: string) {
|
||||||
|
this.response = await this.agent.post("/house").send({ name });
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I rename my household to {string}", async function (this: CustomWorld, name: string) {
|
||||||
|
this.response = await this.agent.patch("/house/current").send({ name });
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I leave the household", async function (this: CustomWorld) {
|
||||||
|
this.response = await this.agent.post("/house/leave");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I remove the second user from my household", async function (this: CustomWorld) {
|
||||||
|
const secondMe = await this.secondAgent.get("/auth/me");
|
||||||
|
this.response = await this.agent.delete(`/house/members/${secondMe.body.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("my household should be named {string}", async function (this: CustomWorld, name: string) {
|
||||||
|
const res = await this.agent.get("/house/current");
|
||||||
|
assert.equal(res.body.name, name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Steps involving a second, independently signed-in user ---------------
|
||||||
|
// The first ("a profile already exists with email ...") step is reused
|
||||||
|
// as-is for the second user too — it just inserts a row, agent-agnostic.
|
||||||
|
|
||||||
|
When(
|
||||||
|
"the second user logs in with email {string} and password {string}",
|
||||||
|
async function (this: CustomWorld, email: string, password: string) {
|
||||||
|
this.secondResponse = await this.secondAgent.post("/auth/login").send({ email, password });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When(
|
||||||
|
"the second user joins my household using its invite code",
|
||||||
|
async function (this: CustomWorld) {
|
||||||
|
const house = await this.agent.get("/house/current");
|
||||||
|
this.secondResponse = await this.secondAgent
|
||||||
|
.post("/house/join")
|
||||||
|
.send({ inviteCode: house.body.inviteCode });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When("the second user tries to delete the household", async function (this: CustomWorld) {
|
||||||
|
this.secondResponse = await this.secondAgent.delete("/house/current");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the second user's response status should be {int}",
|
||||||
|
function (this: CustomWorld, status: number) {
|
||||||
|
assert.equal(this.secondResponse.status, status);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the second user's response error code should be {string}",
|
||||||
|
function (this: CustomWorld, code: string) {
|
||||||
|
const expected = ErrorCode[code as keyof typeof ErrorCode];
|
||||||
|
assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`);
|
||||||
|
assert.equal(this.secondResponse.body.code, expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then("the second user should be a member of my household", async function (this: CustomWorld) {
|
||||||
|
const house = await this.agent.get("/house/current");
|
||||||
|
const secondMe = await this.secondAgent.get("/auth/me");
|
||||||
|
const memberIds = (house.body.members as Array<{ id: number }>).map((member) => member.id);
|
||||||
|
assert.ok(memberIds.includes(secondMe.body.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the second user should be the household's admin", async function (this: CustomWorld) {
|
||||||
|
const secondMe = await this.secondAgent.get("/auth/me");
|
||||||
|
const house = await this.secondAgent.get("/house/current");
|
||||||
|
assert.equal(house.body.adminId, secondMe.body.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the second user should have no household", async function (this: CustomWorld) {
|
||||||
|
const secondMe = await this.secondAgent.get("/auth/me");
|
||||||
|
assert.equal(secondMe.body.houseId, null);
|
||||||
|
});
|
||||||
61
apps/api/features/step-definitions/planning.steps.ts
Normal file
61
apps/api/features/step-definitions/planning.steps.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { DateTime } from "@batch-cooking/date-tools";
|
||||||
|
import { Given, Then, When } from "@cucumber/cucumber";
|
||||||
|
import { prisma } from "../../src/db/prisma.js";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
/** `GET /planning` takes `?date=` explicitly — this scenario wording ("the current planning") maps to "today". */
|
||||||
|
When("I request the current planning", async function (this: CustomWorld) {
|
||||||
|
this.response = await this.agent.get("/planning").query({ date: DateTime.utc().toISODate() });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the current planning response should be empty", function (this: CustomWorld) {
|
||||||
|
assert.equal(this.response.body, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Creates the planning/recipe rows directly via Prisma rather than through
|
||||||
|
// the API — there's no "create a planning" endpoint yet (see
|
||||||
|
// specs/batch-cooking-architecture.md, "Calcul batch-cooking" is still
|
||||||
|
// TODO), so this is the only way to get a household into a state where it
|
||||||
|
// has one. A household is no longer created implicitly at signup, so this
|
||||||
|
// step creates one via `POST /house` first — the scenario never names it
|
||||||
|
// explicitly, its name doesn't matter here.
|
||||||
|
Given(
|
||||||
|
"my household has a planning covering today with recipe {string} on {string} for {string}",
|
||||||
|
async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
|
||||||
|
const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
|
||||||
|
const houseId: number = houseRes.body.id;
|
||||||
|
|
||||||
|
const recipe = await prisma.recipe.create({ data: { name: recipeName } });
|
||||||
|
const today = new Date();
|
||||||
|
const planning = await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: new Date(
|
||||||
|
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2),
|
||||||
|
),
|
||||||
|
finishDate: new Date(
|
||||||
|
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.planningItem.create({
|
||||||
|
data: { planningId: planning.id, weekDay, meal, recipeId: recipe.id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the current planning response should include recipe {string} on {string} for {string}",
|
||||||
|
function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
|
||||||
|
const items = this.response.body.items as Array<{
|
||||||
|
weekDay: string;
|
||||||
|
meal: string;
|
||||||
|
recipe: { name: string };
|
||||||
|
}>;
|
||||||
|
const item = items.find((i) => i.recipe.name === recipeName);
|
||||||
|
assert.ok(item, `expected an item with recipe "${recipeName}", got ${JSON.stringify(items)}`);
|
||||||
|
assert.equal(item.weekDay, weekDay);
|
||||||
|
assert.equal(item.meal, meal);
|
||||||
|
},
|
||||||
|
);
|
||||||
15
apps/api/features/step-definitions/preferences.steps.ts
Normal file
15
apps/api/features/step-definitions/preferences.steps.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Then, When } from "@cucumber/cucumber";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
When("I request my preferences", async function (this: CustomWorld) {
|
||||||
|
this.response = await this.agent.get("/preferences");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I set my theme preference to {string}", async function (this: CustomWorld, theme: string) {
|
||||||
|
this.response = await this.agent.patch("/preferences").send({ theme });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("my theme preference should be {string}", function (this: CustomWorld, theme: string) {
|
||||||
|
assert.equal(this.response.body.theme, theme);
|
||||||
|
});
|
||||||
46
apps/api/features/step-definitions/profile.steps.ts
Normal file
46
apps/api/features/step-definitions/profile.steps.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Then, When } from "@cucumber/cucumber";
|
||||||
|
import { prisma } from "../../src/db/prisma.js";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
/** Splits a comma-separated list of names from a `.feature` string into trimmed, non-empty parts. */
|
||||||
|
function splitNames(names: string): string[] {
|
||||||
|
return names
|
||||||
|
.split(",")
|
||||||
|
.map((name) => name.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves allergen names (Category.name) to their Allergy id — see reference.service.ts for why the name lives on Category, not Allergy. */
|
||||||
|
async function allergyIdsFor(names: string[]): Promise<number[]> {
|
||||||
|
const allergies = await prisma.allergy.findMany({ include: { category: true } });
|
||||||
|
return names.map((name) => {
|
||||||
|
const match = allergies.find((allergy) => allergy.category.name === name);
|
||||||
|
if (!match) throw new Error(`No seeded allergen named "${name}"`);
|
||||||
|
return match.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
When("I set my regime to {string}", async function (this: CustomWorld, dietName: string) {
|
||||||
|
const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } });
|
||||||
|
this.response = await this.agent.patch("/profile/diet").send({ dietId: diet.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"my profile's regime should be {string}",
|
||||||
|
async function (this: CustomWorld, dietName: string) {
|
||||||
|
const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } });
|
||||||
|
assert.equal(this.response.body.dietId, diet.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When("I set my allergens to {string}", async function (this: CustomWorld, names: string) {
|
||||||
|
const allergyIds = await allergyIdsFor(splitNames(names));
|
||||||
|
this.response = await this.agent.patch("/profile/allergies").send({ allergyIds });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("my selected allergens should be {string}", async function (this: CustomWorld, names: string) {
|
||||||
|
const expected = (await allergyIdsFor(splitNames(names))).sort();
|
||||||
|
const actual = [...this.response.body].sort();
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
11
apps/api/features/step-definitions/reference.steps.ts
Normal file
11
apps/api/features/step-definitions/reference.steps.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Then } from "@cucumber/cucumber";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the reference list response should include {string}",
|
||||||
|
function (this: CustomWorld, name: string) {
|
||||||
|
const names = (this.response.body as Array<{ name: string }>).map((item) => item.name);
|
||||||
|
assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`);
|
||||||
|
},
|
||||||
|
);
|
||||||
10
apps/api/features/support/hooks.ts
Normal file
10
apps/api/features/support/hooks.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Before, setDefaultTimeout } from "@cucumber/cucumber";
|
||||||
|
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||||
|
|
||||||
|
// Integration tests hitting a real Postgres + argon2 need more than
|
||||||
|
// Cucumber's 5s default, especially on a cold Prisma connection.
|
||||||
|
setDefaultTimeout(10_000);
|
||||||
|
|
||||||
|
Before(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
26
apps/api/features/support/world.ts
Normal file
26
apps/api/features/support/world.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { type IWorldOptions, World, setWorldConstructor } from "@cucumber/cucumber";
|
||||||
|
import type { Express } from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../../src/app.js";
|
||||||
|
|
||||||
|
// Fresh Express app per scenario (in-process, via supertest — no server to
|
||||||
|
// spin up/tear down) plus the last HTTP response, available to every step.
|
||||||
|
// `agent` persists cookies across requests within a scenario (needed for
|
||||||
|
// "sign up then check I'm authenticated" style flows).
|
||||||
|
export class CustomWorld extends World {
|
||||||
|
app: Express;
|
||||||
|
agent: ReturnType<typeof request.agent>;
|
||||||
|
response!: request.Response;
|
||||||
|
/** A second, independent session (own cookie jar) — only used by scenarios needing two distinct signed-in users, e.g. household invites/admin transfer/member removal. */
|
||||||
|
secondAgent: ReturnType<typeof request.agent>;
|
||||||
|
secondResponse!: request.Response;
|
||||||
|
|
||||||
|
constructor(options: IWorldOptions) {
|
||||||
|
super(options);
|
||||||
|
this.app = createApp();
|
||||||
|
this.agent = request.agent(this.app);
|
||||||
|
this.secondAgent = request.agent(this.app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorldConstructor(CustomWorld);
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "cross-env NODE_ENV=test mocha",
|
"test": "cross-env NODE_ENV=test mocha",
|
||||||
|
"test:bdd": "cross-env NODE_ENV=test NODE_OPTIONS=--import=tsx cucumber-js",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:seed": "prisma db seed",
|
"prisma:seed": "prisma db seed",
|
||||||
|
|
@ -23,15 +24,13 @@
|
||||||
"@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",
|
|
||||||
"socket.io": "^4.8.3",
|
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cucumber/cucumber": "^13.2.1",
|
||||||
"@faker-js/faker": "^10.6.0",
|
"@faker-js/faker": "^10.6.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
|
@ -40,7 +39,7 @@
|
||||||
"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",
|
"prisma": "^5.22.0",
|
||||||
"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,17 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "ingredient_allergy" (
|
|
||||||
"ingredient_id" INTEGER NOT NULL,
|
|
||||||
"allergy_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "ingredient_allergy_pkey" PRIMARY KEY ("ingredient_id","allergy_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "ingredients_name_key" ON "ingredients"("name");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ingredient_allergy" ADD CONSTRAINT "ingredient_allergy_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ingredient_allergy" ADD CONSTRAINT "ingredient_allergy_allergy_id_fkey" FOREIGN KEY ("allergy_id") REFERENCES "allergy"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "RecipeVisibility" AS ENUM ('PERSONAL', 'HOUSE', 'PUBLIC');
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "recipe" ADD COLUMN "author_house_id" INTEGER,
|
|
||||||
ADD COLUMN "author_id" INTEGER NOT NULL,
|
|
||||||
ADD COLUMN "visibility" "RecipeVisibility" NOT NULL DEFAULT 'PERSONAL';
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "user_profile_disliked_ingredient" (
|
|
||||||
"user_profile_id" INTEGER NOT NULL,
|
|
||||||
"ingredient_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "user_profile_disliked_ingredient_pkey" PRIMARY KEY ("user_profile_id","ingredient_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "recipe_favorite" (
|
|
||||||
"user_profile_id" INTEGER NOT NULL,
|
|
||||||
"recipe_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "recipe_favorite_pkey" PRIMARY KEY ("user_profile_id","recipe_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "recipe_diet" (
|
|
||||||
"recipe_id" INTEGER NOT NULL,
|
|
||||||
"diet_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "recipe_diet_pkey" PRIMARY KEY ("recipe_id","diet_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "user_profile_disliked_ingredient" ADD CONSTRAINT "user_profile_disliked_ingredient_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "user_profile_disliked_ingredient" ADD CONSTRAINT "user_profile_disliked_ingredient_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe" ADD CONSTRAINT "recipe_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "user_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe" ADD CONSTRAINT "recipe_author_house_id_fkey" FOREIGN KEY ("author_house_id") REFERENCES "house"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe_favorite" ADD CONSTRAINT "recipe_favorite_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe_favorite" ADD CONSTRAINT "recipe_favorite_recipe_id_fkey" FOREIGN KEY ("recipe_id") REFERENCES "recipe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe_diet" ADD CONSTRAINT "recipe_diet_recipe_id_fkey" FOREIGN KEY ("recipe_id") REFERENCES "recipe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe_diet" ADD CONSTRAINT "recipe_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "IngredientCategory" AS ENUM ('CEREALES_FECULENTS', 'LEGUMINEUSES', 'VIANDES_VOLAILLES', 'POISSONS_FRUITS_DE_MER', 'PRODUITS_LAITIERS_OEUFS', 'LEGUMES', 'FRUITS', 'FRUITS_SECS_OLEAGINEUX', 'CONDIMENTS_SAUCES', 'EPICES_HERBES', 'SUCRE_PATISSERIE', 'CUISINE_ITALIENNE', 'CUISINE_ASIATIQUE', 'CUISINE_MEXICAINE', 'MAGHREB_MOYEN_ORIENT', 'PAINS_SANDWICHS', 'EPICERIE_DIVERS', 'LIQUIDES_BOISSONS');
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "category" "IngredientCategory" NOT NULL DEFAULT 'EPICERIE_DIVERS';
|
|
||||||
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "ingredient_diet" (
|
|
||||||
"ingredient_id" INTEGER NOT NULL,
|
|
||||||
"diet_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "ingredient_diet_pkey" PRIMARY KEY ("ingredient_id","diet_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
-- Rewritten after this migration failed on a database that already had
|
|
||||||
-- seeded ingredient rows: the original version (auto-generated by `prisma
|
|
||||||
-- migrate diff`) cast every existing `category` value directly from the
|
|
||||||
-- old 18-value enum to the new 7-value one, which fails for every row
|
|
||||||
-- since none of the old values exist in the new enum. This version instead
|
|
||||||
-- adds the new columns with a safe default (never casting existing data),
|
|
||||||
-- then swaps them in — the same "add with a default, correct for real on
|
|
||||||
-- the next seed run" pattern already used for `IngredientCategory`'s and
|
|
||||||
-- `IngredientSubcategory`'s own `@default(...)` (see their doc comments in
|
|
||||||
-- schema.prisma). `seedReferenceData()` runs right after `migrate deploy`
|
|
||||||
-- on every container start (see apps/api/Dockerfile) and corrects every
|
|
||||||
-- row's real category/subcategory immediately.
|
|
||||||
--
|
|
||||||
-- `DROP TYPE IF EXISTS "IngredientSubcategory"` guards against a previous
|
|
||||||
-- failed attempt at this exact migration: that CREATE TYPE statement runs
|
|
||||||
-- outside the AlterEnum transaction below and so persists even though the
|
|
||||||
-- rest of that failed attempt rolled back — retrying without this guard
|
|
||||||
-- would hit "type already exists".
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
DROP TYPE IF EXISTS "IngredientSubcategory";
|
|
||||||
CREATE TYPE "IngredientSubcategory" AS ENUM ('LEGUMES', 'FRUITS', 'HERBES_FRAICHES', 'VIANDES', 'VOLAILLES', 'POISSONS', 'CRUSTACES_FRUITS_DE_MER', 'FECULENTS', 'LEGUMINEUSES', 'GRAINES_FRUITS_SECS', 'AUTRES', 'PAINS', 'PATES_A_CUIRE', 'PRODUITS_LAITIERS', 'OEUFS', 'ALTERNATIVES', 'EPICES', 'SAUCES', 'ASSAISONNEMENTS', 'BASES', 'EPAISSISSANTS', 'SUCRES');
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
DROP TYPE IF EXISTS "IngredientCategory_new";
|
|
||||||
CREATE TYPE "IngredientCategory_new" AS ENUM ('PRODUITS_FRAIS', 'BOUCHERIE_POISSONNERIE', 'EPICERIE_SECHE', 'BOULANGERIE', 'CREMERIE_FROMAGE', 'CONDIMENTS_EPICES', 'AIDES_CULINAIRES');
|
|
||||||
|
|
||||||
-- AlterTable: add the new columns at their defaults — no cast of existing
|
|
||||||
-- `category` values, so this succeeds regardless of what the table
|
|
||||||
-- currently holds.
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "category_new" "IngredientCategory_new" NOT NULL DEFAULT 'EPICERIE_SECHE';
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "subcategory" "IngredientSubcategory" NOT NULL DEFAULT 'AUTRES';
|
|
||||||
|
|
||||||
-- Swap the old `category` column (old 18-value enum) out for the new one.
|
|
||||||
ALTER TABLE "ingredients" DROP COLUMN "category";
|
|
||||||
ALTER TABLE "ingredients" RENAME COLUMN "category_new" TO "category";
|
|
||||||
DROP TYPE "IngredientCategory";
|
|
||||||
ALTER TYPE "IngredientCategory_new" RENAME TO "IngredientCategory";
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "IngredientIcon" AS ENUM ('VEGETABLE', 'FRUIT', 'HERB', 'MEAT', 'POULTRY', 'FISH', 'SHELLFISH', 'GRAIN', 'LEGUME', 'NUT_SEED', 'BREAD', 'DOUGH', 'MILK', 'CHEESE', 'EGG', 'SPROUT', 'SPICE', 'JAR', 'BOTTLE', 'DRINK', 'STOCK_POT', 'SUGAR');
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "ingredients" DROP COLUMN "icon",
|
|
||||||
ADD COLUMN "icon" "IngredientIcon" NOT NULL DEFAULT 'JAR';
|
|
||||||
|
|
@ -1,480 +0,0 @@
|
||||||
-- Renames the reference-data label column to a stable slug key on Diet,
|
|
||||||
-- Category (allergens), and Ingredient — see `utils/slugify.ts` and
|
|
||||||
-- `scripts/generate-catalog-i18n.ts`. The display label these columns used
|
|
||||||
-- to hold moves to `apps/web`'s `locales/fr/translation.json` (`catalog.*`
|
|
||||||
-- namespace) instead.
|
|
||||||
|
|
||||||
ALTER TABLE "diet" RENAME COLUMN "name" TO "key";
|
|
||||||
ALTER INDEX "diet_name_key" RENAME TO "diet_key_key";
|
|
||||||
|
|
||||||
ALTER TABLE "category" RENAME COLUMN "name" TO "key";
|
|
||||||
ALTER INDEX "category_name_key" RENAME TO "category_key_key";
|
|
||||||
|
|
||||||
ALTER TABLE "ingredients" RENAME COLUMN "name" TO "key";
|
|
||||||
ALTER INDEX "ingredients_name_key" RENAME TO "ingredients_key_key";
|
|
||||||
|
|
||||||
-- Auto-generated by scripts/generate-catalog-i18n.ts — do not hand-edit.
|
|
||||||
-- Backfills the "key" column (just renamed from "name" by this migration's
|
|
||||||
-- preceding statement, so it still holds the old French label) to its slug
|
|
||||||
-- value, for every row already seeded in a database this migration runs
|
|
||||||
-- against. A fresh database has none of these rows yet (the seed script
|
|
||||||
-- inserts by "key" from the start), so this is a no-op there.
|
|
||||||
|
|
||||||
UPDATE "diet" SET "key" = 'omnivore' WHERE "key" = 'Omnivore';
|
|
||||||
UPDATE "diet" SET "key" = 'vegetarien' WHERE "key" = 'Végétarien';
|
|
||||||
UPDATE "diet" SET "key" = 'vegan' WHERE "key" = 'Végan';
|
|
||||||
UPDATE "diet" SET "key" = 'pescetarien' WHERE "key" = 'Pescétarien';
|
|
||||||
UPDATE "diet" SET "key" = 'sans_gluten' WHERE "key" = 'Sans gluten';
|
|
||||||
|
|
||||||
UPDATE "category" SET "key" = 'gluten' WHERE "key" = 'Gluten';
|
|
||||||
UPDATE "category" SET "key" = 'crustaces' WHERE "key" = 'Crustacés';
|
|
||||||
UPDATE "category" SET "key" = 'oeufs' WHERE "key" = 'Œufs';
|
|
||||||
UPDATE "category" SET "key" = 'poissons' WHERE "key" = 'Poissons';
|
|
||||||
UPDATE "category" SET "key" = 'arachides' WHERE "key" = 'Arachides';
|
|
||||||
UPDATE "category" SET "key" = 'soja' WHERE "key" = 'Soja';
|
|
||||||
UPDATE "category" SET "key" = 'lait' WHERE "key" = 'Lait';
|
|
||||||
UPDATE "category" SET "key" = 'fruits_a_coque' WHERE "key" = 'Fruits à coque';
|
|
||||||
UPDATE "category" SET "key" = 'celeri' WHERE "key" = 'Céleri';
|
|
||||||
UPDATE "category" SET "key" = 'moutarde' WHERE "key" = 'Moutarde';
|
|
||||||
UPDATE "category" SET "key" = 'graines_de_sesame' WHERE "key" = 'Graines de sésame';
|
|
||||||
UPDATE "category" SET "key" = 'sulfites' WHERE "key" = 'Sulfites';
|
|
||||||
UPDATE "category" SET "key" = 'lupin' WHERE "key" = 'Lupin';
|
|
||||||
UPDATE "category" SET "key" = 'mollusques' WHERE "key" = 'Mollusques';
|
|
||||||
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomate' WHERE "key" = 'Tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oignon' WHERE "key" = 'Oignon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'echalote' WHERE "key" = 'Échalote';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ail' WHERE "key" = 'Ail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'carotte' WHERE "key" = 'Carotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'courgette' WHERE "key" = 'Courgette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'concombre' WHERE "key" = 'Concombre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cornichons' WHERE "key" = 'Cornichons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poivron' WHERE "key" = 'Poivron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'champignon' WHERE "key" = 'Champignon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cepes' WHERE "key" = 'Cèpes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'aubergine' WHERE "key" = 'Aubergine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brocoli' WHERE "key" = 'Brocoli';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chou_fleur' WHERE "key" = 'Chou-fleur';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chou_blanc' WHERE "key" = 'Chou blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chou_rouge' WHERE "key" = 'Chou rouge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chou_de_bruxelles' WHERE "key" = 'Chou de Bruxelles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'epinard' WHERE "key" = 'Épinard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blette' WHERE "key" = 'Blette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'salade' WHERE "key" = 'Salade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'roquette' WHERE "key" = 'Roquette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cresson' WHERE "key" = 'Cresson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poireau' WHERE "key" = 'Poireau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'celeri' WHERE "key" = 'Céleri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'radis' WHERE "key" = 'Radis';
|
|
||||||
UPDATE "ingredients" SET "key" = 'betterave' WHERE "key" = 'Betterave';
|
|
||||||
UPDATE "ingredients" SET "key" = 'navet' WHERE "key" = 'Navet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'panais' WHERE "key" = 'Panais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haricot_vert' WHERE "key" = 'Haricot vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'petit_pois' WHERE "key" = 'Petit pois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mais' WHERE "key" = 'Maïs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'artichaut' WHERE "key" = 'Artichaut';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fenouil' WHERE "key" = 'Fenouil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'endive' WHERE "key" = 'Endive';
|
|
||||||
UPDATE "ingredients" SET "key" = 'potiron' WHERE "key" = 'Potiron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'butternut' WHERE "key" = 'Butternut';
|
|
||||||
UPDATE "ingredients" SET "key" = 'asperge' WHERE "key" = 'Asperge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'avocat' WHERE "key" = 'Avocat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pomme_de_terre' WHERE "key" = 'Pomme de terre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'patate_douce' WHERE "key" = 'Patate douce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomates_cerises' WHERE "key" = 'Tomates cerises';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pak_choi' WHERE "key" = 'Pak-choï';
|
|
||||||
UPDATE "ingredients" SET "key" = 'germes_de_soja' WHERE "key" = 'Germes de soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shiitake' WHERE "key" = 'Shiitake';
|
|
||||||
UPDATE "ingredients" SET "key" = 'daikon' WHERE "key" = 'Daikon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_vert_frais' WHERE "key" = 'Piment vert frais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'citron' WHERE "key" = 'Citron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'citron_vert' WHERE "key" = 'Citron vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pomme' WHERE "key" = 'Pomme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poire' WHERE "key" = 'Poire';
|
|
||||||
UPDATE "ingredients" SET "key" = 'banane' WHERE "key" = 'Banane';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orange' WHERE "key" = 'Orange';
|
|
||||||
UPDATE "ingredients" SET "key" = 'clementine' WHERE "key" = 'Clémentine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pamplemousse' WHERE "key" = 'Pamplemousse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fraise' WHERE "key" = 'Fraise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'framboise' WHERE "key" = 'Framboise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'myrtille' WHERE "key" = 'Myrtille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mure' WHERE "key" = 'Mûre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cerise' WHERE "key" = 'Cerise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'abricot' WHERE "key" = 'Abricot';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peche' WHERE "key" = 'Pêche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'prune' WHERE "key" = 'Prune';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raisin' WHERE "key" = 'Raisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'melon' WHERE "key" = 'Melon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pasteque' WHERE "key" = 'Pastèque';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ananas' WHERE "key" = 'Ananas';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mangue' WHERE "key" = 'Mangue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kiwi' WHERE "key" = 'Kiwi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'figue' WHERE "key" = 'Figue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'datte' WHERE "key" = 'Datte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'litchi' WHERE "key" = 'Litchi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grenade' WHERE "key" = 'Grenade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rhubarbe' WHERE "key" = 'Rhubarbe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coing' WHERE "key" = 'Coing';
|
|
||||||
UPDATE "ingredients" SET "key" = 'basilic' WHERE "key" = 'Basilic';
|
|
||||||
UPDATE "ingredients" SET "key" = 'persil' WHERE "key" = 'Persil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'thym' WHERE "key" = 'Thym';
|
|
||||||
UPDATE "ingredients" SET "key" = 'romarin' WHERE "key" = 'Romarin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'laurier' WHERE "key" = 'Laurier';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ciboulette' WHERE "key" = 'Ciboulette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coriandre_fraiche' WHERE "key" = 'Coriandre fraîche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'menthe' WHERE "key" = 'Menthe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'origan' WHERE "key" = 'Origan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'aneth' WHERE "key" = 'Aneth';
|
|
||||||
UPDATE "ingredients" SET "key" = 'estragon' WHERE "key" = 'Estragon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sarriette' WHERE "key" = 'Sarriette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'marjolaine' WHERE "key" = 'Marjolaine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauge' WHERE "key" = 'Sauge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cerfeuil' WHERE "key" = 'Cerfeuil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gingembre' WHERE "key" = 'Gingembre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'citronnelle' WHERE "key" = 'Citronnelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'combava' WHERE "key" = 'Combava';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lapin' WHERE "key" = 'Lapin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'boeuf_hache' WHERE "key" = 'Bœuf haché';
|
|
||||||
UPDATE "ingredients" SET "key" = 'steak_de_boeuf' WHERE "key" = 'Steak de bœuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'roti_de_boeuf' WHERE "key" = 'Rôti de bœuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'escalope_de_veau' WHERE "key" = 'Escalope de veau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'filet_mignon_de_porc' WHERE "key" = 'Filet mignon de porc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cote_de_porc' WHERE "key" = 'Côte de porc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'agneau' WHERE "key" = 'Agneau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gigot_d_agneau' WHERE "key" = 'Gigot d''agneau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lardons' WHERE "key" = 'Lardons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bacon' WHERE "key" = 'Bacon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jambon_blanc' WHERE "key" = 'Jambon blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jambon_cru' WHERE "key" = 'Jambon cru';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saucisse' WHERE "key" = 'Saucisse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chorizo' WHERE "key" = 'Chorizo';
|
|
||||||
UPDATE "ingredients" SET "key" = 'merguez' WHERE "key" = 'Merguez';
|
|
||||||
UPDATE "ingredients" SET "key" = 'prosciutto' WHERE "key" = 'Prosciutto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pancetta' WHERE "key" = 'Pancetta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mortadelle' WHERE "key" = 'Mortadelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'salami' WHERE "key" = 'Salami';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poulet' WHERE "key" = 'Poulet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dinde' WHERE "key" = 'Dinde';
|
|
||||||
UPDATE "ingredients" SET "key" = 'canard' WHERE "key" = 'Canard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'magret_de_canard' WHERE "key" = 'Magret de canard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saumon' WHERE "key" = 'Saumon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'thon' WHERE "key" = 'Thon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cabillaud' WHERE "key" = 'Cabillaud';
|
|
||||||
UPDATE "ingredients" SET "key" = 'truite' WHERE "key" = 'Truite';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sardine' WHERE "key" = 'Sardine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'anchois' WHERE "key" = 'Anchois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'merlan' WHERE "key" = 'Merlan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'surimi' WHERE "key" = 'Surimi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bar_loup_de_mer' WHERE "key" = 'Bar (loup de mer)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dorade' WHERE "key" = 'Dorade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sole' WHERE "key" = 'Sole';
|
|
||||||
UPDATE "ingredients" SET "key" = 'turbot' WHERE "key" = 'Turbot';
|
|
||||||
UPDATE "ingredients" SET "key" = 'merlu' WHERE "key" = 'Merlu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'colin' WHERE "key" = 'Colin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lieu_noir' WHERE "key" = 'Lieu noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eglefin' WHERE "key" = 'Églefin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'maquereau' WHERE "key" = 'Maquereau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hareng' WHERE "key" = 'Hareng';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rouget' WHERE "key" = 'Rouget';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raie' WHERE "key" = 'Raie';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lotte' WHERE "key" = 'Lotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fletan' WHERE "key" = 'Flétan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'espadon' WHERE "key" = 'Espadon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'carpe' WHERE "key" = 'Carpe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brochet' WHERE "key" = 'Brochet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'perche' WHERE "key" = 'Perche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tilapia' WHERE "key" = 'Tilapia';
|
|
||||||
UPDATE "ingredients" SET "key" = 'panga' WHERE "key" = 'Panga';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saumon_fume' WHERE "key" = 'Saumon fumé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poisson_seche' WHERE "key" = 'Poisson séché';
|
|
||||||
UPDATE "ingredients" SET "key" = 'crevettes' WHERE "key" = 'Crevettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'langoustines' WHERE "key" = 'Langoustines';
|
|
||||||
UPDATE "ingredients" SET "key" = 'homard' WHERE "key" = 'Homard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'crabe' WHERE "key" = 'Crabe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'langouste' WHERE "key" = 'Langouste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'moules' WHERE "key" = 'Moules';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huitres' WHERE "key" = 'Huîtres';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saint_jacques' WHERE "key" = 'Saint-Jacques';
|
|
||||||
UPDATE "ingredients" SET "key" = 'calamar' WHERE "key" = 'Calamar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poulpe' WHERE "key" = 'Poulpe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'palourdes' WHERE "key" = 'Palourdes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bulots' WHERE "key" = 'Bulots';
|
|
||||||
UPDATE "ingredients" SET "key" = 'semoule' WHERE "key" = 'Semoule';
|
|
||||||
UPDATE "ingredients" SET "key" = 'couscous' WHERE "key" = 'Couscous';
|
|
||||||
UPDATE "ingredients" SET "key" = 'boulgour' WHERE "key" = 'Boulgour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'polenta' WHERE "key" = 'Polenta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'quinoa' WHERE "key" = 'Quinoa';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pates' WHERE "key" = 'Pâtes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pates_completes' WHERE "key" = 'Pâtes complètes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz' WHERE "key" = 'Riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_basmati' WHERE "key" = 'Riz basmati';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_complet' WHERE "key" = 'Riz complet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'flocons_d_avoine' WHERE "key" = 'Flocons d''avoine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spaghetti' WHERE "key" = 'Spaghetti';
|
|
||||||
UPDATE "ingredients" SET "key" = 'penne' WHERE "key" = 'Penne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tagliatelles' WHERE "key" = 'Tagliatelles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lasagnes_feuilles' WHERE "key" = 'Lasagnes (feuilles)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gnocchi' WHERE "key" = 'Gnocchi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_arborio' WHERE "key" = 'Riz arborio';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nouilles_de_riz' WHERE "key" = 'Nouilles de riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nouilles_udon' WHERE "key" = 'Nouilles udon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nouilles_soba' WHERE "key" = 'Nouilles soba';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nouilles_chinoises' WHERE "key" = 'Nouilles chinoises';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vermicelles_de_riz' WHERE "key" = 'Vermicelles de riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vermicelles_de_soja' WHERE "key" = 'Vermicelles de soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_gluant' WHERE "key" = 'Riz gluant';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_a_sushi' WHERE "key" = 'Riz à sushi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_jasmin' WHERE "key" = 'Riz jasmin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lentilles_vertes' WHERE "key" = 'Lentilles vertes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lentilles_corail' WHERE "key" = 'Lentilles corail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pois_chiches' WHERE "key" = 'Pois chiches';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haricots_blancs' WHERE "key" = 'Haricots blancs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haricots_rouges' WHERE "key" = 'Haricots rouges';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haricots_noirs' WHERE "key" = 'Haricots noirs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pois_casses' WHERE "key" = 'Pois cassés';
|
|
||||||
UPDATE "ingredients" SET "key" = 'feves' WHERE "key" = 'Fèves';
|
|
||||||
UPDATE "ingredients" SET "key" = 'edamame' WHERE "key" = 'Edamame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haricots_pinto' WHERE "key" = 'Haricots pinto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cacahuetes' WHERE "key" = 'Cacahuètes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'amandes' WHERE "key" = 'Amandes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noix' WHERE "key" = 'Noix';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noisettes' WHERE "key" = 'Noisettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noix_de_cajou' WHERE "key" = 'Noix de cajou';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pistaches' WHERE "key" = 'Pistaches';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noix_de_pecan' WHERE "key" = 'Noix de pécan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poudre_d_amande' WHERE "key" = 'Poudre d''amande';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pignons_de_pin' WHERE "key" = 'Pignons de pin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'graines_de_tournesol' WHERE "key" = 'Graines de tournesol';
|
|
||||||
UPDATE "ingredients" SET "key" = 'graines_de_courge' WHERE "key" = 'Graines de courge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noix_de_coco_rapee' WHERE "key" = 'Noix de coco râpée';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raisins_secs' WHERE "key" = 'Raisins secs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pruneaux' WHERE "key" = 'Pruneaux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'abricots_secs' WHERE "key" = 'Abricots secs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'graines_de_sesame' WHERE "key" = 'Graines de sésame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'champignons_noirs' WHERE "key" = 'Champignons noirs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'algue_nori' WHERE "key" = 'Algue nori';
|
|
||||||
UPDATE "ingredients" SET "key" = 'algue_wakame' WHERE "key" = 'Algue wakamé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'algue_kombu' WHERE "key" = 'Algue kombu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pousses_de_bambou' WHERE "key" = 'Pousses de bambou';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chataignes_d_eau' WHERE "key" = 'Châtaignes d''eau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain' WHERE "key" = 'Pain';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_de_mie' WHERE "key" = 'Pain de mie';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_complet' WHERE "key" = 'Pain complet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baguette' WHERE "key" = 'Baguette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_de_seigle' WHERE "key" = 'Pain de seigle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chapelure' WHERE "key" = 'Chapelure';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_a_burger' WHERE "key" = 'Pain à burger';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_brioche' WHERE "key" = 'Pain brioché';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_a_hot_dog' WHERE "key" = 'Pain à hot-dog';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_pita' WHERE "key" = 'Pain pita';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_bagel' WHERE "key" = 'Pain bagel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'naan' WHERE "key" = 'Naan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_wrap' WHERE "key" = 'Pain wrap';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_viennois' WHERE "key" = 'Pain viennois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_de_campagne' WHERE "key" = 'Pain de campagne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_aux_cereales' WHERE "key" = 'Pain aux céréales';
|
|
||||||
UPDATE "ingredients" SET "key" = 'petit_pain' WHERE "key" = 'Petit pain';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_suedois' WHERE "key" = 'Pain suédois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_sans_gluten' WHERE "key" = 'Pain sans gluten';
|
|
||||||
UPDATE "ingredients" SET "key" = 'biscotte' WHERE "key" = 'Biscotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'croutons' WHERE "key" = 'Croûtons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'focaccia' WHERE "key" = 'Focaccia';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ciabatta' WHERE "key" = 'Ciabatta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tortilla_de_mais' WHERE "key" = 'Tortilla de maïs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tortilla_de_ble' WHERE "key" = 'Tortilla de blé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_feuilletee' WHERE "key" = 'Pâte feuilletée';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_brisee' WHERE "key" = 'Pâte brisée';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_a_pizza' WHERE "key" = 'Pâte à pizza';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_a_tarte_sablee' WHERE "key" = 'Pâte à tarte sablée';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lait' WHERE "key" = 'Lait';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beurre' WHERE "key" = 'Beurre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'creme_fraiche' WHERE "key" = 'Crème fraîche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'creme_liquide' WHERE "key" = 'Crème liquide';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fromage' WHERE "key" = 'Fromage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'emmental' WHERE "key" = 'Emmental';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gruyere' WHERE "key" = 'Gruyère';
|
|
||||||
UPDATE "ingredients" SET "key" = 'parmesan' WHERE "key" = 'Parmesan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mozzarella' WHERE "key" = 'Mozzarella';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chevre_fromage' WHERE "key" = 'Chèvre (fromage)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'feta' WHERE "key" = 'Feta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'comte' WHERE "key" = 'Comté';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fromage_blanc' WHERE "key" = 'Fromage blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mascarpone' WHERE "key" = 'Mascarpone';
|
|
||||||
UPDATE "ingredients" SET "key" = 'yaourt' WHERE "key" = 'Yaourt';
|
|
||||||
UPDATE "ingredients" SET "key" = 'burrata' WHERE "key" = 'Burrata';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ricotta' WHERE "key" = 'Ricotta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pecorino' WHERE "key" = 'Pecorino';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gorgonzola' WHERE "key" = 'Gorgonzola';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cheddar' WHERE "key" = 'Cheddar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oeuf' WHERE "key" = 'Œuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lait_de_coco' WHERE "key" = 'Lait de coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'creme_de_coco' WHERE "key" = 'Crème de coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lait_d_amande' WHERE "key" = 'Lait d''amande';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lait_d_avoine' WHERE "key" = 'Lait d''avoine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tofu' WHERE "key" = 'Tofu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tofu_soyeux' WHERE "key" = 'Tofu soyeux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herbes_de_provence' WHERE "key" = 'Herbes de Provence';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poivre_noir' WHERE "key" = 'Poivre noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'paprika' WHERE "key" = 'Paprika';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_d_espelette' WHERE "key" = 'Piment d''Espelette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_de_cayenne' WHERE "key" = 'Piment de Cayenne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cumin' WHERE "key" = 'Cumin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curry_poudre' WHERE "key" = 'Curry (poudre)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curcuma' WHERE "key" = 'Curcuma';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cannelle' WHERE "key" = 'Cannelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'muscade' WHERE "key" = 'Muscade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'safran' WHERE "key" = 'Safran';
|
|
||||||
UPDATE "ingredients" SET "key" = 'clou_de_girofle' WHERE "key" = 'Clou de girofle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vanille_gousse' WHERE "key" = 'Vanille (gousse)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poivre_blanc' WHERE "key" = 'Poivre blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poivre_rose' WHERE "key" = 'Poivre rose';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poivre_du_sichuan' WHERE "key" = 'Poivre du Sichuan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'paprika_fume' WHERE "key" = 'Paprika fumé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_oiseau' WHERE "key" = 'Piment oiseau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baies_de_genievre' WHERE "key" = 'Baies de genièvre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'anis_etoile_badiane' WHERE "key" = 'Anis étoilé (badiane)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'anis_vert' WHERE "key" = 'Anis vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'graines_de_fenouil' WHERE "key" = 'Graines de fenouil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sumac' WHERE "key" = 'Sumac';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nigelle' WHERE "key" = 'Nigelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'quatre_epices' WHERE "key" = 'Quatre épices';
|
|
||||||
UPDATE "ingredients" SET "key" = 'colombo_poudre' WHERE "key" = 'Colombo (poudre)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baharat' WHERE "key" = 'Baharat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raifort' WHERE "key" = 'Raifort';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sel_aux_herbes' WHERE "key" = 'Sel aux herbes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sel_de_celeri' WHERE "key" = 'Sel de céleri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fleur_de_sel' WHERE "key" = 'Fleur de sel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sel' WHERE "key" = 'Sel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cinq_epices' WHERE "key" = 'Cinq épices';
|
|
||||||
UPDATE "ingredients" SET "key" = 'garam_masala' WHERE "key" = 'Garam masala';
|
|
||||||
UPDATE "ingredients" SET "key" = 'graines_de_coriandre' WHERE "key" = 'Graines de coriandre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cardamome' WHERE "key" = 'Cardamome';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fenugrec' WHERE "key" = 'Fenugrec';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_jalapeno' WHERE "key" = 'Piment jalapeño';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_chipotle' WHERE "key" = 'Piment chipotle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_poblano' WHERE "key" = 'Piment poblano';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_habanero' WHERE "key" = 'Piment habanero';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ras_el_hanout' WHERE "key" = 'Ras el hanout';
|
|
||||||
UPDATE "ingredients" SET "key" = 'za_atar' WHERE "key" = 'Za''atar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_soja' WHERE "key" = 'Sauce soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'moutarde' WHERE "key" = 'Moutarde';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mayonnaise' WHERE "key" = 'Mayonnaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ketchup' WHERE "key" = 'Ketchup';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tabasco' WHERE "key" = 'Tabasco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_worcestershire' WHERE "key" = 'Sauce Worcestershire';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_nuoc_mam' WHERE "key" = 'Sauce nuoc-mâm';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wasabi' WHERE "key" = 'Wasabi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'harissa' WHERE "key" = 'Harissa';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_de_curry' WHERE "key" = 'Pâte de curry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beurre_de_cacahuete' WHERE "key" = 'Beurre de cacahuète';
|
|
||||||
UPDATE "ingredients" SET "key" = 'moutarde_de_dijon' WHERE "key" = 'Moutarde de Dijon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'moutarde_a_l_ancienne' WHERE "key" = 'Moutarde à l''ancienne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_barbecue' WHERE "key" = 'Sauce barbecue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_tartare' WHERE "key" = 'Sauce tartare';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_cocktail' WHERE "key" = 'Sauce cocktail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_bearnaise' WHERE "key" = 'Sauce béarnaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_hollandaise' WHERE "key" = 'Sauce hollandaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_bechamel' WHERE "key" = 'Sauce béchamel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_teriyaki' WHERE "key" = 'Sauce teriyaki';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_ponzu' WHERE "key" = 'Sauce ponzu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chimichurri' WHERE "key" = 'Chimichurri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pesto_rouge_tomates_sechees' WHERE "key" = 'Pesto rouge (tomates séchées)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pesto' WHERE "key" = 'Pesto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_huitre' WHERE "key" = 'Sauce huître';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_hoisin' WHERE "key" = 'Sauce hoisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_sriracha' WHERE "key" = 'Sauce sriracha';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_sweet_chili' WHERE "key" = 'Sauce sweet chili';
|
|
||||||
UPDATE "ingredients" SET "key" = 'miso' WHERE "key" = 'Miso';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_de_crevettes' WHERE "key" = 'Pâte de crevettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_de_curry_rouge_thai' WHERE "key" = 'Pâte de curry rouge (thaï)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_de_curry_vert_thai' WHERE "key" = 'Pâte de curry vert (thaï)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tahini' WHERE "key" = 'Tahini';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_d_olive' WHERE "key" = 'Huile d''olive';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_tournesol' WHERE "key" = 'Huile de tournesol';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_colza' WHERE "key" = 'Huile de colza';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_coco' WHERE "key" = 'Huile de coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_sesame' WHERE "key" = 'Huile de sésame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_de_cidre' WHERE "key" = 'Vinaigre de cidre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_blanc' WHERE "key" = 'Vinaigre blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_balsamique' WHERE "key" = 'Vinaigre balsamique';
|
|
||||||
UPDATE "ingredients" SET "key" = 'capres' WHERE "key" = 'Câpres';
|
|
||||||
UPDATE "ingredients" SET "key" = 'olives' WHERE "key" = 'Olives';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vin_blanc_cuisine' WHERE "key" = 'Vin blanc (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vin_rouge_cuisine' WHERE "key" = 'Vin rouge (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_de_vin_rouge' WHERE "key" = 'Vinaigre de vin rouge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_de_vin_blanc' WHERE "key" = 'Vinaigre de vin blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_de_xeres' WHERE "key" = 'Vinaigre de xérès';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_noix' WHERE "key" = 'Huile de noix';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_noisette' WHERE "key" = 'Huile de noisette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_d_arachide' WHERE "key" = 'Huile d''arachide';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_pimentee' WHERE "key" = 'Huile pimentée';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_de_riz' WHERE "key" = 'Vinaigre de riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mirin' WHERE "key" = 'Mirin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sake_cuisine' WHERE "key" = 'Saké (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_citron' WHERE "key" = 'Jus de citron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_citron_vert' WHERE "key" = 'Jus de citron vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_d_orange' WHERE "key" = 'Jus d''orange';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_pomme' WHERE "key" = 'Jus de pomme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_raisin' WHERE "key" = 'Jus de raisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_tomate' WHERE "key" = 'Jus de tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_cranberry' WHERE "key" = 'Jus de cranberry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cafe' WHERE "key" = 'Café';
|
|
||||||
UPDATE "ingredients" SET "key" = 'the' WHERE "key" = 'Thé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'biere_cuisine' WHERE "key" = 'Bière (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cidre_cuisine' WHERE "key" = 'Cidre (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'champagne_vin_petillant_cuisine' WHERE "key" = 'Champagne / vin pétillant (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'porto_cuisine' WHERE "key" = 'Porto (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vin_jaune_cuisine' WHERE "key" = 'Vin jaune (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cognac' WHERE "key" = 'Cognac';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rhum' WHERE "key" = 'Rhum';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whisky' WHERE "key" = 'Whisky';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vodka' WHERE "key" = 'Vodka';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_ble' WHERE "key" = 'Farine de blé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_complete' WHERE "key" = 'Farine complète';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_mais' WHERE "key" = 'Farine de maïs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_sarrasin' WHERE "key" = 'Farine de sarrasin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_riz' WHERE "key" = 'Farine de riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_cube_legumes' WHERE "key" = 'Bouillon cube légumes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_cube_volaille' WHERE "key" = 'Bouillon cube volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'concentre_de_tomate' WHERE "key" = 'Concentré de tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coulis_de_tomate' WHERE "key" = 'Coulis de tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomates_pelees_conserve' WHERE "key" = 'Tomates pelées (conserve)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomates_sechees' WHERE "key" = 'Tomates séchées';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fond_de_veau' WHERE "key" = 'Fond de veau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fond_de_volaille' WHERE "key" = 'Fond de volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_cube_boeuf' WHERE "key" = 'Bouillon cube bœuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_cube_poisson' WHERE "key" = 'Bouillon cube poisson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_de_legumes' WHERE "key" = 'Bouillon de légumes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_de_volaille' WHERE "key" = 'Bouillon de volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_de_boeuf' WHERE "key" = 'Bouillon de bœuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'court_bouillon' WHERE "key" = 'Court-bouillon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dashi_bouillon_japonais' WHERE "key" = 'Dashi (bouillon japonais)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bisque_de_crustaces' WHERE "key" = 'Bisque de crustacés';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_tapioca' WHERE "key" = 'Farine de tapioca';
|
|
||||||
UPDATE "ingredients" SET "key" = 'masa_harina' WHERE "key" = 'Masa harina';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eau' WHERE "key" = 'Eau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eau_gazeuse' WHERE "key" = 'Eau gazeuse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eau_de_fleur_d_oranger' WHERE "key" = 'Eau de fleur d''oranger';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eau_de_rose' WHERE "key" = 'Eau de rose';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fumet_de_poisson' WHERE "key" = 'Fumet de poisson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'levure_boulangere' WHERE "key" = 'Levure boulangère';
|
|
||||||
UPDATE "ingredients" SET "key" = 'levure_chimique' WHERE "key" = 'Levure chimique';
|
|
||||||
UPDATE "ingredients" SET "key" = 'maizena' WHERE "key" = 'Maïzena';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_lupin' WHERE "key" = 'Farine de lupin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gelatine' WHERE "key" = 'Gélatine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bicarbonate_de_soude' WHERE "key" = 'Bicarbonate de soude';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fecule_de_pomme_de_terre' WHERE "key" = 'Fécule de pomme de terre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sucre' WHERE "key" = 'Sucre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'miel' WHERE "key" = 'Miel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sirop_d_erable' WHERE "key" = 'Sirop d''érable';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sucre_roux' WHERE "key" = 'Sucre roux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sucre_glace' WHERE "key" = 'Sucre glace';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cassonade' WHERE "key" = 'Cassonade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chocolat_noir' WHERE "key" = 'Chocolat noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chocolat_au_lait' WHERE "key" = 'Chocolat au lait';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chocolat_blanc' WHERE "key" = 'Chocolat blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pepites_de_chocolat' WHERE "key" = 'Pépites de chocolat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cacao_en_poudre' WHERE "key" = 'Cacao en poudre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'extrait_de_vanille' WHERE "key" = 'Extrait de vanille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sucre_de_palme' WHERE "key" = 'Sucre de palme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sirop_de_sucre_de_canne' WHERE "key" = 'Sirop de sucre de canne';
|
|
||||||
|
|
@ -1,465 +0,0 @@
|
||||||
-- Auto-generated once by scripts/gen-english-remap-sql.ts — do not re-run,
|
|
||||||
-- do not hand-edit. Remaps every Diet/Category(allergen)/Ingredient row's
|
|
||||||
-- "key" from the French slug the previous migration
|
|
||||||
-- (20260818_catalog_labels_to_keys) produced to the hand-assigned English
|
|
||||||
-- key in catalog-en-keys.ts.
|
|
||||||
|
|
||||||
UPDATE "diet" SET "key" = 'omnivore' WHERE "key" = 'omnivore';
|
|
||||||
UPDATE "diet" SET "key" = 'vegetarian' WHERE "key" = 'vegetarien';
|
|
||||||
UPDATE "diet" SET "key" = 'vegan' WHERE "key" = 'vegan';
|
|
||||||
UPDATE "diet" SET "key" = 'pescatarian' WHERE "key" = 'pescetarien';
|
|
||||||
UPDATE "diet" SET "key" = 'gluten_free' WHERE "key" = 'sans_gluten';
|
|
||||||
|
|
||||||
UPDATE "category" SET "key" = 'gluten' WHERE "key" = 'gluten';
|
|
||||||
UPDATE "category" SET "key" = 'crustaceans' WHERE "key" = 'crustaces';
|
|
||||||
UPDATE "category" SET "key" = 'eggs' WHERE "key" = 'oeufs';
|
|
||||||
UPDATE "category" SET "key" = 'fish' WHERE "key" = 'poissons';
|
|
||||||
UPDATE "category" SET "key" = 'peanuts' WHERE "key" = 'arachides';
|
|
||||||
UPDATE "category" SET "key" = 'soy' WHERE "key" = 'soja';
|
|
||||||
UPDATE "category" SET "key" = 'milk' WHERE "key" = 'lait';
|
|
||||||
UPDATE "category" SET "key" = 'tree_nuts' WHERE "key" = 'fruits_a_coque';
|
|
||||||
UPDATE "category" SET "key" = 'celery' WHERE "key" = 'celeri';
|
|
||||||
UPDATE "category" SET "key" = 'mustard' WHERE "key" = 'moutarde';
|
|
||||||
UPDATE "category" SET "key" = 'sesame_seeds' WHERE "key" = 'graines_de_sesame';
|
|
||||||
UPDATE "category" SET "key" = 'sulfites' WHERE "key" = 'sulfites';
|
|
||||||
UPDATE "category" SET "key" = 'lupin' WHERE "key" = 'lupin';
|
|
||||||
UPDATE "category" SET "key" = 'molluscs' WHERE "key" = 'mollusques';
|
|
||||||
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomato' WHERE "key" = 'tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'onion' WHERE "key" = 'oignon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shallot' WHERE "key" = 'echalote';
|
|
||||||
UPDATE "ingredients" SET "key" = 'garlic' WHERE "key" = 'ail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'carrot' WHERE "key" = 'carotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'zucchini' WHERE "key" = 'courgette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cucumber' WHERE "key" = 'concombre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gherkins' WHERE "key" = 'cornichons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bell_pepper' WHERE "key" = 'poivron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mushroom' WHERE "key" = 'champignon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'porcini' WHERE "key" = 'cepes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eggplant' WHERE "key" = 'aubergine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'broccoli' WHERE "key" = 'brocoli';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cauliflower' WHERE "key" = 'chou_fleur';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_cabbage' WHERE "key" = 'chou_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_cabbage' WHERE "key" = 'chou_rouge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brussels_sprouts' WHERE "key" = 'chou_de_bruxelles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spinach' WHERE "key" = 'epinard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'swiss_chard' WHERE "key" = 'blette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lettuce' WHERE "key" = 'salade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'arugula' WHERE "key" = 'roquette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'watercress' WHERE "key" = 'cresson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'leek' WHERE "key" = 'poireau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'celery' WHERE "key" = 'celeri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'radish' WHERE "key" = 'radis';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beetroot' WHERE "key" = 'betterave';
|
|
||||||
UPDATE "ingredients" SET "key" = 'turnip' WHERE "key" = 'navet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'parsnip' WHERE "key" = 'panais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'green_bean' WHERE "key" = 'haricot_vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pea' WHERE "key" = 'petit_pois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'corn' WHERE "key" = 'mais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'artichoke' WHERE "key" = 'artichaut';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fennel' WHERE "key" = 'fenouil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'endive' WHERE "key" = 'endive';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pumpkin' WHERE "key" = 'potiron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'butternut_squash' WHERE "key" = 'butternut';
|
|
||||||
UPDATE "ingredients" SET "key" = 'asparagus' WHERE "key" = 'asperge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'avocado' WHERE "key" = 'avocat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'potato' WHERE "key" = 'pomme_de_terre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweet_potato' WHERE "key" = 'patate_douce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cherry_tomato' WHERE "key" = 'tomates_cerises';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bok_choy' WHERE "key" = 'pak_choi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soybean_sprouts' WHERE "key" = 'germes_de_soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shiitake' WHERE "key" = 'shiitake';
|
|
||||||
UPDATE "ingredients" SET "key" = 'daikon' WHERE "key" = 'daikon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fresh_green_chili' WHERE "key" = 'piment_vert_frais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lemon' WHERE "key" = 'citron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lime' WHERE "key" = 'citron_vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'apple' WHERE "key" = 'pomme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pear' WHERE "key" = 'poire';
|
|
||||||
UPDATE "ingredients" SET "key" = 'banana' WHERE "key" = 'banane';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orange' WHERE "key" = 'orange';
|
|
||||||
UPDATE "ingredients" SET "key" = 'clementine' WHERE "key" = 'clementine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grapefruit' WHERE "key" = 'pamplemousse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'strawberry' WHERE "key" = 'fraise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raspberry' WHERE "key" = 'framboise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blueberry' WHERE "key" = 'myrtille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackberry' WHERE "key" = 'mure';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cherry' WHERE "key" = 'cerise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'apricot' WHERE "key" = 'abricot';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peach' WHERE "key" = 'peche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'plum' WHERE "key" = 'prune';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grape' WHERE "key" = 'raisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'melon' WHERE "key" = 'melon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'watermelon' WHERE "key" = 'pasteque';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pineapple' WHERE "key" = 'ananas';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mango' WHERE "key" = 'mangue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kiwi' WHERE "key" = 'kiwi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fig' WHERE "key" = 'figue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'date' WHERE "key" = 'datte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lychee' WHERE "key" = 'litchi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pomegranate' WHERE "key" = 'grenade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rhubarb' WHERE "key" = 'rhubarbe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'quince' WHERE "key" = 'coing';
|
|
||||||
UPDATE "ingredients" SET "key" = 'basil' WHERE "key" = 'basilic';
|
|
||||||
UPDATE "ingredients" SET "key" = 'parsley' WHERE "key" = 'persil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'thyme' WHERE "key" = 'thym';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rosemary' WHERE "key" = 'romarin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bay_leaf' WHERE "key" = 'laurier';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chives' WHERE "key" = 'ciboulette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fresh_cilantro' WHERE "key" = 'coriandre_fraiche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mint' WHERE "key" = 'menthe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oregano' WHERE "key" = 'origan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dill' WHERE "key" = 'aneth';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tarragon' WHERE "key" = 'estragon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'savory' WHERE "key" = 'sarriette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'marjoram' WHERE "key" = 'marjolaine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sage' WHERE "key" = 'sauge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chervil' WHERE "key" = 'cerfeuil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ginger' WHERE "key" = 'gingembre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lemongrass' WHERE "key" = 'citronnelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kaffir_lime' WHERE "key" = 'combava';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rabbit' WHERE "key" = 'lapin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ground_beef' WHERE "key" = 'boeuf_hache';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beef_steak' WHERE "key" = 'steak_de_boeuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beef_roast' WHERE "key" = 'roti_de_boeuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'veal_cutlet' WHERE "key" = 'escalope_de_veau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pork_tenderloin' WHERE "key" = 'filet_mignon_de_porc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pork_chop' WHERE "key" = 'cote_de_porc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lamb' WHERE "key" = 'agneau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'leg_of_lamb' WHERE "key" = 'gigot_d_agneau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bacon_lardons' WHERE "key" = 'lardons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bacon' WHERE "key" = 'bacon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ham' WHERE "key" = 'jambon_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cured_ham' WHERE "key" = 'jambon_cru';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sausage' WHERE "key" = 'saucisse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chorizo' WHERE "key" = 'chorizo';
|
|
||||||
UPDATE "ingredients" SET "key" = 'merguez' WHERE "key" = 'merguez';
|
|
||||||
UPDATE "ingredients" SET "key" = 'prosciutto' WHERE "key" = 'prosciutto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pancetta' WHERE "key" = 'pancetta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mortadella' WHERE "key" = 'mortadelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'salami' WHERE "key" = 'salami';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chicken' WHERE "key" = 'poulet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'turkey' WHERE "key" = 'dinde';
|
|
||||||
UPDATE "ingredients" SET "key" = 'duck' WHERE "key" = 'canard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'duck_breast' WHERE "key" = 'magret_de_canard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'salmon' WHERE "key" = 'saumon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tuna' WHERE "key" = 'thon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cod' WHERE "key" = 'cabillaud';
|
|
||||||
UPDATE "ingredients" SET "key" = 'trout' WHERE "key" = 'truite';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sardine' WHERE "key" = 'sardine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'anchovy' WHERE "key" = 'anchois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiting' WHERE "key" = 'merlan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'surimi' WHERE "key" = 'surimi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sea_bass' WHERE "key" = 'bar_loup_de_mer';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sea_bream' WHERE "key" = 'dorade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sole' WHERE "key" = 'sole';
|
|
||||||
UPDATE "ingredients" SET "key" = 'turbot' WHERE "key" = 'turbot';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hake' WHERE "key" = 'merlu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pollock' WHERE "key" = 'colin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saithe' WHERE "key" = 'lieu_noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haddock' WHERE "key" = 'eglefin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mackerel' WHERE "key" = 'maquereau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herring' WHERE "key" = 'hareng';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_mullet' WHERE "key" = 'rouget';
|
|
||||||
UPDATE "ingredients" SET "key" = 'skate' WHERE "key" = 'raie';
|
|
||||||
UPDATE "ingredients" SET "key" = 'monkfish' WHERE "key" = 'lotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'halibut' WHERE "key" = 'fletan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'swordfish' WHERE "key" = 'espadon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'carp' WHERE "key" = 'carpe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pike' WHERE "key" = 'brochet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'perch' WHERE "key" = 'perche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tilapia' WHERE "key" = 'tilapia';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pangasius' WHERE "key" = 'panga';
|
|
||||||
UPDATE "ingredients" SET "key" = 'smoked_salmon' WHERE "key" = 'saumon_fume';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dried_fish' WHERE "key" = 'poisson_seche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shrimp' WHERE "key" = 'crevettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'langoustine' WHERE "key" = 'langoustines';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lobster' WHERE "key" = 'homard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'crab' WHERE "key" = 'crabe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spiny_lobster' WHERE "key" = 'langouste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mussels' WHERE "key" = 'moules';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oysters' WHERE "key" = 'huitres';
|
|
||||||
UPDATE "ingredients" SET "key" = 'scallops' WHERE "key" = 'saint_jacques';
|
|
||||||
UPDATE "ingredients" SET "key" = 'squid' WHERE "key" = 'calamar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'octopus' WHERE "key" = 'poulpe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'clams' WHERE "key" = 'palourdes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whelks' WHERE "key" = 'bulots';
|
|
||||||
UPDATE "ingredients" SET "key" = 'semolina' WHERE "key" = 'semoule';
|
|
||||||
UPDATE "ingredients" SET "key" = 'couscous' WHERE "key" = 'couscous';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bulgur' WHERE "key" = 'boulgour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'polenta' WHERE "key" = 'polenta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'quinoa' WHERE "key" = 'quinoa';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pasta' WHERE "key" = 'pates';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whole_wheat_pasta' WHERE "key" = 'pates_completes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rice' WHERE "key" = 'riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'basmati_rice' WHERE "key" = 'riz_basmati';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brown_rice' WHERE "key" = 'riz_complet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oats' WHERE "key" = 'flocons_d_avoine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spaghetti' WHERE "key" = 'spaghetti';
|
|
||||||
UPDATE "ingredients" SET "key" = 'penne' WHERE "key" = 'penne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tagliatelle' WHERE "key" = 'tagliatelles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lasagna_sheets' WHERE "key" = 'lasagnes_feuilles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gnocchi' WHERE "key" = 'gnocchi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'arborio_rice' WHERE "key" = 'riz_arborio';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rice_noodles' WHERE "key" = 'nouilles_de_riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'udon_noodles' WHERE "key" = 'nouilles_udon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soba_noodles' WHERE "key" = 'nouilles_soba';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chinese_noodles' WHERE "key" = 'nouilles_chinoises';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rice_vermicelli' WHERE "key" = 'vermicelles_de_riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soy_vermicelli' WHERE "key" = 'vermicelles_de_soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sticky_rice' WHERE "key" = 'riz_gluant';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sushi_rice' WHERE "key" = 'riz_a_sushi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jasmine_rice' WHERE "key" = 'riz_jasmin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'green_lentils' WHERE "key" = 'lentilles_vertes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_lentils' WHERE "key" = 'lentilles_corail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chickpeas' WHERE "key" = 'pois_chiches';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_beans' WHERE "key" = 'haricots_blancs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kidney_beans' WHERE "key" = 'haricots_rouges';
|
|
||||||
UPDATE "ingredients" SET "key" = 'black_beans' WHERE "key" = 'haricots_noirs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'split_peas' WHERE "key" = 'pois_casses';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fava_beans' WHERE "key" = 'feves';
|
|
||||||
UPDATE "ingredients" SET "key" = 'edamame' WHERE "key" = 'edamame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pinto_beans' WHERE "key" = 'haricots_pinto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanuts_shelled' WHERE "key" = 'cacahuetes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'almonds' WHERE "key" = 'amandes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'walnuts' WHERE "key" = 'noix';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hazelnuts' WHERE "key" = 'noisettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cashews' WHERE "key" = 'noix_de_cajou';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pistachios' WHERE "key" = 'pistaches';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pecans' WHERE "key" = 'noix_de_pecan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'almond_powder' WHERE "key" = 'poudre_d_amande';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pine_nuts' WHERE "key" = 'pignons_de_pin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sunflower_seeds' WHERE "key" = 'graines_de_tournesol';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pumpkin_seeds' WHERE "key" = 'graines_de_courge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shredded_coconut' WHERE "key" = 'noix_de_coco_rapee';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raisins' WHERE "key" = 'raisins_secs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'prunes' WHERE "key" = 'pruneaux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dried_apricots' WHERE "key" = 'abricots_secs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sesame_seeds' WHERE "key" = 'graines_de_sesame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'black_mushrooms' WHERE "key" = 'champignons_noirs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nori_seaweed' WHERE "key" = 'algue_nori';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wakame_seaweed' WHERE "key" = 'algue_wakame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kombu_seaweed' WHERE "key" = 'algue_kombu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bamboo_shoots' WHERE "key" = 'pousses_de_bambou';
|
|
||||||
UPDATE "ingredients" SET "key" = 'water_chestnuts' WHERE "key" = 'chataignes_d_eau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bread' WHERE "key" = 'pain';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sandwich_bread' WHERE "key" = 'pain_de_mie';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whole_wheat_bread' WHERE "key" = 'pain_complet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baguette' WHERE "key" = 'baguette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rye_bread' WHERE "key" = 'pain_de_seigle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'breadcrumbs' WHERE "key" = 'chapelure';
|
|
||||||
UPDATE "ingredients" SET "key" = 'burger_bun' WHERE "key" = 'pain_a_burger';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brioche_bun' WHERE "key" = 'pain_brioche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hot_dog_bun' WHERE "key" = 'pain_a_hot_dog';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pita_bread' WHERE "key" = 'pain_pita';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bagel' WHERE "key" = 'pain_bagel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'naan' WHERE "key" = 'naan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wrap_bread' WHERE "key" = 'pain_wrap';
|
|
||||||
UPDATE "ingredients" SET "key" = 'viennese_bread' WHERE "key" = 'pain_viennois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'country_bread' WHERE "key" = 'pain_de_campagne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'multigrain_bread' WHERE "key" = 'pain_aux_cereales';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bread_roll' WHERE "key" = 'petit_pain';
|
|
||||||
UPDATE "ingredients" SET "key" = 'swedish_bread' WHERE "key" = 'pain_suedois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gluten_free_bread' WHERE "key" = 'pain_sans_gluten';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rusk' WHERE "key" = 'biscotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'croutons' WHERE "key" = 'croutons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'focaccia' WHERE "key" = 'focaccia';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ciabatta' WHERE "key" = 'ciabatta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'corn_tortilla' WHERE "key" = 'tortilla_de_mais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wheat_tortilla' WHERE "key" = 'tortilla_de_ble';
|
|
||||||
UPDATE "ingredients" SET "key" = 'puff_pastry' WHERE "key" = 'pate_feuilletee';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shortcrust_pastry' WHERE "key" = 'pate_brisee';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pizza_dough' WHERE "key" = 'pate_a_pizza';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweet_shortcrust_pastry' WHERE "key" = 'pate_a_tarte_sablee';
|
|
||||||
UPDATE "ingredients" SET "key" = 'milk' WHERE "key" = 'lait';
|
|
||||||
UPDATE "ingredients" SET "key" = 'butter' WHERE "key" = 'beurre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'creme_fraiche' WHERE "key" = 'creme_fraiche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'liquid_cream' WHERE "key" = 'creme_liquide';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cheese' WHERE "key" = 'fromage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'emmental' WHERE "key" = 'emmental';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gruyere' WHERE "key" = 'gruyere';
|
|
||||||
UPDATE "ingredients" SET "key" = 'parmesan' WHERE "key" = 'parmesan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mozzarella' WHERE "key" = 'mozzarella';
|
|
||||||
UPDATE "ingredients" SET "key" = 'goat_cheese' WHERE "key" = 'chevre_fromage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'feta' WHERE "key" = 'feta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'comte' WHERE "key" = 'comte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fromage_blanc' WHERE "key" = 'fromage_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mascarpone' WHERE "key" = 'mascarpone';
|
|
||||||
UPDATE "ingredients" SET "key" = 'yogurt' WHERE "key" = 'yaourt';
|
|
||||||
UPDATE "ingredients" SET "key" = 'burrata' WHERE "key" = 'burrata';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ricotta' WHERE "key" = 'ricotta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pecorino' WHERE "key" = 'pecorino';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gorgonzola' WHERE "key" = 'gorgonzola';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cheddar' WHERE "key" = 'cheddar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'egg' WHERE "key" = 'oeuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconut_milk' WHERE "key" = 'lait_de_coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconut_cream' WHERE "key" = 'creme_de_coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'almond_milk' WHERE "key" = 'lait_d_amande';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oat_milk' WHERE "key" = 'lait_d_avoine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tofu' WHERE "key" = 'tofu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'silken_tofu' WHERE "key" = 'tofu_soyeux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herbes_de_provence' WHERE "key" = 'herbes_de_provence';
|
|
||||||
UPDATE "ingredients" SET "key" = 'black_pepper' WHERE "key" = 'poivre_noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'paprika' WHERE "key" = 'paprika';
|
|
||||||
UPDATE "ingredients" SET "key" = 'espelette_pepper' WHERE "key" = 'piment_d_espelette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cayenne_pepper' WHERE "key" = 'piment_de_cayenne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cumin' WHERE "key" = 'cumin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curry_powder' WHERE "key" = 'curry_poudre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'turmeric' WHERE "key" = 'curcuma';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cinnamon' WHERE "key" = 'cannelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nutmeg' WHERE "key" = 'muscade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saffron' WHERE "key" = 'safran';
|
|
||||||
UPDATE "ingredients" SET "key" = 'clove' WHERE "key" = 'clou_de_girofle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vanilla_bean' WHERE "key" = 'vanille_gousse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_pepper' WHERE "key" = 'poivre_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pink_pepper' WHERE "key" = 'poivre_rose';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sichuan_pepper' WHERE "key" = 'poivre_du_sichuan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'smoked_paprika' WHERE "key" = 'paprika_fume';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bird_eye_chili' WHERE "key" = 'piment_oiseau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'juniper_berries' WHERE "key" = 'baies_de_genievre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'star_anise' WHERE "key" = 'anis_etoile_badiane';
|
|
||||||
UPDATE "ingredients" SET "key" = 'green_anise' WHERE "key" = 'anis_vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fennel_seeds' WHERE "key" = 'graines_de_fenouil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sumac' WHERE "key" = 'sumac';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nigella' WHERE "key" = 'nigelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'allspice' WHERE "key" = 'quatre_epices';
|
|
||||||
UPDATE "ingredients" SET "key" = 'colombo_powder' WHERE "key" = 'colombo_poudre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baharat' WHERE "key" = 'baharat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'horseradish' WHERE "key" = 'raifort';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herb_salt' WHERE "key" = 'sel_aux_herbes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'celery_salt' WHERE "key" = 'sel_de_celeri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fleur_de_sel' WHERE "key" = 'fleur_de_sel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'salt' WHERE "key" = 'sel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'five_spice' WHERE "key" = 'cinq_epices';
|
|
||||||
UPDATE "ingredients" SET "key" = 'garam_masala' WHERE "key" = 'garam_masala';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coriander_seeds' WHERE "key" = 'graines_de_coriandre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cardamom' WHERE "key" = 'cardamome';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fenugreek' WHERE "key" = 'fenugrec';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jalapeno' WHERE "key" = 'piment_jalapeno';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chipotle' WHERE "key" = 'piment_chipotle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poblano_pepper' WHERE "key" = 'piment_poblano';
|
|
||||||
UPDATE "ingredients" SET "key" = 'habanero' WHERE "key" = 'piment_habanero';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ras_el_hanout' WHERE "key" = 'ras_el_hanout';
|
|
||||||
UPDATE "ingredients" SET "key" = 'zaatar' WHERE "key" = 'za_atar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soy_sauce' WHERE "key" = 'sauce_soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mustard' WHERE "key" = 'moutarde';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mayonnaise' WHERE "key" = 'mayonnaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ketchup' WHERE "key" = 'ketchup';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tabasco' WHERE "key" = 'tabasco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'worcestershire_sauce' WHERE "key" = 'sauce_worcestershire';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fish_sauce' WHERE "key" = 'sauce_nuoc_mam';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wasabi' WHERE "key" = 'wasabi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'harissa' WHERE "key" = 'harissa';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curry_paste' WHERE "key" = 'pate_de_curry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanut_butter' WHERE "key" = 'beurre_de_cacahuete';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dijon_mustard' WHERE "key" = 'moutarde_de_dijon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wholegrain_mustard' WHERE "key" = 'moutarde_a_l_ancienne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'barbecue_sauce' WHERE "key" = 'sauce_barbecue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tartar_sauce' WHERE "key" = 'sauce_tartare';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cocktail_sauce' WHERE "key" = 'sauce_cocktail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bearnaise_sauce' WHERE "key" = 'sauce_bearnaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hollandaise_sauce' WHERE "key" = 'sauce_hollandaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bechamel_sauce' WHERE "key" = 'sauce_bechamel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'teriyaki_sauce' WHERE "key" = 'sauce_teriyaki';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ponzu_sauce' WHERE "key" = 'sauce_ponzu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chimichurri' WHERE "key" = 'chimichurri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_pesto' WHERE "key" = 'pesto_rouge_tomates_sechees';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pesto' WHERE "key" = 'pesto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oyster_sauce' WHERE "key" = 'sauce_huitre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hoisin_sauce' WHERE "key" = 'sauce_hoisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sriracha' WHERE "key" = 'sauce_sriracha';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweet_chili_sauce' WHERE "key" = 'sauce_sweet_chili';
|
|
||||||
UPDATE "ingredients" SET "key" = 'miso' WHERE "key" = 'miso';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shrimp_paste' WHERE "key" = 'pate_de_crevettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_curry_paste' WHERE "key" = 'pate_de_curry_rouge_thai';
|
|
||||||
UPDATE "ingredients" SET "key" = 'green_curry_paste' WHERE "key" = 'pate_de_curry_vert_thai';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tahini' WHERE "key" = 'tahini';
|
|
||||||
UPDATE "ingredients" SET "key" = 'olive_oil' WHERE "key" = 'huile_d_olive';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sunflower_oil' WHERE "key" = 'huile_de_tournesol';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rapeseed_oil' WHERE "key" = 'huile_de_colza';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconut_oil' WHERE "key" = 'huile_de_coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sesame_oil' WHERE "key" = 'huile_de_sesame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cider_vinegar' WHERE "key" = 'vinaigre_de_cidre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_vinegar' WHERE "key" = 'vinaigre_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'balsamic_vinegar' WHERE "key" = 'vinaigre_balsamique';
|
|
||||||
UPDATE "ingredients" SET "key" = 'capers' WHERE "key" = 'capres';
|
|
||||||
UPDATE "ingredients" SET "key" = 'olives' WHERE "key" = 'olives';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_wine' WHERE "key" = 'vin_blanc_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_wine' WHERE "key" = 'vin_rouge_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_wine_vinegar' WHERE "key" = 'vinaigre_de_vin_rouge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_wine_vinegar' WHERE "key" = 'vinaigre_de_vin_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sherry_vinegar' WHERE "key" = 'vinaigre_de_xeres';
|
|
||||||
UPDATE "ingredients" SET "key" = 'walnut_oil' WHERE "key" = 'huile_de_noix';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hazelnut_oil' WHERE "key" = 'huile_de_noisette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanut_oil' WHERE "key" = 'huile_d_arachide';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chili_oil' WHERE "key" = 'huile_pimentee';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rice_vinegar' WHERE "key" = 'vinaigre_de_riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mirin' WHERE "key" = 'mirin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sake' WHERE "key" = 'sake_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lemon_juice' WHERE "key" = 'jus_de_citron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lime_juice' WHERE "key" = 'jus_de_citron_vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orange_juice' WHERE "key" = 'jus_d_orange';
|
|
||||||
UPDATE "ingredients" SET "key" = 'apple_juice' WHERE "key" = 'jus_de_pomme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grape_juice' WHERE "key" = 'jus_de_raisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomato_juice' WHERE "key" = 'jus_de_tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cranberry_juice' WHERE "key" = 'jus_de_cranberry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coffee' WHERE "key" = 'cafe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tea' WHERE "key" = 'the';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beer' WHERE "key" = 'biere_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cider' WHERE "key" = 'cidre_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'champagne' WHERE "key" = 'champagne_vin_petillant_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'port_wine' WHERE "key" = 'porto_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vin_jaune' WHERE "key" = 'vin_jaune_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cognac' WHERE "key" = 'cognac';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rum' WHERE "key" = 'rhum';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whisky' WHERE "key" = 'whisky';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vodka' WHERE "key" = 'vodka';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wheat_flour' WHERE "key" = 'farine_de_ble';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whole_wheat_flour' WHERE "key" = 'farine_complete';
|
|
||||||
UPDATE "ingredients" SET "key" = 'corn_flour' WHERE "key" = 'farine_de_mais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'buckwheat_flour' WHERE "key" = 'farine_de_sarrasin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rice_flour' WHERE "key" = 'farine_de_riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vegetable_stock_cube' WHERE "key" = 'bouillon_cube_legumes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chicken_stock_cube' WHERE "key" = 'bouillon_cube_volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomato_paste' WHERE "key" = 'concentre_de_tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomato_coulis' WHERE "key" = 'coulis_de_tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'canned_peeled_tomatoes' WHERE "key" = 'tomates_pelees_conserve';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sun_dried_tomatoes' WHERE "key" = 'tomates_sechees';
|
|
||||||
UPDATE "ingredients" SET "key" = 'veal_stock' WHERE "key" = 'fond_de_veau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chicken_stock' WHERE "key" = 'fond_de_volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beef_stock_cube' WHERE "key" = 'bouillon_cube_boeuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fish_stock_cube' WHERE "key" = 'bouillon_cube_poisson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vegetable_broth' WHERE "key" = 'bouillon_de_legumes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chicken_broth' WHERE "key" = 'bouillon_de_volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beef_broth' WHERE "key" = 'bouillon_de_boeuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'court_bouillon' WHERE "key" = 'court_bouillon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dashi' WHERE "key" = 'dashi_bouillon_japonais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shellfish_bisque' WHERE "key" = 'bisque_de_crustaces';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tapioca_flour' WHERE "key" = 'farine_de_tapioca';
|
|
||||||
UPDATE "ingredients" SET "key" = 'masa_harina' WHERE "key" = 'masa_harina';
|
|
||||||
UPDATE "ingredients" SET "key" = 'water' WHERE "key" = 'eau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sparkling_water' WHERE "key" = 'eau_gazeuse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orange_blossom_water' WHERE "key" = 'eau_de_fleur_d_oranger';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rose_water' WHERE "key" = 'eau_de_rose';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fish_fumet' WHERE "key" = 'fumet_de_poisson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bakers_yeast' WHERE "key" = 'levure_boulangere';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baking_powder' WHERE "key" = 'levure_chimique';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cornstarch' WHERE "key" = 'maizena';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lupin_flour' WHERE "key" = 'farine_de_lupin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gelatin' WHERE "key" = 'gelatine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baking_soda' WHERE "key" = 'bicarbonate_de_soude';
|
|
||||||
UPDATE "ingredients" SET "key" = 'potato_starch' WHERE "key" = 'fecule_de_pomme_de_terre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sugar' WHERE "key" = 'sucre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'honey' WHERE "key" = 'miel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'maple_syrup' WHERE "key" = 'sirop_d_erable';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brown_sugar' WHERE "key" = 'sucre_roux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'powdered_sugar' WHERE "key" = 'sucre_glace';
|
|
||||||
UPDATE "ingredients" SET "key" = 'demerara_sugar' WHERE "key" = 'cassonade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dark_chocolate' WHERE "key" = 'chocolat_noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'milk_chocolate' WHERE "key" = 'chocolat_au_lait';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_chocolate' WHERE "key" = 'chocolat_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chocolate_chips' WHERE "key" = 'pepites_de_chocolat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cocoa_powder' WHERE "key" = 'cacao_en_poudre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vanilla_extract' WHERE "key" = 'extrait_de_vanille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'palm_sugar' WHERE "key" = 'sucre_de_palme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cane_syrup' WHERE "key" = 'sirop_de_sucre_de_canne';
|
|
||||||
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- Adds the number of portions to prepare for a planning slot
|
|
||||||
-- (`PlanningItem.portions`), entered manually by whoever assigns the
|
|
||||||
-- recipe — no default exists anywhere to derive it from (no such concept
|
|
||||||
-- on `Recipe` either). Backfills any pre-existing row with 1 portion via a
|
|
||||||
-- transient DEFAULT, then drops that default so it isn't implicitly
|
|
||||||
-- reused for new inserts going forward (the app always sends an explicit
|
|
||||||
-- value, see `addPlanningItemSchema`).
|
|
||||||
ALTER TABLE "planning_item" ADD COLUMN "portions" INTEGER NOT NULL DEFAULT 1;
|
|
||||||
ALTER TABLE "planning_item" ALTER COLUMN "portions" DROP DEFAULT;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- Replaces the never-wired-up `Ingredient.alternateRecipeId` FK (zero
|
|
||||||
-- usage anywhere outside schema.prisma — confirmed by repo-wide grep)
|
|
||||||
-- with a plain boolean flag: whether this ingredient is reasonably
|
|
||||||
-- makeable at home. Product decision: no ingredient↔recipe linking in
|
|
||||||
-- the database — the recipe form only nudges the author toward the
|
|
||||||
-- recipe catalog's own search, pre-filled with the ingredient's name.
|
|
||||||
ALTER TABLE "ingredients" DROP CONSTRAINT "ingredients_alternate_recipe_fkey";
|
|
||||||
ALTER TABLE "ingredients" DROP COLUMN "alternate_recipe";
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "reproducible" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
@ -1,294 +0,0 @@
|
||||||
-- Replaces every Diet/Category(allergen)/Ingredient `key` with a
|
|
||||||
-- directly-authored English camelCase uid (no more French label +
|
|
||||||
-- separate catalog-en-keys.ts lookup table — see reference-seed-data.ts's
|
|
||||||
-- module doc comment). Auto-generated once by
|
|
||||||
-- scripts/gen-camel-uid-migration.ts — do not re-run, do not hand-edit.
|
|
||||||
-- Same shape as 20260818193000_catalog_keys_to_english/migration.sql.
|
|
||||||
|
|
||||||
UPDATE "diet" SET "key" = 'glutenFree' WHERE "key" = 'gluten_free';
|
|
||||||
UPDATE "category" SET "key" = 'treeNuts' WHERE "key" = 'tree_nuts';
|
|
||||||
UPDATE "category" SET "key" = 'sesameSeeds' WHERE "key" = 'sesame_seeds';
|
|
||||||
-- "sesame_seeds" is also an Ingredient key (the ingredient "Graines de
|
|
||||||
-- sésame" carries an allergen of the same name on itself) — hand-added,
|
|
||||||
-- the generator's first pass missed this dual-table case (see its
|
|
||||||
-- updated `if`/`if`/`if` — not `if`/`else if` — comment for why).
|
|
||||||
UPDATE "ingredients" SET "key" = 'sesameSeeds' WHERE "key" = 'sesame_seeds';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bellPepper' WHERE "key" = 'bell_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteCabbage' WHERE "key" = 'white_cabbage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redCabbage' WHERE "key" = 'red_cabbage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brusselsSprouts' WHERE "key" = 'brussels_sprouts';
|
|
||||||
UPDATE "ingredients" SET "key" = 'swissChard' WHERE "key" = 'swiss_chard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greenBean' WHERE "key" = 'green_bean';
|
|
||||||
UPDATE "ingredients" SET "key" = 'butternutSquash' WHERE "key" = 'butternut_squash';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweetPotato' WHERE "key" = 'sweet_potato';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cherryTomato' WHERE "key" = 'cherry_tomato';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bokChoy' WHERE "key" = 'bok_choy';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soybeanSprouts' WHERE "key" = 'soybean_sprouts';
|
|
||||||
UPDATE "ingredients" SET "key" = 'freshGreenChili' WHERE "key" = 'fresh_green_chili';
|
|
||||||
UPDATE "ingredients" SET "key" = 'napaCabbage' WHERE "key" = 'napa_cabbage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'springOnion' WHERE "key" = 'spring_onion';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redKuriSquash' WHERE "key" = 'red_kuri_squash';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lambsLettuce' WHERE "key" = 'lambs_lettuce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bayLeaf' WHERE "key" = 'bay_leaf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'freshCilantro' WHERE "key" = 'fresh_cilantro';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kaffirLime' WHERE "key" = 'kaffir_lime';
|
|
||||||
UPDATE "ingredients" SET "key" = 'groundBeef' WHERE "key" = 'ground_beef';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefSteak' WHERE "key" = 'beef_steak';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefRoast' WHERE "key" = 'beef_roast';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealCutlet' WHERE "key" = 'veal_cutlet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'porkTenderloin' WHERE "key" = 'pork_tenderloin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'porkChop' WHERE "key" = 'pork_chop';
|
|
||||||
UPDATE "ingredients" SET "key" = 'legOfLamb' WHERE "key" = 'leg_of_lamb';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baconLardons' WHERE "key" = 'bacon_lardons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curedHam' WHERE "key" = 'cured_ham';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whitePudding' WHERE "key" = 'white_pudding';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackPudding' WHERE "key" = 'black_pudding';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dryCuredSausage' WHERE "key" = 'dry_cured_sausage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bayonneHam' WHERE "key" = 'bayonne_ham';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rosetteSausage' WHERE "key" = 'rosette_sausage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealLiver' WHERE "key" = 'veal_liver';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealKidneys' WHERE "key" = 'veal_kidneys';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealBrain' WHERE "key" = 'veal_brain';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealSweetbread' WHERE "key" = 'veal_sweetbread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefTongue' WHERE "key" = 'beef_tongue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'roeDeer' WHERE "key" = 'roe_deer';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wildBoar' WHERE "key" = 'wild_boar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'horseMeat' WHERE "key" = 'horse_meat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefHeart' WHERE "key" = 'beef_heart';
|
|
||||||
UPDATE "ingredients" SET "key" = 'foieGras' WHERE "key" = 'foie_gras';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefMuzzle' WHERE "key" = 'beef_muzzle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grisonsDriedBeef' WHERE "key" = 'grisons_dried_beef';
|
|
||||||
UPDATE "ingredients" SET "key" = 'duckBreast' WHERE "key" = 'duck_breast';
|
|
||||||
UPDATE "ingredients" SET "key" = 'guineaFowl' WHERE "key" = 'guinea_fowl';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poultryLiver' WHERE "key" = 'poultry_liver';
|
|
||||||
UPDATE "ingredients" SET "key" = 'seaBass' WHERE "key" = 'sea_bass';
|
|
||||||
UPDATE "ingredients" SET "key" = 'seaBream' WHERE "key" = 'sea_bream';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redMullet' WHERE "key" = 'red_mullet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'smokedSalmon' WHERE "key" = 'smoked_salmon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'driedFish' WHERE "key" = 'dried_fish';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saltCod' WHERE "key" = 'salt_cod';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lemonSole' WHERE "key" = 'lemon_sole';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spinyLobster' WHERE "key" = 'spiny_lobster';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spiderCrab' WHERE "key" = 'spider_crab';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greyShrimp' WHERE "key" = 'grey_shrimp';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wholeWheatPasta' WHERE "key" = 'whole_wheat_pasta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'basmatiRice' WHERE "key" = 'basmati_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brownRice' WHERE "key" = 'brown_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lasagnaSheets' WHERE "key" = 'lasagna_sheets';
|
|
||||||
UPDATE "ingredients" SET "key" = 'arborioRice' WHERE "key" = 'arborio_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riceNoodles' WHERE "key" = 'rice_noodles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'udonNoodles' WHERE "key" = 'udon_noodles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sobaNoodles' WHERE "key" = 'soba_noodles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chineseNoodles' WHERE "key" = 'chinese_noodles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riceVermicelli' WHERE "key" = 'rice_vermicelli';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soyVermicelli' WHERE "key" = 'soy_vermicelli';
|
|
||||||
UPDATE "ingredients" SET "key" = 'stickyRice' WHERE "key" = 'sticky_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sushiRice' WHERE "key" = 'sushi_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jasmineRice' WHERE "key" = 'jasmine_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greenLentils' WHERE "key" = 'green_lentils';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redLentils' WHERE "key" = 'red_lentils';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteBeans' WHERE "key" = 'white_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kidneyBeans' WHERE "key" = 'kidney_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackBeans' WHERE "key" = 'black_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'splitPeas' WHERE "key" = 'split_peas';
|
|
||||||
UPDATE "ingredients" SET "key" = 'favaBeans' WHERE "key" = 'fava_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pintoBeans' WHERE "key" = 'pinto_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'flageoletBeans' WHERE "key" = 'flageolet_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'goldenLentils' WHERE "key" = 'golden_lentils';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanutsShelled' WHERE "key" = 'peanuts_shelled';
|
|
||||||
UPDATE "ingredients" SET "key" = 'almondPowder' WHERE "key" = 'almond_powder';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pineNuts' WHERE "key" = 'pine_nuts';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sunflowerSeeds' WHERE "key" = 'sunflower_seeds';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pumpkinSeeds' WHERE "key" = 'pumpkin_seeds';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shreddedCoconut' WHERE "key" = 'shredded_coconut';
|
|
||||||
UPDATE "ingredients" SET "key" = 'driedApricots' WHERE "key" = 'dried_apricots';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackMushrooms' WHERE "key" = 'black_mushrooms';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noriSeaweed' WHERE "key" = 'nori_seaweed';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wakameSeaweed' WHERE "key" = 'wakame_seaweed';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kombuSeaweed' WHERE "key" = 'kombu_seaweed';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bambooShoots' WHERE "key" = 'bamboo_shoots';
|
|
||||||
UPDATE "ingredients" SET "key" = 'waterChestnuts' WHERE "key" = 'water_chestnuts';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sandwichBread' WHERE "key" = 'sandwich_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wholeWheatBread' WHERE "key" = 'whole_wheat_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ryeBread' WHERE "key" = 'rye_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'burgerBun' WHERE "key" = 'burger_bun';
|
|
||||||
UPDATE "ingredients" SET "key" = 'briocheBun' WHERE "key" = 'brioche_bun';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hotDogBun' WHERE "key" = 'hot_dog_bun';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pitaBread' WHERE "key" = 'pita_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wrapBread' WHERE "key" = 'wrap_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vienneseBread' WHERE "key" = 'viennese_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'countryBread' WHERE "key" = 'country_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'multigrainBread' WHERE "key" = 'multigrain_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'breadRoll' WHERE "key" = 'bread_roll';
|
|
||||||
UPDATE "ingredients" SET "key" = 'swedishBread' WHERE "key" = 'swedish_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'glutenFreeBread' WHERE "key" = 'gluten_free_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cornTortilla' WHERE "key" = 'corn_tortilla';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wheatTortilla' WHERE "key" = 'wheat_tortilla';
|
|
||||||
UPDATE "ingredients" SET "key" = 'puffPastry' WHERE "key" = 'puff_pastry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shortcrustPastry' WHERE "key" = 'shortcrust_pastry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pizzaDough' WHERE "key" = 'pizza_dough';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweetShortcrustPastry' WHERE "key" = 'sweet_shortcrust_pastry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cremeFraiche' WHERE "key" = 'creme_fraiche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'liquidCream' WHERE "key" = 'liquid_cream';
|
|
||||||
UPDATE "ingredients" SET "key" = 'goatCheese' WHERE "key" = 'goat_cheese';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fromageBlanc' WHERE "key" = 'fromage_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saintNectaire' WHERE "key" = 'saint_nectaire';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blueCheese' WHERE "key" = 'blue_cheese';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pontLeveque' WHERE "key" = 'pont_leveque';
|
|
||||||
UPDATE "ingredients" SET "key" = 'racletteCheese' WHERE "key" = 'raclette_cheese';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fourmeDAmbert' WHERE "key" = 'fourme_d_ambert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ossauIraty' WHERE "key" = 'ossau_iraty';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saintMarcellin' WHERE "key" = 'saint_marcellin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'crottinDeChavignol' WHERE "key" = 'crottin_de_chavignol';
|
|
||||||
UPDATE "ingredients" SET "key" = 'abondanceCheese' WHERE "key" = 'abondance_cheese';
|
|
||||||
UPDATE "ingredients" SET "key" = 'carreDeLEst' WHERE "key" = 'carre_de_l_est';
|
|
||||||
UPDATE "ingredients" SET "key" = 'montDor' WHERE "key" = 'mont_dor';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greekYogurt' WHERE "key" = 'greek_yogurt';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconutMilk' WHERE "key" = 'coconut_milk';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconutCream' WHERE "key" = 'coconut_cream';
|
|
||||||
UPDATE "ingredients" SET "key" = 'almondMilk' WHERE "key" = 'almond_milk';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oatMilk' WHERE "key" = 'oat_milk';
|
|
||||||
UPDATE "ingredients" SET "key" = 'silkenTofu' WHERE "key" = 'silken_tofu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herbesDeProvence' WHERE "key" = 'herbes_de_provence';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackPepper' WHERE "key" = 'black_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'espelettePepper' WHERE "key" = 'espelette_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cayennePepper' WHERE "key" = 'cayenne_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curryPowder' WHERE "key" = 'curry_powder';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vanillaBean' WHERE "key" = 'vanilla_bean';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whitePepper' WHERE "key" = 'white_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pinkPepper' WHERE "key" = 'pink_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sichuanPepper' WHERE "key" = 'sichuan_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'smokedPaprika' WHERE "key" = 'smoked_paprika';
|
|
||||||
UPDATE "ingredients" SET "key" = 'birdEyeChili' WHERE "key" = 'bird_eye_chili';
|
|
||||||
UPDATE "ingredients" SET "key" = 'juniperBerries' WHERE "key" = 'juniper_berries';
|
|
||||||
UPDATE "ingredients" SET "key" = 'starAnise' WHERE "key" = 'star_anise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greenAnise' WHERE "key" = 'green_anise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fennelSeeds' WHERE "key" = 'fennel_seeds';
|
|
||||||
UPDATE "ingredients" SET "key" = 'colomboPowder' WHERE "key" = 'colombo_powder';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herbSalt' WHERE "key" = 'herb_salt';
|
|
||||||
UPDATE "ingredients" SET "key" = 'celerySalt' WHERE "key" = 'celery_salt';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fleurDeSel' WHERE "key" = 'fleur_de_sel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fiveSpice' WHERE "key" = 'five_spice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'garamMasala' WHERE "key" = 'garam_masala';
|
|
||||||
UPDATE "ingredients" SET "key" = 'corianderSeeds' WHERE "key" = 'coriander_seeds';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poblanoPepper' WHERE "key" = 'poblano_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rasElHanout' WHERE "key" = 'ras_el_hanout';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soySauce' WHERE "key" = 'soy_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'worcestershireSauce' WHERE "key" = 'worcestershire_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fishSauce' WHERE "key" = 'fish_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curryPaste' WHERE "key" = 'curry_paste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanutButter' WHERE "key" = 'peanut_butter';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dijonMustard' WHERE "key" = 'dijon_mustard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wholegrainMustard' WHERE "key" = 'wholegrain_mustard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'barbecueSauce' WHERE "key" = 'barbecue_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tartarSauce' WHERE "key" = 'tartar_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cocktailSauce' WHERE "key" = 'cocktail_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bearnaiseSauce' WHERE "key" = 'bearnaise_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hollandaiseSauce' WHERE "key" = 'hollandaise_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bechamelSauce' WHERE "key" = 'bechamel_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'teriyakiSauce' WHERE "key" = 'teriyaki_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ponzuSauce' WHERE "key" = 'ponzu_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redPesto' WHERE "key" = 'red_pesto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oysterSauce' WHERE "key" = 'oyster_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hoisinSauce' WHERE "key" = 'hoisin_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweetChiliSauce' WHERE "key" = 'sweet_chili_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shrimpPaste' WHERE "key" = 'shrimp_paste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redCurryPaste' WHERE "key" = 'red_curry_paste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greenCurryPaste' WHERE "key" = 'green_curry_paste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oliveOil' WHERE "key" = 'olive_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sunflowerOil' WHERE "key" = 'sunflower_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rapeseedOil' WHERE "key" = 'rapeseed_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconutOil' WHERE "key" = 'coconut_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sesameOil' WHERE "key" = 'sesame_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ciderVinegar' WHERE "key" = 'cider_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteVinegar' WHERE "key" = 'white_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'balsamicVinegar' WHERE "key" = 'balsamic_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackOlives' WHERE "key" = 'black_olives';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greenOlives' WHERE "key" = 'green_olives';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteWine' WHERE "key" = 'white_wine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redWine' WHERE "key" = 'red_wine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'roseWine' WHERE "key" = 'rose_wine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redWineVinegar' WHERE "key" = 'red_wine_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteWineVinegar' WHERE "key" = 'white_wine_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sherryVinegar' WHERE "key" = 'sherry_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'walnutOil' WHERE "key" = 'walnut_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hazelnutOil' WHERE "key" = 'hazelnut_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanutOil' WHERE "key" = 'peanut_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chiliOil' WHERE "key" = 'chili_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riceVinegar' WHERE "key" = 'rice_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cornOil' WHERE "key" = 'corn_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grapeseedOil' WHERE "key" = 'grapeseed_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soybeanOil' WHERE "key" = 'soybean_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'palmOil' WHERE "key" = 'palm_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lemonJuice' WHERE "key" = 'lemon_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'limeJuice' WHERE "key" = 'lime_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orangeJuice' WHERE "key" = 'orange_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'appleJuice' WHERE "key" = 'apple_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grapeJuice' WHERE "key" = 'grape_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomatoJuice' WHERE "key" = 'tomato_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cranberryJuice' WHERE "key" = 'cranberry_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'portWine' WHERE "key" = 'port_wine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinJaune' WHERE "key" = 'vin_jaune';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wheatFlour' WHERE "key" = 'wheat_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wholeWheatFlour' WHERE "key" = 'whole_wheat_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cornFlour' WHERE "key" = 'corn_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'buckwheatFlour' WHERE "key" = 'buckwheat_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riceFlour' WHERE "key" = 'rice_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vegetableStockCube' WHERE "key" = 'vegetable_stock_cube';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chickenStockCube' WHERE "key" = 'chicken_stock_cube';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomatoPaste' WHERE "key" = 'tomato_paste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomatoCoulis' WHERE "key" = 'tomato_coulis';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cannedPeeledTomatoes' WHERE "key" = 'canned_peeled_tomatoes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sunDriedTomatoes' WHERE "key" = 'sun_dried_tomatoes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealStock' WHERE "key" = 'veal_stock';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chickenStock' WHERE "key" = 'chicken_stock';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefStockCube' WHERE "key" = 'beef_stock_cube';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fishStockCube' WHERE "key" = 'fish_stock_cube';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vegetableBroth' WHERE "key" = 'vegetable_broth';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chickenBroth' WHERE "key" = 'chicken_broth';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefBroth' WHERE "key" = 'beef_broth';
|
|
||||||
UPDATE "ingredients" SET "key" = 'courtBouillon' WHERE "key" = 'court_bouillon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shellfishBisque' WHERE "key" = 'shellfish_bisque';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tapiocaFlour' WHERE "key" = 'tapioca_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'masaHarina' WHERE "key" = 'masa_harina';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sparklingWater' WHERE "key" = 'sparkling_water';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orangeBlossomWater' WHERE "key" = 'orange_blossom_water';
|
|
||||||
UPDATE "ingredients" SET "key" = 'roseWater' WHERE "key" = 'rose_water';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fishFumet' WHERE "key" = 'fish_fumet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bakersYeast' WHERE "key" = 'bakers_yeast';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bakingPowder' WHERE "key" = 'baking_powder';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lupinFlour' WHERE "key" = 'lupin_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bakingSoda' WHERE "key" = 'baking_soda';
|
|
||||||
UPDATE "ingredients" SET "key" = 'potatoStarch' WHERE "key" = 'potato_starch';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mapleSyrup' WHERE "key" = 'maple_syrup';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brownSugar' WHERE "key" = 'brown_sugar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'powderedSugar' WHERE "key" = 'powdered_sugar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'demeraraSugar' WHERE "key" = 'demerara_sugar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'darkChocolate' WHERE "key" = 'dark_chocolate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'milkChocolate' WHERE "key" = 'milk_chocolate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteChocolate' WHERE "key" = 'white_chocolate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chocolateChips' WHERE "key" = 'chocolate_chips';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cocoaPowder' WHERE "key" = 'cocoa_powder';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vanillaExtract' WHERE "key" = 'vanilla_extract';
|
|
||||||
UPDATE "ingredients" SET "key" = 'palmSugar' WHERE "key" = 'palm_sugar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'caneSyrup' WHERE "key" = 'cane_syrup';
|
|
||||||
|
|
||||||
-- IngredientCategory/IngredientSubcategory enum rename — same
|
|
||||||
-- add-with-default-then-swap approach as
|
|
||||||
-- 20260818113250_ingredient_taxonomy_rework/migration.sql: the old and
|
|
||||||
-- new enums share no values, so a direct cast isn't possible. Existing
|
|
||||||
-- rows land on the placeholder default; seedReferenceData() (runs on
|
|
||||||
-- every container start, see apps/api/Dockerfile) corrects every row's
|
|
||||||
-- real category/subcategory immediately after.
|
|
||||||
CREATE TYPE "IngredientCategory_new" AS ENUM ('freshProduce', 'meatAndSeafood', 'dryGoods', 'bakery', 'dairyAndCheese', 'condimentsAndSpices', 'cookingEssentials');
|
|
||||||
CREATE TYPE "IngredientSubcategory_new" AS ENUM ('vegetables', 'fruits', 'freshHerbs', 'meats', 'poultry', 'fish', 'shellfish', 'starches', 'legumes', 'nutsAndSeeds', 'other', 'breads', 'rawDough', 'dairy', 'eggs', 'plantBasedAlternatives', 'spices', 'sauces', 'seasonings', 'bases', 'thickeners', 'sugars');
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "category_new" "IngredientCategory_new" NOT NULL DEFAULT 'dryGoods';
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "subcategory_new" "IngredientSubcategory_new" NOT NULL DEFAULT 'other';
|
|
||||||
ALTER TABLE "ingredients" DROP COLUMN "category";
|
|
||||||
ALTER TABLE "ingredients" DROP COLUMN "subcategory";
|
|
||||||
ALTER TABLE "ingredients" RENAME COLUMN "category_new" TO "category";
|
|
||||||
ALTER TABLE "ingredients" RENAME COLUMN "subcategory_new" TO "subcategory";
|
|
||||||
DROP TYPE "IngredientCategory";
|
|
||||||
DROP TYPE "IngredientSubcategory";
|
|
||||||
ALTER TYPE "IngredientCategory_new" RENAME TO "IngredientCategory";
|
|
||||||
ALTER TYPE "IngredientSubcategory_new" RENAME TO "IngredientSubcategory";
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- Adds how many portions a recipe yields as written (`Recipe.portions`) —
|
|
||||||
-- the planning recipe picker now pre-fills its own "how many portions?"
|
|
||||||
-- step from this (see `RecipePickerDialog`), closing the gap noted on
|
|
||||||
-- `PlanningItem.portions`'s own migration ("no such default exists ... on
|
|
||||||
-- `Recipe` either"). Backfills any pre-existing row with 4 portions (a
|
|
||||||
-- reasonable default recipe yield) via a transient DEFAULT, then drops it
|
|
||||||
-- so it isn't implicitly reused for new inserts going forward — same
|
|
||||||
-- pattern as `20260819064721_planning_item_portions`.
|
|
||||||
ALTER TABLE "recipe" ADD COLUMN "portions" INTEGER NOT NULL DEFAULT 4;
|
|
||||||
ALTER TABLE "recipe" ALTER COLUMN "portions" DROP DEFAULT;
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
-- Adds the `unit` reference catalog (key/type/to_base_factor) so recipe
|
|
||||||
-- ingredient units are a closed, normalized set instead of free text —
|
|
||||||
-- groundwork for a future unit-conversion feature (e.g. a shopping list
|
|
||||||
-- summing "500g" + "0.5kg" of the same ingredient), not that feature
|
|
||||||
-- itself. Seeded by reference-seed-data.ts's UNITS, same "SQL creates the
|
|
||||||
-- shape, application code seeds the rows" split as Diet/Allergy/Ingredient.
|
|
||||||
--
|
|
||||||
-- `recipe_ingredient.unit` (free text) is replaced by `unit_id` (FK), with
|
|
||||||
-- no backfill: a free-text value like "cas" or "grammes" can't be reliably
|
|
||||||
-- mapped to a catalog key without a human in the loop. Acceptable as a
|
|
||||||
-- straight breaking change here — the app has no real recipes yet
|
|
||||||
-- (pre-launch) — rather than staging `unit_id` as nullable across a
|
|
||||||
-- transition nothing will ever populate.
|
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `unit` on the `recipe_ingredient` table. All the data in the column will be lost.
|
|
||||||
- Added the required column `unit_id` to the `recipe_ingredient` table without a default value. This is not possible if the table is not empty.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "UnitType" AS ENUM ('MASS', 'VOLUME', 'COUNT');
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "recipe_ingredient" DROP COLUMN "unit",
|
|
||||||
ADD COLUMN "unit_id" INTEGER NOT NULL;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "unit" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
"type" "UnitType" NOT NULL,
|
|
||||||
"to_base_factor" DECIMAL(12,4) NOT NULL DEFAULT 1,
|
|
||||||
|
|
||||||
CONSTRAINT "unit_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "unit_key_key" ON "unit"("key");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe_ingredient" ADD CONSTRAINT "recipe_ingredient_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "unit"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
-- Adds `TechStep.key` (`key String @unique`) and `TechStepMapping.locale` —
|
|
||||||
-- same "stable English camelCase uid, French label lives only in apps/web's
|
|
||||||
-- locales/fr/translation.json" convention as Diet/Category/Unit. `TechStep`
|
|
||||||
-- was created in the initial migration with no way to identify a row other
|
|
||||||
-- than its numeric id; this closes that gap so reference-seed-data.ts can
|
|
||||||
-- upsert it by key like every other reference catalog. `locale` lets the
|
|
||||||
-- same TechStep carry one matching rule set per language. Neither table has
|
|
||||||
-- ever been seeded (no rows exist pre-launch), so plain NOT NULL columns
|
|
||||||
-- with no backfill are safe.
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "key" TEXT NOT NULL;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "tech_step_key_key" ON "tech_step"("key");
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "tech_step_mapping" ADD COLUMN "locale" TEXT NOT NULL;
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
-- Adds `Source.key` (`key String @unique`) — the catalog of implemented
|
|
||||||
-- recipe sources is now kept in sync with the adapter registry
|
|
||||||
-- (recipe-source-registry.ts) by key, same "stable English camelCase uid"
|
|
||||||
-- convention as Diet/Unit/TechStep, rather than hand-maintained. `sources`
|
|
||||||
-- has never been seeded (no rows exist pre-launch), so a plain NOT NULL
|
|
||||||
-- column with no backfill is safe.
|
|
||||||
--
|
|
||||||
-- Adds `Recipe.external_id` — the item's identifier on `source`, `null`
|
|
||||||
-- for a manually-authored recipe. `@@unique([sourceId, externalId])`
|
|
||||||
-- prevents importing the same source recipe twice; Postgres treats each
|
|
||||||
-- NULL as distinct, so manually-authored recipes (both columns null) never
|
|
||||||
-- collide with each other or with one another here.
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "sources" ADD COLUMN "key" TEXT NOT NULL;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "sources_key_key" ON "sources"("key");
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "recipe" ADD COLUMN "external_id" TEXT;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "recipe_source_id_external_id_key" ON "recipe"("source_id", "external_id");
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
-- Replaces `Step.tech_step_id` (single nullable FK — at most one technique
|
|
||||||
-- per step) with `step_tech_step`, an ordered join table — a step can
|
|
||||||
-- genuinely involve more than one technique (e.g. "Dans une poêle chaude,
|
|
||||||
-- faire chauffer une noix de beurre" is both `preheat` and `melt`). Per PR
|
|
||||||
-- review feedback on the first version of this feature; `tech_step`/`step`
|
|
||||||
-- have never carried real recipe data yet (this feature isn't released),
|
|
||||||
-- so no backfill is needed.
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "step" DROP CONSTRAINT "step_tech_step_id_fkey";
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "step" DROP COLUMN "tech_step_id";
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step" (
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"tech_step_id" INTEGER NOT NULL,
|
|
||||||
"order" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_pkey" PRIMARY KEY ("step_id", "order")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step" ADD CONSTRAINT "step_tech_step_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step" ADD CONSTRAINT "step_tech_step_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- Adds `Source.official` (Boolean, no default — every source must state
|
|
||||||
-- it explicitly, mirrors `RecipeSourceAdapter.official`) and `house_source`,
|
|
||||||
-- an opt-in join table: a household sees recipes from a source only once a
|
|
||||||
-- row exists for it (no row = hidden). `sources` has never been seeded
|
|
||||||
-- (no adapter registered yet), so a plain NOT NULL column with no backfill
|
|
||||||
-- is safe.
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "sources" ADD COLUMN "official" BOOLEAN NOT NULL;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "house_source" (
|
|
||||||
"house_id" INTEGER NOT NULL,
|
|
||||||
"source_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "house_source_pkey" PRIMARY KEY ("house_id", "source_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "house_source" ADD CONSTRAINT "house_source_house_id_fkey" FOREIGN KEY ("house_id") REFERENCES "house"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "house_source" ADD CONSTRAINT "house_source_source_id_fkey" FOREIGN KEY ("source_id") REFERENCES "sources"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- Adds `Source.icon_url` — the source's own logo/favicon, shown next to
|
|
||||||
-- its name in the `SourceSelect` picker (apps/web). Nullable, no backfill
|
|
||||||
-- needed for existing rows (none had one to begin with).
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "sources" ADD COLUMN "icon_url" TEXT;
|
|
||||||
|
|
@ -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;
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,5 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
|
||||||
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||||
import { registerAllRecipeSources } from "../src/sources/index.js";
|
|
||||||
|
|
||||||
// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in
|
// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in
|
||||||
// the rest of the app's config/env plumbing this doesn't need), run via
|
// the rest of the app's config/env plumbing this doesn't need), run via
|
||||||
|
|
@ -10,10 +8,8 @@ import { registerAllRecipeSources } from "../src/sources/index.js";
|
||||||
// `prisma migrate reset`. The actual data/logic lives in
|
// `prisma migrate reset`. The actual data/logic lives in
|
||||||
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
|
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
registerAllRecipeSources();
|
|
||||||
|
|
||||||
seedReferenceData(prisma)
|
seedReferenceData(prisma)
|
||||||
.then(() => syncRecipeSources(prisma))
|
|
||||||
.then(() => prisma.$disconnect())
|
.then(() => prisma.$disconnect())
|
||||||
.catch(async (err) => {
|
.catch(async (err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,14 @@
|
||||||
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 { 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 +20,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,50 +27,11 @@ 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("/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
|
|
||||||
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after
|
|
||||||
// every API route above (so they always win) and before the catch-all
|
|
||||||
// 404 below (so unmatched GETs fall through to the SPA's index.html
|
|
||||||
// instead of a JSON 404).
|
|
||||||
if (env.FRONTEND_DIST_DIR) {
|
|
||||||
server.serveStaticFrontend(env.FRONTEND_DIST_DIR);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No route matched — same shape as every other error response, via the
|
// No route matched — same shape as every other error response, via the
|
||||||
// shared ErrorCode contract, so clients never special-case 404s.
|
// shared ErrorCode contract, so clients never special-case 404s.
|
||||||
|
|
@ -99,12 +39,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,135 +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
|
|
||||||
* alongside the API. Optional, no default — only set inside the
|
|
||||||
* production Docker image (see Dockerfile); left unset in native dev
|
|
||||||
* (`pnpm dev:api`), where `pnpm dev:web`'s own Vite dev server serves
|
|
||||||
* the frontend instead.
|
|
||||||
*/
|
|
||||||
FRONTEND_DIST_DIR: z.string().optional(),
|
|
||||||
/**
|
|
||||||
* Overrides whether the session cookie gets the `Secure` attribute
|
|
||||||
* (HTTPS-only — see auth.routes.ts). Independent from NODE_ENV on
|
|
||||||
* purpose: NODE_ENV=production doesn't imply the deployment actually
|
|
||||||
* has TLS in front of it (e.g. an HTTP-only dev/staging instance), and
|
|
||||||
* a `Secure` cookie is silently never sent back by the browser over
|
|
||||||
* plain HTTP — every authenticated request 401s despite login
|
|
||||||
* succeeding, with no error to point at the cause. Unset (the default)
|
|
||||||
* falls back to NODE_ENV === "production", same as before this existed.
|
|
||||||
* Empty string counts as unset too, so `${COOKIE_SECURE:-}` in
|
|
||||||
* docker-compose.yml doesn't force it to `false` when not provided.
|
|
||||||
*/
|
|
||||||
COOKIE_SECURE: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.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,90 +0,0 @@
|
||||||
import type { PrismaClient } from "@prisma/client";
|
|
||||||
import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upserts one `Source` row (schema.prisma) per adapter currently in
|
|
||||||
* `recipe-source-registry.ts`, keyed by `adapter.key` — keeps the
|
|
||||||
* `sources` catalog an exact mirror of "which sources are actually
|
|
||||||
* implemented in code", rather than a hand-maintained list that can drift
|
|
||||||
* out of sync the way `DIETS`/`UNITS` (`reference-seed-data.ts`) would if
|
|
||||||
* copy-pasted here. Call once at startup (`prisma/seed.ts`) and in test
|
|
||||||
* setup (`test-support/reset-db.ts`), the same place `seedReferenceData`
|
|
||||||
* runs — kept as its own function rather than folded into that one, since
|
|
||||||
* it reads from the adapter registry instead of a static array.
|
|
||||||
*
|
|
||||||
* Never deletes a `Source` row whose key fell out of the registry (e.g. an
|
|
||||||
* adapter temporarily removed from code) — a recipe already imported from
|
|
||||||
* it should keep citing it rather than having `sourceId` silently nulled
|
|
||||||
* out from under it (see `onDelete: SetNull` on `Recipe.source` in
|
|
||||||
* schema.prisma, which is what *would* happen on an actual delete).
|
|
||||||
*
|
|
||||||
* Safe to call with an empty registry (leaves the `sources` table
|
|
||||||
* untouched) — the case whenever nothing has called `registerRecipeSource`
|
|
||||||
* yet, e.g. most test files (see `apps/api/src/sources/index.ts` for where
|
|
||||||
* the app's own concrete adapters — currently just TheMealDB — register
|
|
||||||
* themselves at startup).
|
|
||||||
*/
|
|
||||||
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
|
|
||||||
try {
|
|
||||||
for (const adapter of listRecipeSources()) {
|
|
||||||
await prisma.source.upsert({
|
|
||||||
where: { key: adapter.key },
|
|
||||||
update: {
|
|
||||||
name: adapter.name,
|
|
||||||
official: adapter.official,
|
|
||||||
iconUrl: adapter.iconUrl,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
key: adapter.key,
|
|
||||||
name: adapter.name,
|
|
||||||
official: adapter.official,
|
|
||||||
iconUrl: adapter.iconUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — callers (app startup, `sources.service.ts` via test
|
|
||||||
// setup) already handle/log failures centrally; this function just
|
|
||||||
// isn't allowed a bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which of `externalIds` already have a `Recipe` imported from the source
|
|
||||||
* registered under `sourceKey`, mapped to that `Recipe`'s id — the
|
|
||||||
* DB-touching counterpart to `markAlreadyImported` (recipe-source-adapter.ts),
|
|
||||||
* which stays pure and takes a plain `ReadonlySet<string>` (this map's
|
|
||||||
* `.keys()`) rather than querying itself. The id (not just membership) is
|
|
||||||
* what `sources.service.ts`'s browse endpoint needs to link an
|
|
||||||
* already-imported item straight to its real `Recipe`, instead of a
|
|
||||||
* 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(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
sourceKey: string,
|
|
||||||
externalIds: string[],
|
|
||||||
): Promise<Map<string, number>> {
|
|
||||||
try {
|
|
||||||
if (externalIds.length === 0) return new Map();
|
|
||||||
|
|
||||||
const source = await prisma.source.findUnique({
|
|
||||||
where: { key: sourceKey },
|
|
||||||
});
|
|
||||||
if (!source) return new Map();
|
|
||||||
|
|
||||||
const imported = await prisma.recipe.findMany({
|
|
||||||
where: { sourceId: source.id, externalId: { in: externalIds } },
|
|
||||||
select: { id: true, externalId: true },
|
|
||||||
});
|
|
||||||
return new Map(
|
|
||||||
imported.flatMap((recipe) =>
|
|
||||||
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see syncRecipeSources()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
/**
|
|
||||||
* The generic contract every recipe source (a specific website, an API, …)
|
|
||||||
* implements — groundwork for the "Import d'une recette" pipeline described
|
|
||||||
* in specs/batch-cooking-architecture.md (import depuis source → traduction
|
|
||||||
* en étapes → sauvegarde). This file only defines the shapes; no concrete
|
|
||||||
* source exists yet (see `recipe-source-registry.ts` for where one would be
|
|
||||||
* registered) and nothing here talks to the database or an HTTP route —
|
|
||||||
* that wiring (persisting an imported recipe, resolving `sourceId`) is
|
|
||||||
* deliberately out of scope until a real source needs it.
|
|
||||||
*
|
|
||||||
* The flow a caller drives against one adapter:
|
|
||||||
* 1. `list()` — browse what's available from the source (paginated,
|
|
||||||
* optionally filtered by `query`), like flipping through a catalog.
|
|
||||||
* 2. {@link markAlreadyImported} — flag which of those items we've
|
|
||||||
* already imported, so browsing a source doesn't dangle recipes the
|
|
||||||
* user has already brought in as if they were new. A separate, pure
|
|
||||||
* step rather than something `list()` itself does: an adapter only
|
|
||||||
* knows its source, never our database — same reasoning as
|
|
||||||
* `tech-step-matcher.ts`'s split between pure `splitIntoClauses` and
|
|
||||||
* its DB/model-touching `TechStepClassifierService`. Whichever future
|
|
||||||
* layer queries "which externalIds from this source do we already have"
|
|
||||||
* (not yet decided — it needs a place to persist that link,
|
|
||||||
* see {@link RecipeSourceListItem.externalId}) calls this to annotate
|
|
||||||
* the page before returning it.
|
|
||||||
* 3. `fetchDetail(externalId)` — once the user picks one item from that
|
|
||||||
* list, fetch its full raw content.
|
|
||||||
* 4. `parse(raw)` — turn that raw content into a {@link ParsedRecipe},
|
|
||||||
* pure and synchronous so it's unit-testable without any network
|
|
||||||
* access (same split as step 2 above).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Search/pagination input for {@link RecipeSourceAdapter.list}. */
|
|
||||||
export interface RecipeSourceListParams {
|
|
||||||
/** Free-text search, if the source supports it. Omitted means "browse everything". */
|
|
||||||
query?: string;
|
|
||||||
/**
|
|
||||||
* Opaque continuation token from a previous {@link RecipeSourceListResult.nextCursor}
|
|
||||||
* — omitted (or `null`) means "start from the first page". Deliberately
|
|
||||||
* opaque (not a page number) so an adapter can back it with whatever its
|
|
||||||
* source actually supports (page number, offset, an API-provided token).
|
|
||||||
*/
|
|
||||||
cursor?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One entry in a {@link RecipeSourceAdapter.list} result — enough to show in a browsing UI and to fetch the full recipe once selected. */
|
|
||||||
export interface RecipeSourceListItem {
|
|
||||||
/**
|
|
||||||
* Source-specific identifier, opaque to callers — passed back verbatim
|
|
||||||
* to {@link RecipeSourceAdapter.fetchDetail}, and the key
|
|
||||||
* {@link markAlreadyImported} matches against to tell an already-imported
|
|
||||||
* item apart from a new one.
|
|
||||||
*/
|
|
||||||
externalId: string;
|
|
||||||
title: string;
|
|
||||||
picture: string | null;
|
|
||||||
/** Canonical URL of the recipe on the source, kept for attribution even before it's imported. */
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeSourceListResult {
|
|
||||||
items: RecipeSourceListItem[];
|
|
||||||
/** Pass back as `cursor` to fetch the next page — `null` means this was the last page. */
|
|
||||||
nextCursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A browsed {@link RecipeSourceListItem}, after {@link markAlreadyImported} has flagged whether we already imported it. What a browsing UI actually renders — e.g. to grey it out or offer "already added" instead of "import". */
|
|
||||||
export interface BrowsableRecipeItem extends RecipeSourceListItem {
|
|
||||||
alreadyImported: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits a page of {@link RecipeSourceListItem}s into already-imported vs.
|
|
||||||
* new, purely by checking each item's `externalId` against
|
|
||||||
* `importedExternalIds` — no I/O here, the caller is responsible for
|
|
||||||
* gathering that set (from wherever we end up persisting the link between
|
|
||||||
* an imported `Recipe` and the source item it came from) before calling
|
|
||||||
* this. Kept as a tiny, dedicated, easily-testable step rather than folded
|
|
||||||
* into `list()` itself, so an adapter never needs to know our database
|
|
||||||
* exists.
|
|
||||||
*/
|
|
||||||
export function markAlreadyImported(
|
|
||||||
items: RecipeSourceListItem[],
|
|
||||||
importedExternalIds: ReadonlySet<string>,
|
|
||||||
): BrowsableRecipeItem[] {
|
|
||||||
return items.map((item) => ({
|
|
||||||
...item,
|
|
||||||
alreadyImported: importedExternalIds.has(item.externalId),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One ingredient line as lifted from a source, before it's resolved against
|
|
||||||
* our own `Ingredient`/`Unit` reference catalogs (that resolution —
|
|
||||||
* matching free text to a `key`, the way `tech-step-matcher.ts` matches
|
|
||||||
* step text to a `TechStep` — is a separate, not-yet-built concern; this
|
|
||||||
* type only carries what a source's raw text actually says). `rawText` is
|
|
||||||
* kept alongside the (best-effort) parsed fields so a failed/partial parse
|
|
||||||
* is still traceable back to what the source originally wrote.
|
|
||||||
*/
|
|
||||||
export interface ParsedRecipeIngredient {
|
|
||||||
rawText: string;
|
|
||||||
quantity: number | null;
|
|
||||||
/** Free-text unit exactly as written by the source (e.g. `"cuillère à soupe"`, `"g"`) — not yet resolved to a `Unit.key`. */
|
|
||||||
unit: string | null;
|
|
||||||
/** Free-text ingredient name exactly as written by the source — not yet resolved to an `Ingredient.key`. */
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedRecipeStep {
|
|
||||||
description: string;
|
|
||||||
picture: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The normalized shape every adapter's {@link RecipeSourceAdapter.parse}
|
|
||||||
* produces, regardless of the source. Intentionally *not*
|
|
||||||
* `CreateRecipeInput` (packages/shared/src/schemas/recipe.ts): ingredients
|
|
||||||
* are still free text (no `ingredientId`/`unitId` — that catalog-matching
|
|
||||||
* step doesn't exist yet), and there's no `dietIds`/`visibility` since a
|
|
||||||
* source can't know those. Turning a `ParsedRecipe` into a saved `Recipe`
|
|
||||||
* is future work for whichever module ends up driving this pipeline.
|
|
||||||
*/
|
|
||||||
export interface ParsedRecipe {
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
picture: string | null;
|
|
||||||
/** `null` when the source doesn't state a serving size. */
|
|
||||||
portions: number | null;
|
|
||||||
/** Canonical URL of the recipe on the source — the eventual `Source`/`Recipe.sourceId` link (schema.prisma) is populated from this once the import pipeline saves the recipe. */
|
|
||||||
sourceUrl: string;
|
|
||||||
ingredients: ParsedRecipeIngredient[];
|
|
||||||
steps: ParsedRecipeStep[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single recipe source — a specific website or API, plus the two pieces
|
|
||||||
* of source-specific logic needed to pull a recipe out of it. `TRawDetail`
|
|
||||||
* is whatever shape `fetchDetail` naturally returns for this source (an
|
|
||||||
* HTML string, a parsed JSON body, …); `parse` is the only thing that needs
|
|
||||||
* to understand it.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const myAdapter: RecipeSourceAdapter<{ html: string }> = {
|
|
||||||
* key: "someRecipeSite",
|
|
||||||
* name: "Some Recipe Site",
|
|
||||||
* official: false,
|
|
||||||
* iconUrl: "https://somerecipesite.example/favicon.svg",
|
|
||||||
* locale: "fr",
|
|
||||||
* async list(params) { ... },
|
|
||||||
* async fetchDetail(externalId) { ... },
|
|
||||||
* parse(raw) { ... },
|
|
||||||
* };
|
|
||||||
* registerRecipeSource(myAdapter);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export interface RecipeSourceAdapter<TRawDetail = unknown> {
|
|
||||||
/** Stable identifier used to look this adapter up in the registry — same "English camelCase uid" convention as `Diet.key`/`Unit.key`/`TechStep.key`. */
|
|
||||||
key: string;
|
|
||||||
/** Human-readable name, for display in a source picker. */
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* Whether this source is an official API (the site/publisher itself
|
|
||||||
* provides structured recipe data) versus unofficial web scraping (we
|
|
||||||
* parse HTML the site never committed to a stable shape for) — surfaced
|
|
||||||
* to households (`Source.official`, synced via `syncRecipeSources`) so
|
|
||||||
* they can tell the two apart when deciding which sources to enable
|
|
||||||
* (see `HouseSource`, schema.prisma). No default on purpose: every
|
|
||||||
* adapter author has to consciously pick one rather than silently
|
|
||||||
* inheriting a guess.
|
|
||||||
*/
|
|
||||||
official: boolean;
|
|
||||||
/** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */
|
|
||||||
iconUrl: string | null;
|
|
||||||
/**
|
|
||||||
* Language of the text this source produces (`ParsedRecipe.description`/
|
|
||||||
* `steps[].description`/`ingredients[].name`) — e.g. `"en"` for
|
|
||||||
* TheMealDB. Not a user preference: the language the source's own
|
|
||||||
* content is actually written in, regardless of who's browsing it.
|
|
||||||
* Determines which trained-classifier/ingredient-label locale
|
|
||||||
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
|
|
||||||
* recipes against when previewing/importing one.
|
|
||||||
*/
|
|
||||||
locale: string;
|
|
||||||
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
|
|
||||||
fetchDetail(externalId: string): Promise<TRawDetail>;
|
|
||||||
parse(raw: TRawDetail): ParsedRecipe;
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
/**
|
|
||||||
* Error vocabulary a {@link RecipeSourceAdapter} (recipe-source-adapter.ts)
|
|
||||||
* implementation throws when talking to its source fails — kept separate
|
|
||||||
* from `@batch-cooking/error-tools`'s `HttpError`/`ErrorCode` (used for
|
|
||||||
* *this API's* HTTP responses) since no route drives this module yet. A
|
|
||||||
* future import route would catch these and translate them into an
|
|
||||||
* `HttpError` with a dedicated `ErrorCode` the same way any other service
|
|
||||||
* error is; this module only needs a consistent shape to throw in the
|
|
||||||
* meantime, not that translation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Base class for every error a {@link RecipeSourceAdapter} can throw — lets a caller `catch (err) { if (err instanceof RecipeSourceError) ... }` regardless of which stage failed. */
|
|
||||||
export class RecipeSourceError extends Error {
|
|
||||||
/** The failing adapter's `key` (recipe-source-adapter.ts's `RecipeSourceAdapter.key`) — which source this error came from. */
|
|
||||||
readonly sourceKey: string;
|
|
||||||
|
|
||||||
constructor(sourceKey: string, message: string, options?: { cause?: unknown }) {
|
|
||||||
super(message, options);
|
|
||||||
this.sourceKey = sourceKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The source's `list`/`fetchDetail` failed — network error, non-2xx response, source unreachable, etc. */
|
|
||||||
export class RecipeSourceFetchError extends RecipeSourceError {
|
|
||||||
constructor(sourceKey: string, message: string, options?: { cause?: unknown }) {
|
|
||||||
super(sourceKey, message, options);
|
|
||||||
this.name = "RecipeSourceFetchError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The source responded, but `parse` couldn't make sense of the raw payload (unexpected shape, missing required field, …). */
|
|
||||||
export class RecipeSourceParseError extends RecipeSourceError {
|
|
||||||
constructor(sourceKey: string, message: string, options?: { cause?: unknown }) {
|
|
||||||
super(sourceKey, message, options);
|
|
||||||
this.name = "RecipeSourceParseError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import type { RecipeSourceAdapter } from "./recipe-source-adapter.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory registry of every {@link RecipeSourceAdapter} (recipe-source-adapter.ts)
|
|
||||||
* this process knows about, keyed by `adapter.key`. Deliberately not
|
|
||||||
* DB-backed — an adapter *is* code (a website's fetch/parse logic can't
|
|
||||||
* live in a database row) — but the `Source` table (schema.prisma) is kept
|
|
||||||
* in sync with it (see `syncRecipeSources`, recipe-source-sync.ts) so a
|
|
||||||
* saved `Recipe.sourceId` has a row to point at. Actually saving an
|
|
||||||
* imported recipe (setting `Recipe.sourceId`/`externalId`) is still future
|
|
||||||
* work for whichever module ends up driving the import pipeline — this
|
|
||||||
* registry only answers "which sources can we import from right now".
|
|
||||||
*
|
|
||||||
* No adapter is registered here yet — this file only provides the
|
|
||||||
* mechanism; `registerRecipeSource` is meant to be called once per adapter
|
|
||||||
* module, at whatever point a concrete source is added.
|
|
||||||
*/
|
|
||||||
const adapters = new Map<string, RecipeSourceAdapter>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers `adapter` under its own `key`. Throws if that key is already
|
|
||||||
* taken — two adapters silently overwriting each other would be a bug (a
|
|
||||||
* caller reaching for "marmiton" should never get a different adapter than
|
|
||||||
* the one it registered), not a case to swallow.
|
|
||||||
*/
|
|
||||||
export function registerRecipeSource<TRawDetail>(adapter: RecipeSourceAdapter<TRawDetail>): void {
|
|
||||||
if (adapters.has(adapter.key)) {
|
|
||||||
throw new Error(`Recipe source "${adapter.key}" is already registered`);
|
|
||||||
}
|
|
||||||
// `TRawDetail` only matters within one adapter's own list/fetchDetail/parse
|
|
||||||
// trio — once stored, callers look adapters up by key and drive the same
|
|
||||||
// three methods generically, so the registry itself doesn't need to know
|
|
||||||
// each adapter's raw type. This cast is the standard way to store a
|
|
||||||
// heterogeneous collection of otherwise-identically-shaped generics.
|
|
||||||
adapters.set(adapter.key, adapter as RecipeSourceAdapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The adapter registered under `key`, or `undefined` if none is. */
|
|
||||||
export function getRecipeSource(key: string): RecipeSourceAdapter | undefined {
|
|
||||||
return adapters.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every registered adapter — e.g. to offer a source picker. */
|
|
||||||
export function listRecipeSources(): RecipeSourceAdapter[] {
|
|
||||||
return [...adapters.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Empties the registry. Not meant for application code — `apps/api/src`
|
|
||||||
* never calls this — only for test isolation, the same role
|
|
||||||
* `test-support/reset-db.ts` plays for the database: without it, adapters
|
|
||||||
* registered by one test file would leak into the next.
|
|
||||||
*/
|
|
||||||
export function clearRecipeSources(): void {
|
|
||||||
adapters.clear();
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue