Compare commits
135 commits
feat/setti
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7aa696f55 | |||
| f19365e20e | |||
| 8a51f0bd6c | |||
| 32c7ed48c6 | |||
| 0ab30a4588 | |||
| 3c04efd16f | |||
| 7c423cc0fd | |||
| d58491e6f9 | |||
| 478914787f | |||
| 5ab9832131 | |||
|
|
eef5db92b5 | ||
|
|
550627919d | ||
|
|
bf58834aa9 | ||
|
|
109dde9c7b | ||
|
|
520e539fe6 | ||
|
|
ba3c978c25 | ||
|
|
88666f0ac5 | ||
|
|
5ea1026151 | ||
|
|
92bea914e8 | ||
|
|
0e0fd81563 | ||
|
|
66e5666687 | ||
|
|
52e379fcf7 | ||
|
|
5d63ff9ea9 | ||
|
|
f7d7664397 | ||
| 85fd9bae7d | |||
| 260bc3dc05 | |||
| b0c1d46acc | |||
| 933882a527 | |||
| fc8f38afde | |||
| f24b289cd4 | |||
| 73ae8169a1 | |||
|
|
0f5abb1749 | ||
| 4381e63045 | |||
|
|
174e16bf13 | ||
| d5172c2c66 | |||
| 17b6b9151a | |||
| 61ac2993db | |||
| 368ea08960 | |||
|
|
be0d885269 | ||
|
|
b383abdfdf | ||
|
|
5f4c1c25ed | ||
|
|
c7f63c9834 | ||
|
|
45661f58b0 | ||
| 81848179bf | |||
| 991f91bc0e | |||
| 01eee1ae31 | |||
| e4c8d910d4 | |||
| a860363438 | |||
| b5a12cf489 | |||
| 44ef5e071f | |||
| 73206c442b | |||
|
|
9e5e950873 | ||
| 0619fafd28 | |||
| d0900ad7fd | |||
|
|
f58ac82a07 | ||
| 4db2085826 | |||
| deec91c5a3 | |||
|
|
6178205603 | ||
| 89722ba790 | |||
|
|
7fc74541f6 | ||
| 8ab652206d | |||
|
|
b9f148a674 | ||
|
|
89937d7a6b | ||
| 5b868e4422 | |||
| b725ec1016 | |||
|
|
b060fc47c3 | ||
| 30fffd59ad | |||
| 766d48eaa5 | |||
|
|
0b1c102418 | ||
|
|
4a163b9317 | ||
| 3eadb4db41 | |||
|
|
af8e1df701 | ||
| aedeb257ce | |||
| dd747af70f | |||
| 4a1e46b283 | |||
| ea1dfc5ad7 | |||
| 5026a23bd5 | |||
|
|
8884d28341 | ||
| 0ff8775cf6 | |||
| de500e1a8a | |||
|
|
102f6846d5 | ||
| 84ea77cb3b | |||
|
|
f9ad3710b3 | ||
| 4a6f586866 | |||
| 1c99e18ce7 | |||
| 0cb1dccd92 | |||
| 4647a82942 | |||
|
|
3fd55c4f85 | ||
|
|
54ab10d826 | ||
| 77b55d7aeb | |||
| 36fbe02299 | |||
|
|
56f95bfb3a | ||
| 978fe71a11 | |||
| 84178a9f5a | |||
| 1b787d0cd2 | |||
|
|
a8eea46c58 | ||
| c29648e293 | |||
| 8bdbfda3ae | |||
|
|
f1fefc1f38 | ||
|
|
fc886a79cb | ||
| ffb1e06130 | |||
|
|
c63bc15ea4 | ||
|
|
4b988706cc | ||
|
|
c34eaa89d0 | ||
|
|
c38097f522 | ||
| 6f02cc0922 | |||
| 655c10ad95 | |||
| 8d1c763add | |||
| 84003d2009 | |||
| d761a3795f | |||
| 78e0f89503 | |||
| 9775cbe38f | |||
| 11b03b0e56 | |||
| 5890c62462 | |||
| ea86a4a7f1 | |||
| acab18ac4a | |||
|
|
1367f62f32 | ||
| fff8c0da26 | |||
|
|
77b9d14f7e | ||
| 266f540d88 | |||
|
|
9d19136c93 | ||
| 44e9245a0f | |||
|
|
6fec167e86 | ||
| c4ef989e1a | |||
| c2cc773c7b | |||
|
|
f63f9af544 | ||
| 9dd90359c9 | |||
| d26bae6bac | |||
|
|
c112eaa43e | ||
| cdca6f61f4 | |||
| 73bfcd8fac | |||
| a8d897dfab | |||
| 4bdc9c0470 | |||
| 3acde696f5 | |||
| e3811dc280 |
338 changed files with 48957 additions and 3081 deletions
|
|
@ -6,6 +6,12 @@
|
|||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "web", "dev"],
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "api",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "api", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
35
.env.example
35
.env.example
|
|
@ -6,11 +6,38 @@ POSTGRES_PASSWORD=changeme
|
|||
POSTGRES_DB=batchcooking
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Used by docker-compose.yml's "api" service (Docker-only — the native
|
||||
# Used by docker-compose.yml's "app" service (Docker-only — the native
|
||||
# `pnpm dev:api` workflow reads apps/api/.env instead, set both when using
|
||||
# both workflows). Required, no default on purpose — generate your own.
|
||||
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||
|
||||
# Optional — host ports for the Docker review stack (docker-compose.yml)
|
||||
# API_PORT=3000
|
||||
# WEB_PORT=8080
|
||||
# Optional — host port for the Docker review stack's single app container
|
||||
# (docker-compose.yml), serving both the API and the built frontend.
|
||||
# APP_PORT=3000
|
||||
|
||||
# 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
|
||||
|
|
|
|||
131
.github/workflows/ci.yml
vendored
131
.github/workflows/ci.yml
vendored
|
|
@ -1,18 +1,51 @@
|
|||
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:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
||||
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
|
||||
# Test-only secret, never used outside CI — real deployments must set their own.
|
||||
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+"
|
||||
# Shared between the `test` job's own uvicorn step (below) and apps/api's
|
||||
# IntentServiceClient — see the `test` job for why this can't be a
|
||||
# `services:` container like postgres above (GitHub Actions can only pull
|
||||
# a published image, not build services/tech-step-intent-service/Dockerfile).
|
||||
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
|
||||
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
# Five independent jobs, no needs: between them — each starts in parallel
|
||||
# and reports as its own check, instead of the previous single chained
|
||||
# "lint-and-test then e2e" pipeline.
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: https://github.com/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 lint
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
|
|
@ -22,44 +55,101 @@ jobs:
|
|||
POSTGRES_PASSWORD: ci
|
||||
POSTGRES_DB: batchcooking_ci
|
||||
ports:
|
||||
- 5432:5432
|
||||
- 5433:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: https://github.com/pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@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
|
||||
|
||||
# `services:` (like the `postgres` container above) 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 (GitHub Actions steps in one job
|
||||
# share the same runner 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 reasoning as the real
|
||||
# `postgres` container just 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 ~540s (fr) / ~390s (en),
|
||||
# ~930s combined, against the current ~74-technique corpus (see
|
||||
# docker-compose.yml's healthcheck for the same reasoning and why
|
||||
# this grew slightly from the original ~670s).
|
||||
timeout 1200 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 test
|
||||
|
||||
intent-service-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: https://github.com/astral-sh/setup-uv@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
enable-cache: true
|
||||
|
||||
- name: Install services/tech-step-intent-service
|
||||
working-directory: services/tech-step-intent-service
|
||||
run: uv sync --frozen
|
||||
- name: Run pytest
|
||||
working-directory: services/tech-step-intent-service
|
||||
run: uv run pytest -q
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: https://github.com/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 lint
|
||||
- run: pnpm --filter api exec prisma migrate deploy
|
||||
- run: pnpm --filter api test
|
||||
- run: pnpm --filter api test:bdd
|
||||
- run: pnpm build
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: https://github.com/pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache Cypress binary
|
||||
uses: actions/cache@v4
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/Cypress
|
||||
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
|
@ -70,3 +160,8 @@ jobs:
|
|||
# explicitly so `cypress run` finds it.
|
||||
- run: pnpm --filter web exec cypress install
|
||||
- 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
|
||||
|
|
|
|||
55
.github/workflows/release.yml
vendored
Normal file
55
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
name: Release
|
||||
|
||||
# No image registry involved: Portainer is wired to this repo's Git remote
|
||||
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
|
||||
# that the image actually builds at the tagged commit, publishes a GitHub
|
||||
# Release, then (best-effort) pings Portainer's deploy webhook so it doesn't
|
||||
# have to wait for its own polling interval.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*.*.*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sanity-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Build only, no push, no registry — just confirms Portainer will be
|
||||
# able to build this same Dockerfile successfully at this tag before
|
||||
# anyone points it at this ref.
|
||||
- run: docker build -f apps/api/Dockerfile .
|
||||
|
||||
github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: sanity-build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Auto-generated from merged PRs since the previous tag — no
|
||||
# changelog tooling to maintain, consistent with there being no
|
||||
# versioning tooling elsewhere in the repo yet.
|
||||
generate_release_notes: true
|
||||
|
||||
notify-portainer:
|
||||
runs-on: ubuntu-latest
|
||||
needs: github-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"
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -69,6 +69,14 @@ web_modules/
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test.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/)
|
||||
.cache
|
||||
|
|
@ -147,3 +155,11 @@ Projet batch cooking.pdf
|
|||
|
||||
# Throwaway HTML mockups used to review a design before implementing it
|
||||
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/
|
||||
|
|
|
|||
3
CLAUDE.md
Normal file
3
CLAUDE.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# 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
|
||||
502
README.md
502
README.md
|
|
@ -4,13 +4,21 @@
|
|||
|
||||
Monorepo pnpm workspaces :
|
||||
|
||||
- `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha + Cucumber/BDD)
|
||||
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
|
||||
Page de connexion/inscription en place ; le reste est encore un squelette générique.
|
||||
- `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
|
||||
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
|
||||
numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
|
||||
même règles des deux côtés, pas de risque de dérive entre front et back.
|
||||
- `apps/api` — backend Express/TypeScript : auth, foyer, planning (grille de la
|
||||
semaine), catalogue de recettes (favoris/perso/foyer/publique), import de
|
||||
recettes depuis des sources externes, préférences (thème, régime, allergies,
|
||||
ingrédients détestés). Tests Mocha (base Postgres réelle, isolée de la base
|
||||
de dev — voir plus bas).
|
||||
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par
|
||||
Capacitor plus tard. Espace connecté complet (planning, recettes, réglages)
|
||||
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
|
||||
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 :
|
||||
|
|
@ -19,11 +27,16 @@ Monorepo pnpm workspaces :
|
|||
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
|
||||
(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).
|
||||
- `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` et `packages/express-tools` ont un vrai
|
||||
build (`tsc` → `dist/`, voir leur `package.json`) : consommés en JS compilé, pas en
|
||||
TS brut — nécessaire pour un runtime Node pur (Docker, pas de transpilation à la
|
||||
volée), voir la note dans
|
||||
`packages/shared`, `packages/error-tools`, `packages/express-tools` et
|
||||
`packages/date-tools` ont un vrai build (`tsc` → `dist/`, voir leur
|
||||
`package.json`) : consommés en JS compilé, pas en TS brut — nécessaire pour un
|
||||
runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
|
||||
|
||||
## Prérequis
|
||||
|
|
@ -31,6 +44,8 @@ volée), voir la note dans
|
|||
- Node.js 22 (voir `.nvmrc`)
|
||||
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
|
||||
- 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
|
||||
|
||||
|
|
@ -49,6 +64,9 @@ 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
|
||||
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
|
||||
|
||||
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
|
||||
|
|
@ -75,11 +93,26 @@ hors du repo).
|
|||
|
||||
```bash
|
||||
# Base de données Postgres locale
|
||||
docker compose up -d
|
||||
docker compose up -d postgres
|
||||
|
||||
# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
|
||||
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)
|
||||
pnpm dev:api
|
||||
|
||||
|
|
@ -94,43 +127,108 @@ pnpm dev:web
|
|||
> Dans ce cas, mets `POSTGRES_PORT=5433` (ou autre) dans ton `.env` **et** adapte le
|
||||
> 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
|
||||
|
||||
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
|
||||
pnpm lint # Biome (lint + format check)
|
||||
pnpm lint:fix # Biome --write
|
||||
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
||||
pnpm --filter api test:bdd # tests d'intégration BDD (Cucumber/Gherkin, apps/api)
|
||||
pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
|
||||
pnpm build # build de tous les workspaces
|
||||
pnpm lint # Biome (lint + format check)
|
||||
pnpm lint:fix # Biome --write
|
||||
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
||||
pnpm --filter web e2e # tests e2e (Cypress + Cucumber, démarre le serveur dev automatiquement)
|
||||
pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress component testing)
|
||||
pnpm build # build de tous les workspaces
|
||||
```
|
||||
|
||||
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute lint + tests + build sur chaque push/PR vers `main`, puis les tests e2e Cypress.
|
||||
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`).
|
||||
|
||||
### Cucumber (apps/api)
|
||||
### Base de test isolée de la base de dev (`apps/api`)
|
||||
|
||||
Tests d'intégration lisibles en Gherkin, en complément de Mocha (qui reste pour les
|
||||
tests unitaires purs) :
|
||||
`pnpm --filter api test` exécute une `TRUNCATE ... CASCADE` sur presque tout le
|
||||
schéma **avant chaque test** (`test-support/reset-db.ts`). Pour ne jamais
|
||||
risquer de vider une vraie base de dev locale, `NODE_ENV=test` (posé par le
|
||||
script `test`) fait charger `apps/api/.env.test` au lieu de `.env` — un
|
||||
fichier **à créer toi-même**, pas fourni automatiquement :
|
||||
|
||||
- `apps/api/features/*.feature` — scénarios en Given/When/Then (`health.feature` sert
|
||||
d'exemple)
|
||||
- `apps/api/features/step-definitions/*.steps.ts` — implémentation des steps
|
||||
- `apps/api/features/support/world.ts` — contexte partagé entre les steps d'un
|
||||
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)
|
||||
```bash
|
||||
cp apps/api/.env.test.example apps/api/.env.test
|
||||
# puis édite-le : mêmes identifiants Postgres que ton .env, mais une base
|
||||
# différente (ex. batchcooking_test) — .env.test.example documente les
|
||||
# commandes exactes pour la créer et lui appliquer le schéma.
|
||||
```
|
||||
|
||||
Pour ajouter un scénario : écrire le `.feature`, lancer `pnpm --filter api test:bdd`,
|
||||
implémenter les steps manquants (Cucumber affiche des snippets tout prêts pour ceux
|
||||
qui n'existent pas encore).
|
||||
Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
|
||||
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
||||
seule base qu'il doit rejeter est ta vraie base de dev.
|
||||
|
||||
> **Piège TypeScript/ESM à connaître** (déjà rencontré avec `cypress.config.ts`) :
|
||||
> les fichiers de config d'outils tiers qui font du chargement dynamique de TS
|
||||
> (`cucumber.cjs`, `cypress.config.ts`…) sont sensibles au `"type": "module"` du
|
||||
> `package.json`. `cucumber.cjs` évite le problème *pour sa propre config* en étant
|
||||
> explicitement CommonJS ; les steps/world restent en `.ts` ESM classique et sont
|
||||
> chargés via `tsx` (`NODE_OPTIONS=--import=tsx`, voir le script `test:bdd`).
|
||||
`services/tech-step-intent-service` doit aussi tourner en local avant
|
||||
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
|
||||
par le vrai service (jamais un mock, voir
|
||||
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
|
||||
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
|
||||
section [Développement](#développement) ci-dessus.
|
||||
|
||||
## Déploiement
|
||||
|
||||
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.
|
||||
|
||||
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
|
||||
production est reliée directement au dépôt Git et reconstruit elle-même
|
||||
`docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
|
||||
donc aucune image nulle part.
|
||||
|
||||
### 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 trois jobs : `sanity-build` (build de l'image Docker sans
|
||||
push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
|
||||
redéployer dessus), `github-release` (crée une Release GitHub avec changelog
|
||||
auto-généré à partir des PRs mergées), 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 simplement à son prochain
|
||||
polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
|
||||
stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
|
||||
|
||||
## Auth (apps/api)
|
||||
|
||||
|
|
@ -144,10 +242,14 @@ Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpO
|
|||
l'email ou le mot de passe qui soit incorrect
|
||||
- `POST /auth/logout` — efface le cookie (204)
|
||||
- `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. Le hash est indépendant du foyer : un profil crée
|
||||
toujours son propre foyer à l'inscription (rejoindre un foyer existant n'est pas
|
||||
encore implémenté).
|
||||
Mots de passe hachés avec argon2. `UserProfile.tokenVersion` existe pour
|
||||
invalider les JWT déjà émis (ex. futur changement de mot de passe) mais rien
|
||||
ne l'incrémente encore — pas de route de changement d'email/mot de passe
|
||||
aujourd'hui, seulement la suppression de compte.
|
||||
|
||||
> **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 —
|
||||
|
|
@ -156,179 +258,184 @@ encore implémenté).
|
|||
> concrètement (`argon2.hash(...)` dans un `node -e`) avant de merger, un `pnpm build`
|
||||
> qui passe ne suffit pas à détecter un crash runtime.
|
||||
|
||||
Les tests (Mocha + Cucumber) tournent avec un coût argon2 réduit
|
||||
Les tests (Mocha) tournent avec un coût argon2 réduit
|
||||
(`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
|
||||
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
||||
`prisma migrate deploy` avant les tests.
|
||||
|
||||
> **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`,
|
||||
> voir `test-support/reset-db.ts`) — si tu es en train de tester manuellement à la main
|
||||
> (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.
|
||||
## Foyer — création, invitation, admin, sources externes (apps/api)
|
||||
|
||||
Un foyer (`house`) a un admin (`adminId`) et un code d'invitation à 8
|
||||
caractères (`inviteCode`, alphabet sans caractères ambigus `0`/`O`/`1`/`I`).
|
||||
|
||||
- `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)
|
||||
|
||||
- `GET /planning/current` — nécessite le cookie de session (401 sinon). Renvoie le
|
||||
planning du foyer de l'utilisateur connecté qui couvre la date du jour (`Planning`
|
||||
dont `start_date <= aujourd'hui <= finish_date`), items inclus avec leur recette
|
||||
résolue en `{ id, name }` — ou `null` s'il n'y en a aucun (foyer sans planning en
|
||||
cours, ou profil sans foyer). `null` est une réponse **valide** (200), pas une
|
||||
erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul
|
||||
batch-cooking », voir [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md),
|
||||
reste à construire), donc c'est l'état attendu tant que ce module n'existe pas.
|
||||
- Type de réponse partagé : `PlanningView` (`packages/shared/src/types/planning.ts`),
|
||||
consommé tel quel par `apps/web`.
|
||||
- `GET /planning?date=YYYY-MM-DD` — planning de la semaine (lundi→dimanche)
|
||||
couvrant `date`, pour le foyer de l'utilisateur connecté — `PlanningView |
|
||||
null` (`null` = pas de foyer, ou aucun planning pour cette semaine, deux cas
|
||||
normaux confondus, jamais une erreur).
|
||||
- `POST /planning/items` — ajoute une recette à un créneau :
|
||||
`{ date, weekDay, meal, recipeId, portions }`. `portions` est saisi
|
||||
indépendamment du rendement propre de la recette (`Recipe.portions`) — un
|
||||
créneau peut mettre à l'échelle.
|
||||
- `DELETE /planning/items/:id` — retire un item du planning.
|
||||
|
||||
Détail de `AsyncRequestHandler`/`wrapAsyncHandler` (`packages/express-tools`) —
|
||||
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).
|
||||
Le planning d'une semaine est créé à la demande (première recette ajoutée),
|
||||
jamais en avance.
|
||||
|
||||
## Données de référence — régimes & allergènes (apps/api)
|
||||
## Recettes — catalogue, favoris, import depuis une source externe (apps/api)
|
||||
|
||||
- `GET /reference/diets` — liste des régimes alimentaires (`Diet`, 5 valeurs seedées).
|
||||
- `GET /reference/allergies` — liste des allergènes sélectionnables, `{ id, name }`
|
||||
(le nom vient de `Category.name` — la table `allergy` elle-même ne porte pas de
|
||||
nom, voir `schema.prisma` — chaque allergène = une `Category` + une unique
|
||||
`Allergy` sous cette catégorie).
|
||||
- `GET /recipes?tab=favoris|perso|foyer|publique&search=&suitableForHousehold=&ingredientIds=&dietIds=`
|
||||
— catalogue filtré par onglet + filtres optionnels. `PERSONAL`/`HOUSE`/`PUBLIC`
|
||||
(`Recipe.visibility`) contrôlent qui peut **lire** une recette (jamais qui
|
||||
peut l'éditer, toujours réservé à l'auteur) ; les recettes issues d'une
|
||||
source externe non activée pour le foyer du viewer sont masquées de tous les
|
||||
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).
|
||||
|
||||
Les deux sont **publics** (pas de `requireAuth`) : ce sont 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 (donc une session) n'existe.
|
||||
Détail complet (adaptateurs, algorithmes de matching ingrédients/techniques,
|
||||
synchronisation de la table `sources`) :
|
||||
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation).
|
||||
|
||||
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.
|
||||
## Données de référence — régimes, allergènes, ingrédients, unités, techniques (apps/api)
|
||||
|
||||
`Diet.name` et `Category.name` sont `@unique` — ajouté à ce schéma (pas dans le doc
|
||||
spec d'origine) précisément pour permettre cet upsert idempotent par nom.
|
||||
- `GET /reference/diets`, `/allergies`, `/ingredients`, `/units`,
|
||||
`/tech-steps`, `/sources` — tous **publics** (pas de `requireAuth`) : ce sont
|
||||
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.
|
||||
|
||||
Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
|
||||
standard, pas inventée.
|
||||
Données seedées via `apps/api/src/db/reference-seed-data.ts` (`pnpm --filter
|
||||
api prisma:seed`, ou automatiquement après `prisma migrate reset`) — jamais
|
||||
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).
|
||||
|
||||
**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).
|
||||
## Foyer & profil — régime, allergènes, ingrédients détestés (apps/api)
|
||||
|
||||
## Foyer & profil — nom, régime, allergènes (apps/api)
|
||||
Nécessitent tous une session (`requireAuth`) — données propres à
|
||||
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é ;
|
||||
`null` efface le régime (étape "skippable" du parcours). `404 DIET_NOT_FOUND` si
|
||||
`dietId` ne correspond à aucun régime de référence.
|
||||
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances du profil connecté,
|
||||
sous forme de liste d'IDs (`number[]`). `PATCH { allergyIds }` **remplace**
|
||||
l'ensemble (pas une fusion — le client renvoie toujours la sélection complète,
|
||||
cohérent avec un composant de multi-sélection). `404 ALLERGY_NOT_FOUND` si un ID
|
||||
ne correspond à aucun allergène de référence.
|
||||
`null` efface le régime.
|
||||
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances (medical), liste
|
||||
d'IDs, remplace (pas de fusion).
|
||||
- `GET`/`PATCH /profile/disliked-ingredients` — ingrédients personnellement
|
||||
"pas aimés" (**goût, pas médical** — ne déclenche jamais un avertissement de
|
||||
sécurité, juste un rappel discret sur la fiche recette), même contrat de
|
||||
remplacement.
|
||||
- `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`
|
||||
(`toSafeProfile`), auparavant dupliqué dans `auth.service.ts` et
|
||||
`require-auth.ts` — `profile.service.ts` le réutilise aussi.
|
||||
(`toSafeProfile`).
|
||||
|
||||
## Page de connexion / inscription (apps/web)
|
||||
|
||||
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) :
|
||||
enveloppe `fetch` vers l'API (`credentials: "include"`, requis pour que le cookie
|
||||
de session httpOnly parte/revienne — l'API et le front sont sur des origines
|
||||
différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
|
||||
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET /auth/me` au
|
||||
chargement pour restaurer la session depuis le cookie.
|
||||
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes de route
|
||||
(react-router-dom) : `/` exige d'être connecté, `/login` et `/signup` redirigent vers
|
||||
`/` si on l'est déjà.
|
||||
- `src/pages/{Login,Signup,Home}Page.tsx` — validation client instantanée via les
|
||||
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée
|
||||
`apiClient`) : enveloppe `fetch` vers l'API (`credentials: "include"`, requis
|
||||
pour que le cookie de session httpOnly parte/revienne — l'API et le front
|
||||
sont sur des origines différentes). URL configurable via `VITE_API_URL`
|
||||
(voir `.env.example`).
|
||||
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET
|
||||
/auth/me` au chargement pour restaurer la session depuis le cookie ;
|
||||
`deleteAccount()` pour la suppression de compte.
|
||||
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes
|
||||
de route (react-router-dom) : l'espace connecté exige d'être connecté,
|
||||
`/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
|
||||
`ErrorMessageService` (voir ci-dessous).
|
||||
|
||||
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
||||
|
||||
## Accueil, sidebar & sections (apps/web)
|
||||
## Sidebar, planning, recettes & sections (apps/web)
|
||||
|
||||
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar
|
||||
(nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et
|
||||
`<Outlet />` pour la route active — montée une seule fois comme route parente de tout
|
||||
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é).
|
||||
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` —
|
||||
sidebar (nav Planning/Recettes/Liste de courses, sous-menu Paramètres
|
||||
repliable, menu compte en pied) et `<Outlet />` pour la route active — montée
|
||||
une seule fois comme route parente de tout l'espace authentifié (`App.tsx`).
|
||||
|
||||
## Parcours profil — foyer, régime, allergènes (apps/web)
|
||||
- **`/` — `PlanningPage`** : grille complète de la semaine (7 jours × 5
|
||||
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).
|
||||
|
||||
- `src/features/profile/` — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
|
||||
contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien
|
||||
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".
|
||||
Détail complet (pourquoi une seule route parente, le flux d'import détaillé,
|
||||
les composants UI partagés `Dialog`/`Checkbox`/`Radio`/`Tooltip`) :
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
||||
|
||||
**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.
|
||||
## Parcours d'inscription — onboarding (apps/web)
|
||||
|
||||
**Autre piège, même méthode** : `HouseholdPage` initialisait le régime affiché depuis
|
||||
`useAuth().user.dietId` (un instantané jamais rafraîchi après une modification faite
|
||||
directement via `apiClient`, qui ne touche pas `AuthContext`) — revenait à l'ancienne
|
||||
valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la
|
||||
page fetch son propre profil frais (`apiClient.me()`) au montage, et
|
||||
`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
|
||||
régime pour que le reste de l'app reste cohérent aussi.
|
||||
Wizard de 4 écrans lancé une fois juste après l'inscription :
|
||||
`/onboarding/regime` → `/onboarding/foyer` → `/onboarding/sources`
|
||||
(conditionnelle, sautée si aucun foyer n'a été créé/rejoint à l'étape
|
||||
précédente) → `/onboarding/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`. 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/`) : `smoke.cy.ts` + `auth.cy.ts` +
|
||||
`home-planning.cy.ts` + `onboarding.cy.ts` + `household.cy.ts` mockent l'API via
|
||||
`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
|
||||
provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
|
||||
réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une
|
||||
vraie base).
|
||||
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts` et `*.feature` +
|
||||
`@badeball/cypress-cucumber-preprocessor`) : mockent l'API via `cy.intercept`
|
||||
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne
|
||||
pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de
|
||||
l'API est couvert par la suite Mocha d'`apps/api` (contre une vraie base).
|
||||
Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#tests-cypress--cucumber).
|
||||
|
||||
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
|
||||
> Chromium/Electron headless plante au lancement du process GPU
|
||||
|
|
@ -336,14 +443,16 @@ vraie base).
|
|||
> 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
|
||||
> 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 les conteneurs
|
||||
> Docker dont le `CORS_ORIGIN` cible `localhost:8080`, pas `localhost:5173`).
|
||||
> le serveur de dev (`pnpm dev:web` + `pnpm dev:api` en local, pas le conteneur
|
||||
> Docker — voir [Déploiement](#déploiement) — qui sert le frontend buildé, pas le
|
||||
> serveur de dev Vite).
|
||||
|
||||
## Gestion des erreurs (API ↔ web)
|
||||
|
||||
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
|
||||
**numérique** groupée par famille — `4000` validation, `401x` auth, `404x` not
|
||||
found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
|
||||
**numérique** groupée par famille — `4000` validation, `401x` auth, `402x`
|
||||
conflit/état invalide, `403x` autorisation, `404x` not found, `500x` interne
|
||||
— et `ApiErrorResponse`) : l'API renvoie toujours
|
||||
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
|
||||
quel), et le client traduit `code` en libellé français via **i18next**
|
||||
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
|
||||
|
|
@ -353,8 +462,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
|
||||
compris dans les mocks Cypress).
|
||||
|
||||
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
|
||||
[specs/error-handling.md](specs/error-handling.md).
|
||||
Détail complet (schéma, liste des ~19 codes actuels, exemples, comment ajouter
|
||||
un nouveau code d'erreur) : [specs/error-handling.md](specs/error-handling.md).
|
||||
|
||||
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
|
||||
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
|
||||
|
|
@ -370,16 +479,23 @@ voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshare
|
|||
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
|
||||
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`) ;
|
||||
en ajouter une est une question de fichier de locale, pas de code. Détail :
|
||||
en ajouter une est une question de fichier de locale, pas de code. Les
|
||||
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).
|
||||
|
||||
## 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)
|
||||
|
||||
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
|
||||
de test dans `test/auth.test.ts` (Mocha) et le "bruit" (prénom/nom de remplissage)
|
||||
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.
|
||||
de test dans `test/*.test.ts` (Mocha) — jamais de nom/email qui ressemble à une
|
||||
vraie personne en dur dans un fixture.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
NODE_ENV=development
|
||||
PORT=3000
|
||||
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
|
||||
# do not commit the real value.
|
||||
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?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
|
||||
|
||||
# Optional — defaults shown (see src/config/env.ts)
|
||||
# JWT_EXPIRES_IN=7d
|
||||
# AUTH_COOKIE_NAME=session
|
||||
# CORS_ORIGIN=http://localhost:5173
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
|
||||
# do not commit the real value.
|
||||
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?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
|
||||
|
||||
# Optional — defaults shown (see src/config/env.ts)
|
||||
# JWT_EXPIRES_IN=7d
|
||||
# AUTH_COOKIE_NAME=session
|
||||
# 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
|
||||
|
|
|
|||
29
apps/api/.env.test.example
Normal file
29
apps/api/.env.test.example
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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
|
||||
|
|
@ -2,5 +2,6 @@
|
|||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"node-option": ["import=tsx"],
|
||||
"timeout": 10000
|
||||
"timeout": 10000,
|
||||
"require": ["test-support/mocha-root-hooks.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,29 +10,58 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -
|
|||
RUN corepack enable
|
||||
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
|
||||
# `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 . .
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter api build
|
||||
RUN pnpm --filter web build
|
||||
|
||||
# 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)
|
||||
# stay valid — paths must match exactly between build and runtime stages.
|
||||
FROM base AS runtime
|
||||
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/package.json ./package.json
|
||||
COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=build /repo/packages/shared ./packages/shared
|
||||
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/date-tools ./packages/date-tools
|
||||
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/prisma ./apps/api/prisma
|
||||
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
|
||||
EXPOSE 3000
|
||||
# Applies pending migrations before starting — keeps the review environment's
|
||||
# schema in sync automatically, no manual step needed.
|
||||
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/server.js"]
|
||||
# Applies pending migrations, then seeds the reference data (Diet/Category/
|
||||
# Allergy — see src/scripts/seed-runtime.ts) before starting. Both steps are
|
||||
# safe to repeat on every container start: migrate deploy only applies
|
||||
# pending migrations, and the seed upserts by unique name.
|
||||
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/server.js"]
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
// 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" },
|
||||
},
|
||||
};
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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" }
|
||||
"""
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
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"
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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"
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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"
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
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);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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}"`);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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();
|
||||
});
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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,7 +8,6 @@
|
|||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"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:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "prisma db seed",
|
||||
|
|
@ -27,10 +26,10 @@
|
|||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"prisma": "^5.22.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^13.2.1",
|
||||
"@faker-js/faker": "^10.6.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
|
|
@ -39,7 +38,6 @@
|
|||
"chai": "^5.1.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"mocha": "^10.8.2",
|
||||
"prisma": "^5.22.0",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "ThemePreference" AS ENUM ('LIGHT', 'DARK', 'SYSTEM');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_preference" (
|
||||
"user_profile_id" INTEGER NOT NULL,
|
||||
"theme" "ThemePreference" NOT NULL DEFAULT 'SYSTEM',
|
||||
|
||||
CONSTRAINT "user_preference_pkey" PRIMARY KEY ("user_profile_id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_preference" ADD CONSTRAINT "user_preference_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
-- 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;
|
||||
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
-- 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;
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- 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';
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
-- 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;
|
||||
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
-- 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";
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- 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';
|
||||
|
|
@ -0,0 +1,480 @@
|
|||
-- 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';
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
-- 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';
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
-- 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";
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
-- 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");
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "step_tech_step" ADD COLUMN "end" INTEGER,
|
||||
ADD COLUMN "start" INTEGER;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- DropForeignKey
|
||||
ALTER TABLE "tech_step_mapping" DROP CONSTRAINT "tech_step_mapping_tech_step_id_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "tech_step_mapping";
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER,
|
||||
ADD COLUMN "context_start" INTEGER;
|
||||
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- 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");
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
-- 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;
|
||||
|
|
@ -24,22 +24,33 @@ model House {
|
|||
/// member — see `house.service.ts`'s generator for the charset/length.
|
||||
inviteCode String @unique @map("invite_code")
|
||||
|
||||
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
|
||||
members UserProfile[] @relation("HouseMember")
|
||||
plannings Planning[]
|
||||
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
|
||||
members UserProfile[] @relation("HouseMember")
|
||||
plannings Planning[]
|
||||
/// Recipes whose author belonged to this household when they created
|
||||
/// them — see `Recipe.authorHouseId`.
|
||||
authoredRecipes Recipe[]
|
||||
/// Which recipe sources this household sees in its recipe tabs — see `HouseSource`.
|
||||
enabledSources HouseSource[]
|
||||
|
||||
@@map("house")
|
||||
}
|
||||
|
||||
/// `name` is `@unique` — not in the original spec doc, added so the seed
|
||||
/// script (prisma/seed.ts) can `upsert` by name and stay idempotent/safe to
|
||||
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
||||
/// script (prisma/seed.ts) can `upsert` by key and stay idempotent/safe to
|
||||
/// re-run, and so two reference rows can never silently duplicate the same
|
||||
/// regime.
|
||||
/// regime. A stable English camelCase uid (e.g. `"vegetarian"`), not the
|
||||
/// display label — the label itself lives in `apps/web`'s
|
||||
/// `locales/fr/translation.json` under `catalog.diets.<key>` (see
|
||||
/// `reference-seed-data.ts`'s `DIETS`), so it can be edited/translated
|
||||
/// without ever touching this column or the rows that reference it by id.
|
||||
model Diet {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
|
||||
users UserProfile[]
|
||||
users UserProfile[]
|
||||
recipes RecipeDiet[]
|
||||
ingredients IngredientDiet[]
|
||||
|
||||
@@map("diet")
|
||||
}
|
||||
|
|
@ -54,11 +65,13 @@ enum AllergenKind {
|
|||
}
|
||||
|
||||
/// Enumeration-style table, meant to grow over time (e.g. allergy nuances).
|
||||
/// `name` is `@unique` for the same reason as `Diet.name` above. `kind` is
|
||||
/// also not in the original spec doc — see {@link AllergenKind}.
|
||||
/// `key` is `@unique` for the same reason as `Diet.key` above — a stable
|
||||
/// slug (`catalog.allergens.<key>` in `apps/web`'s locale file), not the
|
||||
/// display label. `kind` is also not in the original spec doc — see
|
||||
/// {@link AllergenKind}.
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
key String @unique
|
||||
kind AllergenKind @default(ALLERGY)
|
||||
|
||||
allergies Allergy[]
|
||||
|
|
@ -70,8 +83,9 @@ model Allergy {
|
|||
id Int @id @default(autoincrement())
|
||||
categoryId Int @map("cat_id")
|
||||
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
users UserProfileAllergy[]
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
users UserProfileAllergy[]
|
||||
ingredients IngredientAllergy[]
|
||||
|
||||
@@map("allergy")
|
||||
}
|
||||
|
|
@ -90,18 +104,72 @@ model UserProfile {
|
|||
houseId Int? @map("house_id")
|
||||
dietId Int? @map("diet_id")
|
||||
|
||||
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
|
||||
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
||||
allergies UserProfileAllergy[]
|
||||
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
|
||||
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
||||
allergies UserProfileAllergy[]
|
||||
/// Ingredients this profile personally dislikes — a taste preference, not
|
||||
/// a medical constraint (see {@link UserProfileDislikedIngredient} and
|
||||
/// `allergies` above for the distinct medical list).
|
||||
dislikedIngredients UserProfileDislikedIngredient[]
|
||||
/// Recipes authored by this profile — see `Recipe.authorId`.
|
||||
authoredRecipes Recipe[]
|
||||
/// Recipes this profile has favorited — see {@link RecipeFavorite}.
|
||||
favoriteRecipes RecipeFavorite[]
|
||||
/// Households this profile administers. In practice at most one — a
|
||||
/// profile can only ever belong to (and thus admin) a single household at
|
||||
/// a time — but Prisma models the admin side of a one-to-many FK as a
|
||||
/// list regardless of that real-world cardinality.
|
||||
administeredHouses House[] @relation("HouseAdmin")
|
||||
administeredHouses House[] @relation("HouseAdmin")
|
||||
preferences UserPreference?
|
||||
/// Tech-step corrections this profile has submitted (any profile that can
|
||||
/// view a recipe may correct its tech-step matches, not just its author —
|
||||
/// see `StepTechStepCorrection.correctorId`).
|
||||
techStepCorrections StepTechStepCorrection[]
|
||||
|
||||
@@map("user_profiles")
|
||||
}
|
||||
|
||||
/// Explicit join table for the user_profiles <-> ingredient "disliked"
|
||||
/// association — same shape as `UserProfileAllergy`, but a personal taste
|
||||
/// preference rather than a medical restriction: not surfaced as a safety
|
||||
/// warning, just a reminder on a recipe's detail view (see
|
||||
/// `RecipeView`/`RecipeDetailPanel`, apps/web).
|
||||
model UserProfileDislikedIngredient {
|
||||
userProfileId Int @map("user_profile_id")
|
||||
ingredientId Int @map("ingredient_id")
|
||||
|
||||
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userProfileId, ingredientId])
|
||||
@@map("user_profile_disliked_ingredient")
|
||||
}
|
||||
|
||||
/// Not in the original spec doc — personalization settings (theme for now,
|
||||
/// meant to grow), one row per profile, created on demand (see
|
||||
/// `preferences.service.ts`) rather than at signup — same "absent means the
|
||||
/// default" philosophy as `dietId`/allergies.
|
||||
enum ThemePreference {
|
||||
LIGHT
|
||||
DARK
|
||||
/// Follow the OS/browser preference — the default. Not "no row yet" (that
|
||||
/// case is handled in the service layer) but an explicit choice to track
|
||||
/// the system, distinguishable from a user who hasn't decided yet if this
|
||||
/// model ever needs that distinction.
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
model UserPreference {
|
||||
/// Both the primary key and the FK — a strict 1-1 with UserProfile, no
|
||||
/// separate auto-incrementing id (a profile has at most one preferences row).
|
||||
userProfileId Int @id @map("user_profile_id")
|
||||
theme ThemePreference @default(SYSTEM)
|
||||
|
||||
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_preference")
|
||||
}
|
||||
|
||||
/// Explicit join table for the user_profiles <-> allergy association
|
||||
/// (documented in the spec as a plain many-to-many, no extra fields).
|
||||
model UserProfileAllergy {
|
||||
|
|
@ -137,6 +205,7 @@ model PlanningItem {
|
|||
weekDay String @map("week_day")
|
||||
meal String
|
||||
recipeId Int @map("recipe_id")
|
||||
portions Int
|
||||
|
||||
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id])
|
||||
|
|
@ -148,45 +217,399 @@ model PlanningItem {
|
|||
// Recipes
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Catalog of implemented recipe sources (specific websites/APIs the
|
||||
/// import pipeline knows how to talk to) — one row per adapter registered
|
||||
/// in `apps/api/src/lib/recipe-source-registry.ts`, kept in sync by
|
||||
/// `syncRecipeSources` (`apps/api/src/db/recipe-source-sync.ts`) rather
|
||||
/// than hand-maintained like `DIETS`/`UNITS` (`reference-seed-data.ts`):
|
||||
/// the adapter registry is the actual source of truth for "which sources
|
||||
/// exist", this table just mirrors it so `Recipe.sourceId` has something
|
||||
/// to point at. `key` matches `RecipeSourceAdapter.key` — same stable
|
||||
/// English camelCase uid convention as `Diet.key`/`Unit.key`/`TechStep.key`.
|
||||
/// Empty until a concrete adapter is registered (none exists yet, see
|
||||
/// recipe-source-adapter.ts).
|
||||
model Source {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String?
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
name String
|
||||
url String?
|
||||
/// Whether this is an official API (the site/publisher provides
|
||||
/// structured recipe data itself) or unofficial web scraping (we parse
|
||||
/// HTML the site never committed to a stable shape for) — mirrors
|
||||
/// `RecipeSourceAdapter.official` (recipe-source-adapter.ts), synced the
|
||||
/// same way as `key`/`name`. Surfaced to households picking which
|
||||
/// sources to enable (see `HouseSource`) so scraped content is never
|
||||
/// mistaken for an official feed.
|
||||
official Boolean
|
||||
/// The source's own logo/favicon URL, shown next to its name in
|
||||
/// `SourceSelect` (apps/web) — mirrors `RecipeSourceAdapter.iconUrl`,
|
||||
/// synced the same way as `name`/`official`. `null` if the source has
|
||||
/// none worth showing.
|
||||
iconUrl String? @map("icon_url")
|
||||
|
||||
recipes Recipe[]
|
||||
recipes Recipe[]
|
||||
enabledHouses HouseSource[]
|
||||
|
||||
@@map("sources")
|
||||
}
|
||||
|
||||
model Recipe {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
sourceId Int? @map("source_id")
|
||||
description String?
|
||||
picture String?
|
||||
/// Which sources a household has chosen to see recipes from — opt-in: no
|
||||
/// row means disabled. A newly created household starts with nothing
|
||||
/// enabled (see the household-creation step in the signup wizard, and the
|
||||
/// household settings page for changing this later); every recipe catalog
|
||||
/// tab (`recipe.service.ts`'s `listRecipes`) filters out recipes whose
|
||||
/// `sourceId` isn't in this list for the viewer's household — a
|
||||
/// manually-authored recipe (`sourceId` `null`) is never affected, this
|
||||
/// only ever hides recipes that came from an external source.
|
||||
model HouseSource {
|
||||
houseId Int @map("house_id")
|
||||
sourceId Int @map("source_id")
|
||||
|
||||
house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
|
||||
source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([houseId, sourceId])
|
||||
@@map("house_source")
|
||||
}
|
||||
|
||||
/// Not in the original spec doc — who can *read* a recipe. Controls only
|
||||
/// visibility, never editing: a recipe can only ever be edited/deleted by
|
||||
/// its `author`, whatever this is set to (see `recipe.service.ts`).
|
||||
enum RecipeVisibility {
|
||||
/// Visible to its author only.
|
||||
PERSONAL
|
||||
/// Visible to `authorHouseId`'s members (a snapshot of the author's
|
||||
/// household *at creation time* — see `Recipe.authorHouseId`).
|
||||
HOUSE
|
||||
/// Visible to every signed-in user — the "shared catalog" behavior the
|
||||
/// very first version of this feature shipped with.
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
model Recipe {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
sourceId Int? @map("source_id")
|
||||
/// The item's identifier on `source` (`RecipeSourceListItem.externalId`,
|
||||
/// recipe-source-adapter.ts) — `null` for a manually-authored recipe,
|
||||
/// alongside `sourceId` being `null`. Together with `sourceId`, this is
|
||||
/// what `findImportedExternalIds` (recipe-source-sync.ts) checks against
|
||||
/// to tell an already-imported source item apart from a new one when
|
||||
/// browsing (see `markAlreadyImported`, recipe-source-adapter.ts) — the
|
||||
/// `@@unique([sourceId, externalId])` below is what actually prevents
|
||||
/// importing the same source recipe twice (Postgres treats each `NULL`
|
||||
/// as distinct, so manually-authored recipes never collide with each
|
||||
/// other here).
|
||||
externalId String? @map("external_id")
|
||||
description String?
|
||||
picture String?
|
||||
/// How many portions this recipe yields as written (its ingredient
|
||||
/// quantities/steps assume this count) — distinct from
|
||||
/// `PlanningItem.portions`, which is how many to actually prepare for one
|
||||
/// planning slot and now defaults to this value client-side but is still
|
||||
/// entered/stored independently (a planning slot may scale the recipe
|
||||
/// up/down).
|
||||
portions Int
|
||||
/// Creator — not in the original spec doc, required once recipes carry a
|
||||
/// visibility level (`PERSONAL`/`HOUSE` need someone to scope against).
|
||||
authorId Int @map("author_id")
|
||||
/// The author's household *at the time this recipe was created* — a
|
||||
/// snapshot (same idea as `Planning.houseId`), not a live lookup: it
|
||||
/// doesn't follow the author if they later change household. `null` if
|
||||
/// the author had no household yet.
|
||||
authorHouseId Int? @map("author_house_id")
|
||||
visibility RecipeVisibility @default(PERSONAL)
|
||||
|
||||
author UserProfile @relation(fields: [authorId], references: [id])
|
||||
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
|
||||
source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull)
|
||||
ingredients RecipeIngredient[]
|
||||
steps Step[]
|
||||
planningItems PlanningItem[]
|
||||
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
|
||||
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
|
||||
favoritedBy RecipeFavorite[]
|
||||
diets RecipeDiet[]
|
||||
|
||||
@@unique([sourceId, externalId])
|
||||
@@map("recipe")
|
||||
}
|
||||
|
||||
model Ingredient {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
icon String?
|
||||
alternateRecipeId Int? @map("alternate_recipe")
|
||||
/// Explicit join table for the user_profiles <-> recipe "favorited"
|
||||
/// association — same shape as `UserProfileAllergy`. Per-user, not
|
||||
/// per-household: two members of the same household can favorite different
|
||||
/// recipes independently.
|
||||
model RecipeFavorite {
|
||||
userProfileId Int @map("user_profile_id")
|
||||
recipeId Int @map("recipe_id")
|
||||
|
||||
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
||||
recipes RecipeIngredient[]
|
||||
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userProfileId, recipeId])
|
||||
@@map("recipe_favorite")
|
||||
}
|
||||
|
||||
/// Explicit join table for the recipe <-> diet "associated regime" tags
|
||||
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
|
||||
/// manual reminder set by whoever creates/edits the recipe, not computed
|
||||
/// from its ingredients.
|
||||
model RecipeDiet {
|
||||
recipeId Int @map("recipe_id")
|
||||
dietId Int @map("diet_id")
|
||||
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([recipeId, dietId])
|
||||
@@map("recipe_diet")
|
||||
}
|
||||
|
||||
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
||||
/// script (reference-seed-data.ts) can `upsert` by key and stay
|
||||
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
|
||||
/// stable slug (`catalog.ingredients.<key>` in `apps/web`'s locale file),
|
||||
/// not the display label.
|
||||
/// Ingredients are reference data (like Diet/Allergy): seeded, never
|
||||
/// created/edited/deleted through the API.
|
||||
/// Not in the original spec doc — supermarket-aisle grouping ("rayons") so
|
||||
/// the ingredient picker (apps/web) can offer category browsing, not just
|
||||
/// free-text search: with 400+ reference ingredients, search alone doesn't
|
||||
/// scale to actually *finding* one. Reworked from an earlier, less
|
||||
/// intuitive scheme (cuisine-of-origin categories mixed in with aisle-style
|
||||
/// ones, e.g. a "cuisine italienne" bucket sitting next to "légumes" —
|
||||
/// meant an ingredient's category depended on which one you thought of
|
||||
/// first) into how a French grocery store is actually laid out: 7 aisles,
|
||||
/// each with a couple of {@link IngredientSubcategory} racks for finer
|
||||
/// browsing once "Épicerie sèche" alone would be 100+ items deep. Mirrors
|
||||
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file
|
||||
/// is the single source of truth for which ingredient belongs to which
|
||||
/// (category, subcategory) pair, these enums just give it type-safe
|
||||
/// columns to live in. `@default(dryGoods)` exists only so this
|
||||
/// column can be added `NOT NULL` to a table that may already have rows —
|
||||
/// the seed script corrects every row's real category on the very next
|
||||
/// run, this default is never the intended value for a real ingredient.
|
||||
enum IngredientCategory {
|
||||
/// 🥦 Vegetables, fruits, fresh herbs.
|
||||
freshProduce
|
||||
/// 🥩 Meats, poultry, fish, shellfish & seafood.
|
||||
meatAndSeafood
|
||||
/// 🥫 Starches, legumes, nuts & seeds, and the rest of the dry/tinned
|
||||
/// goods that don't fit any other bucket (dried seaweed, dried
|
||||
/// mushrooms…).
|
||||
dryGoods
|
||||
/// 🍞 Breads and raw dough (uncooked, ready to bake).
|
||||
bakery
|
||||
/// 🧈 Dairy, eggs, plant-based alternatives (plant milks, tofu…).
|
||||
dairyAndCheese
|
||||
/// 🧂 Spices, sauces, seasonings (oils, vinegars, cooking alcohols…).
|
||||
condimentsAndSpices
|
||||
/// 🍳 Prep bases (flours, stocks, water), thickeners (yeasts, starches,
|
||||
/// gelatin), sugars.
|
||||
cookingEssentials
|
||||
}
|
||||
|
||||
/// Finer-grained rack within one {@link IngredientCategory} aisle — see
|
||||
/// that enum's doc comment for why this two-level scheme replaced a flat
|
||||
/// list. Each value belongs to exactly one category by construction (see
|
||||
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS`, not enforced at the
|
||||
/// database level — Postgres enums can't express that relationship, same
|
||||
/// tradeoff already accepted for `IngredientCategory` itself).
|
||||
/// `@default(other)` — same NOT-NULL-migration-safety-net reasoning as
|
||||
/// `IngredientCategory`'s default, never the intended value for a real row.
|
||||
enum IngredientSubcategory {
|
||||
// --- freshProduce ----------------------------------------------------------
|
||||
vegetables
|
||||
fruits
|
||||
freshHerbs
|
||||
// --- meatAndSeafood ----------------------------------------------------------
|
||||
meats
|
||||
poultry
|
||||
fish
|
||||
shellfish
|
||||
// --- dryGoods ----------------------------------------------------------------
|
||||
starches
|
||||
legumes
|
||||
nutsAndSeeds
|
||||
/// Catch-all for dried/tinned pantry items that don't fit the three
|
||||
/// subcategories above — dried seaweed, dried mushrooms, tinned bamboo
|
||||
/// shoots/water chestnuts…
|
||||
other
|
||||
// --- bakery --------------------------------------------------------------
|
||||
breads
|
||||
/// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) —
|
||||
/// distinct from `breads` (already-baked bread).
|
||||
rawDough
|
||||
// --- dairyAndCheese ------------------------------------------------------
|
||||
dairy
|
||||
eggs
|
||||
/// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu.
|
||||
plantBasedAlternatives
|
||||
// --- condimentsAndSpices ---------------------------------------------------
|
||||
spices
|
||||
sauces
|
||||
/// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that
|
||||
/// season rather than form the base of a dish.
|
||||
seasonings
|
||||
// --- cookingEssentials -----------------------------------------------------
|
||||
/// Flours, stocks/broths, canned tomato bases, water — the literal base
|
||||
/// a recipe is built on.
|
||||
bases
|
||||
/// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch,
|
||||
/// gelatin.
|
||||
thickeners
|
||||
sugars
|
||||
}
|
||||
|
||||
/// Generic pictogram *type* for an ingredient — not in the original spec
|
||||
/// doc. Started as a free-text emoji column (one character per ingredient,
|
||||
/// 437 different ones), which the product decision recorded in chat
|
||||
/// rejected as unprofessional/inconsistent. Rather than 437 hand-drawn SVG
|
||||
/// icons (unrealistic), ingredients share a small vocabulary of ~20
|
||||
/// generic shapes grouped by *what kind of thing* they are — a vegetable,
|
||||
/// a bottle of oil, a wedge of cheese — regardless of which specific
|
||||
/// ingredient. `apps/web`'s `features/recipes/ingredient-icons.tsx` maps
|
||||
/// each value to its actual SVG (matching the app's hand-drawn line-icon
|
||||
/// style, never emoji — see that file for the full reasoning and the
|
||||
/// exact `reference-seed-data.ts` assignment per ingredient).
|
||||
/// `@default(JAR)` — same NOT-NULL-migration-safety-net reasoning as
|
||||
/// `IngredientCategory`'s default, never the intended value for a real row.
|
||||
enum IngredientIcon {
|
||||
VEGETABLE
|
||||
FRUIT
|
||||
HERB
|
||||
MEAT
|
||||
POULTRY
|
||||
FISH
|
||||
SHELLFISH
|
||||
GRAIN
|
||||
LEGUME
|
||||
NUT_SEED
|
||||
BREAD
|
||||
DOUGH
|
||||
MILK
|
||||
CHEESE
|
||||
EGG
|
||||
SPROUT
|
||||
SPICE
|
||||
JAR
|
||||
BOTTLE
|
||||
DRINK
|
||||
STOCK_POT
|
||||
SUGAR
|
||||
}
|
||||
|
||||
model Ingredient {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
icon IngredientIcon @default(JAR)
|
||||
category IngredientCategory @default(dryGoods)
|
||||
subcategory IngredientSubcategory @default(other)
|
||||
/// Whether this ingredient is reasonably makeable at home (a burger bun,
|
||||
/// a béchamel) rather than something you'd only ever buy (a raw
|
||||
/// vegetable, a specific cut of meat) — surfaced in the recipe form as a
|
||||
/// badge/link nudging the author to go check the recipe catalog for a
|
||||
/// "make it yourself" recipe (see `apps/web`'s `IngredientRow`/
|
||||
/// `IngredientPicker`). Deliberately just a flag, not a link to a
|
||||
/// specific recipe — replaces an earlier, never-wired-up
|
||||
/// `alternateRecipeId` FK (product decision discussed in chat: no
|
||||
/// ingredient↔recipe linking in the database, the UI only pre-fills the
|
||||
/// catalog's own search with this ingredient's name).
|
||||
reproducible Boolean @default(false)
|
||||
|
||||
recipes RecipeIngredient[]
|
||||
allergies IngredientAllergy[]
|
||||
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
||||
dislikedBy UserProfileDislikedIngredient[]
|
||||
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
|
||||
diets IngredientDiet[]
|
||||
/// Mentions of this ingredient detected in a step's free text alongside a
|
||||
/// technique — see `StepTechStepIngredient`.
|
||||
stepTechSteps StepTechStepIngredient[]
|
||||
|
||||
@@map("ingredients")
|
||||
}
|
||||
|
||||
/// Explicit join table for the ingredients <-> diet regime association —
|
||||
/// which regimes (Végétarien, Végan, Pescétarien…) this ingredient is safe
|
||||
/// for, so the picker (apps/web's `IngredientPicker`/`IngredientRow`) can
|
||||
/// flag e.g. an ingredient as vegan without the user having to open its
|
||||
/// packaging. Seeded by category in `reference-seed-data.ts` (most
|
||||
/// ingredients in a category share the same compatible regimes, with
|
||||
/// per-item overrides for exceptions — meat cuts, dairy, seafood…), same as
|
||||
/// `IngredientAllergy`. Deliberately omits `Omnivore` (every ingredient is
|
||||
/// trivially compatible — storing it would be pure noise) and `Sans gluten`
|
||||
/// (already fully derivable from whether `IngredientAllergy` links this
|
||||
/// ingredient to the `Gluten` allergen — a second, hand-maintained source
|
||||
/// for the same fact would only risk drifting out of sync with it).
|
||||
model IngredientDiet {
|
||||
ingredientId Int @map("ingredient_id")
|
||||
dietId Int @map("diet_id")
|
||||
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([ingredientId, dietId])
|
||||
@@map("ingredient_diet")
|
||||
}
|
||||
|
||||
/// Explicit join table for the ingredients <-> allergy association — not in
|
||||
/// the original spec doc, added so the recipe catalog can surface which
|
||||
/// allergens an ingredient (and by extension a recipe) carries. Same shape
|
||||
/// as `UserProfileAllergy`.
|
||||
model IngredientAllergy {
|
||||
ingredientId Int @map("ingredient_id")
|
||||
allergyId Int @map("allergy_id")
|
||||
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([ingredientId, allergyId])
|
||||
@@map("ingredient_allergy")
|
||||
}
|
||||
|
||||
/// Which physical quantity a {@link Unit} measures — only units of the same
|
||||
/// type are ever mutually convertible via `toBaseFactor` (grams and
|
||||
/// kilograms both measure MASS; a "pincée" and a "gousse" are both COUNT
|
||||
/// but converting between *them* would need per-ingredient data no catalog
|
||||
/// entry alone can provide, so COUNT units just don't convert to each
|
||||
/// other, each stands alone with `toBaseFactor = 1`).
|
||||
enum UnitType {
|
||||
MASS
|
||||
VOLUME
|
||||
COUNT
|
||||
}
|
||||
|
||||
/// `key` is `@unique` — same idempotent-seed/no-duplicate reasoning as
|
||||
/// `Diet.key`. A stable English camelCase uid (e.g. `"tablespoon"`), not the
|
||||
/// display label — the label lives in `apps/web`'s
|
||||
/// `locales/fr/translation.json` under `catalog.units.<key>` (see
|
||||
/// `reference-seed-data.ts`'s `UNITS`).
|
||||
///
|
||||
/// Not in the original spec doc — `RecipeIngredient.unit` used to be free
|
||||
/// text ("g", "grammes", "G"…), which can never be reliably summed/converted
|
||||
/// (a future shopping list can't tell "g" and "grammes" are the same unit).
|
||||
/// This closes that off: `unit` is now a normalized, finite catalog.
|
||||
/// `toBaseFactor` is how many of this type's base unit (gram for MASS,
|
||||
/// milliliter for VOLUME, itself for COUNT) one of this unit equals —
|
||||
/// laying the groundwork for a future conversion feature (e.g. summing
|
||||
/// "500g" + "0.5kg" of the same ingredient into "1kg") without building
|
||||
/// that feature itself yet.
|
||||
model Unit {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
type UnitType
|
||||
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
|
||||
|
||||
recipeIngredients RecipeIngredient[]
|
||||
/// Ingredient mentions detected alongside a technique in a step's free
|
||||
/// text (e.g. "50g" resolved against this `Unit`) — see
|
||||
/// `StepTechStepIngredient`. Distinct from `recipeIngredients` above
|
||||
/// (the recipe's structured ingredient list): a step can mention a
|
||||
/// quantity+unit that was never itself an ingredient list line.
|
||||
stepTechStepIngredients StepTechStepIngredient[]
|
||||
|
||||
@@map("unit")
|
||||
}
|
||||
|
||||
/// recipe <-> ingredients association. The spec documents this as a plain
|
||||
/// many-to-many, but a shopping list / batch-cooking calculation needs a
|
||||
/// quantity per recipe, so this join table carries quantity + unit
|
||||
|
|
@ -195,35 +618,66 @@ model RecipeIngredient {
|
|||
recipeId Int @map("recipe_id")
|
||||
ingredientId Int @map("ingredient_id")
|
||||
quantity Decimal @db.Decimal(10, 2)
|
||||
unit String
|
||||
unitId Int @map("unit_id")
|
||||
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
unit Unit @relation(fields: [unitId], references: [id])
|
||||
|
||||
@@id([recipeId, ingredientId])
|
||||
@@map("recipe_ingredient")
|
||||
}
|
||||
|
||||
/// `key` is `@unique` — same convention as `Diet`/`Unit`: a stable English
|
||||
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
|
||||
/// label lives in `apps/web`'s `locales/fr/translation.json` under
|
||||
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
|
||||
///
|
||||
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
|
||||
/// `TechStepClassifierService`) used to go through a DB-backed
|
||||
/// `TechStepMapping` table of per-locale regex expressions — replaced with
|
||||
/// a spaCy-based model (`services/tech-step-intent-service`) trained from
|
||||
/// in-code data (`tech-step-training-data.ts`) once regexes turned out
|
||||
/// unable to generalize past their own literal vocabulary. Nothing
|
||||
/// queries/edits that matching data at runtime anymore (it only ever feeds
|
||||
/// that service's one-time training pass), so it no longer needs a table
|
||||
/// of its own — this row now only exists to be a stable id/key other
|
||||
/// tables (`StepTechStep`) reference.
|
||||
model TechStep {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
|
||||
steps Step[]
|
||||
mappings TechStepMapping[]
|
||||
steps StepTechStep[]
|
||||
/// Corrections where this technique was the *previous* (possibly wrong)
|
||||
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
||||
correctionsAsPrevious StepTechStepCorrection[] @relation("PreviousTechStep")
|
||||
/// Corrections where this technique was the *corrected* (user-asserted)
|
||||
/// match — see `StepTechStepCorrection.correctedTechStepId`.
|
||||
correctionsAsCorrected StepTechStepCorrection[] @relation("CorrectedTechStep")
|
||||
/// Training-corpus suggestions targeting this technique — see
|
||||
/// `TechStepTrainingSuggestion`.
|
||||
trainingSuggestions TechStepTrainingSuggestion[]
|
||||
|
||||
@@map("tech_step")
|
||||
}
|
||||
|
||||
/// Used by the recipe-import pipeline to auto-detect which technique a raw
|
||||
/// instruction step corresponds to (expression = text pattern, weight = match score).
|
||||
model TechStepMapping {
|
||||
id Int @id @default(autoincrement())
|
||||
techStepId Int @map("tech_step_id")
|
||||
expression String
|
||||
weight Int
|
||||
/// `key` is `@unique`, same bare id+key shape as `TechStep` — no
|
||||
/// categorization taxonomy like `Ingredient` needed yet, and no matching
|
||||
/// data of its own here either: unlike `TechStep` (whose matching synonyms
|
||||
/// used to live in TS and were moved into
|
||||
/// `services/tech-step-intent-service`'s `training_data.py`), this catalog
|
||||
/// was *born* owned by that service (`utensil_vocabulary.py`) since nothing
|
||||
/// pre-existing needed it — this row only exists to be a stable id/key
|
||||
/// `StepTechStepUtensil` references, and to carry a French label
|
||||
/// (`apps/web`'s `catalog.utensils.<key>`, see `reference-seed-data.ts`'s
|
||||
/// `UTENSILS`).
|
||||
model Utensil {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
|
||||
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
||||
steps StepTechStepUtensil[]
|
||||
|
||||
@@map("tech_step_mapping")
|
||||
@@map("utensil")
|
||||
}
|
||||
|
||||
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
||||
|
|
@ -236,10 +690,210 @@ model Step {
|
|||
description String
|
||||
picture String?
|
||||
order Int
|
||||
techStepId Int? @map("tech_step_id")
|
||||
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
techStep TechStep? @relation(fields: [techStepId], references: [id], onDelete: SetNull)
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
techSteps StepTechStep[]
|
||||
/// User-submitted corrections to this step's detected techniques — see
|
||||
/// `StepTechStepCorrection`.
|
||||
corrections StepTechStepCorrection[]
|
||||
|
||||
@@map("step")
|
||||
}
|
||||
|
||||
/// A single step's *ordered sequence* of detected techniques — one
|
||||
/// instruction can genuinely involve more than one (e.g. "Dans une poêle
|
||||
/// chaude, faire chauffer une noix de beurre" is both `preheat` and
|
||||
/// `melt`), which is why this replaced the original single nullable
|
||||
/// `Step.techStepId` FK (per PR review feedback on the first version of
|
||||
/// this feature). `order` is the position within *this step* (0-based, in
|
||||
/// the order `matchTechStepSpans` — `tech-step-matcher.ts` — detected the
|
||||
/// techniques in the description), not a global ordering across different
|
||||
/// steps of the recipe (that's `Step.order`).
|
||||
///
|
||||
/// `start`/`end` are the tight matched *keyword* span within
|
||||
/// `Step.description` (see `TechStepMatch`, `tech-step-matcher.ts`) — what
|
||||
/// the recipe detail view highlights strongly, with a tooltip.
|
||||
/// `contextStart`/`contextEnd` are the wider *clause* the keyword was found
|
||||
/// in (e.g. "Dans une poêle chaude" around a `preheat` keyword of "poêle
|
||||
/// chaude") — always contains `start`/`end` — what the detail view
|
||||
/// highlights more subtly around it, so both "the exact trigger word(s)"
|
||||
/// and "how much of the sentence is about this technique" are visible.
|
||||
/// Nullable, **not backfilled**: adding them `NOT NULL` without a default
|
||||
/// would fail outright against any pre-existing row, the same mistake the
|
||||
/// `ingredient_unit_catalog` migration made against real prod data. A row
|
||||
/// from before a column existed just has no span for it (no highlight)
|
||||
/// until its recipe is next saved, which recomputes every step's
|
||||
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
|
||||
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
|
||||
/// graceful degradation, not a permanent gap.
|
||||
///
|
||||
/// `source` distinguishes a `"manual"` row — written immediately when a
|
||||
/// user submits a `StepTechStepCorrection` that asserts a technique
|
||||
/// (`recipe-tech-step-correction.service.ts`'s `applyManualCorrection`),
|
||||
/// not just recorded as a pending suggestion — from an `"auto"` row the
|
||||
/// classifier itself produced (`tech-step-matcher.ts`). Both kinds coexist
|
||||
/// in the same ordered sequence; the detail view (`apps/web`) renders them
|
||||
/// with a different highlight color so a viewer can tell which is which.
|
||||
/// `backfillTechSteps` (`scripts/backfill-tech-steps.ts`) only ever
|
||||
/// deletes/recreates `"auto"` rows — a `"manual"` row survives a
|
||||
/// classifier/corpus change until a user (or a future moderation feature)
|
||||
/// explicitly changes it again.
|
||||
model StepTechStep {
|
||||
stepId Int @map("step_id")
|
||||
techStepId Int @map("tech_step_id")
|
||||
order Int
|
||||
start Int?
|
||||
end Int?
|
||||
contextStart Int? @map("context_start")
|
||||
contextEnd Int? @map("context_end")
|
||||
source String @default("auto")
|
||||
|
||||
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
||||
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
||||
/// Ingredients mentioned in the same clause as this technique occurrence
|
||||
/// — see `StepTechStepIngredient`.
|
||||
ingredients StepTechStepIngredient[]
|
||||
/// Utensils mentioned in the same clause as this technique occurrence —
|
||||
/// see `StepTechStepUtensil`.
|
||||
utensils StepTechStepUtensil[]
|
||||
|
||||
@@id([stepId, order])
|
||||
@@map("step_tech_step")
|
||||
}
|
||||
|
||||
/// An ingredient mention found in the same *clause* as one `StepTechStep`
|
||||
/// occurrence (`tech-step-matcher.ts`'s `matchTechStepSpans` — clauses are
|
||||
/// already the unit a technique is judged on, see that file's doc comment,
|
||||
/// so "same clause" is the association rule, no dependency-parsing needed).
|
||||
/// `quantity`/`unitId` are best-effort, populated only when a leading
|
||||
/// numeric expression immediately preceding the ingredient mention resolved
|
||||
/// against the `Unit` catalog (`ingredient-matcher.ts`'s
|
||||
/// `findIngredientMentions`) — both `null` when the clause names the
|
||||
/// ingredient with no quantity ("ajouter le sel"). `start`/`end` are the
|
||||
/// ingredient mention's own span in `Step.description`, same `[start, end)`
|
||||
/// convention as `StepTechStep.start`/`end`. `source` mirrors
|
||||
/// `StepTechStep.source` (`"auto"` today, room for a future user
|
||||
/// correction without a shape change).
|
||||
model StepTechStepIngredient {
|
||||
id Int @id @default(autoincrement())
|
||||
stepId Int @map("step_id")
|
||||
techStepOrder Int @map("tech_step_order")
|
||||
ingredientId Int @map("ingredient_id")
|
||||
quantity Decimal? @db.Decimal(10, 2)
|
||||
unitId Int? @map("unit_id")
|
||||
start Int
|
||||
end Int
|
||||
source String @default("auto")
|
||||
|
||||
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
unit Unit? @relation(fields: [unitId], references: [id])
|
||||
|
||||
@@map("step_tech_step_ingredient")
|
||||
}
|
||||
|
||||
/// A utensil mention found in the same clause as one `StepTechStep`
|
||||
/// occurrence — same association rule as `StepTechStepIngredient` (see its
|
||||
/// doc comment). Detected by
|
||||
/// `services/tech-step-intent-service`'s own utensil `PhraseMatcher`
|
||||
/// (`intent_service/utensil_vocabulary.py`), returned alongside technique
|
||||
/// entities in `POST /v1/process` and filtered to this clause's span by
|
||||
/// `tech-step-matcher.ts`.
|
||||
model StepTechStepUtensil {
|
||||
id Int @id @default(autoincrement())
|
||||
stepId Int @map("step_id")
|
||||
techStepOrder Int @map("tech_step_order")
|
||||
utensilId Int @map("utensil_id")
|
||||
start Int
|
||||
end Int
|
||||
source String @default("auto")
|
||||
|
||||
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
|
||||
utensil Utensil @relation(fields: [utensilId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("step_tech_step_utensil")
|
||||
}
|
||||
|
||||
/// One user-submitted correction to a `Step`'s detected techniques —
|
||||
/// captures ADD (a missing technique the classifier didn't find),
|
||||
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
|
||||
/// `previousTechStepId` is the (possibly absent) match being corrected,
|
||||
/// `correctedTechStepId` is what the user asserts instead (absent means
|
||||
/// "no technique belongs here"). Both `null` at once is invalid (nothing
|
||||
/// would have changed) — enforced service-side, not by the schema, same
|
||||
/// posture as other cross-field invariants in this codebase (e.g.
|
||||
/// `RecipeIngredientView`'s no-duplicate-ingredient check).
|
||||
///
|
||||
/// `start`/`end` are the user's selected `[start, end)` span within
|
||||
/// `Step.description` (`String.prototype.slice` convention, same as
|
||||
/// `StepTechStep`) — what they highlighted before assigning a technique to
|
||||
/// it, not necessarily identical to any existing `StepTechStep` span.
|
||||
///
|
||||
/// Never edited/deleted once created (an audit trail of what was actually
|
||||
/// submitted) — only `consumedAt` changes, stamped once
|
||||
/// `services/tech-step-llm-worker` has turned this correction into a
|
||||
/// `TechStepTrainingSuggestion` for a maintainer to review, so the same
|
||||
/// correction isn't proposed twice on the next scheduled run.
|
||||
model StepTechStepCorrection {
|
||||
id Int @id @default(autoincrement())
|
||||
stepId Int @map("step_id")
|
||||
/// Any profile that could *view* the recipe when they submitted this, not
|
||||
/// necessarily its author — see `assertRecipeVisible`,
|
||||
/// `recipe.service.ts`.
|
||||
correctorId Int @map("corrector_id")
|
||||
start Int
|
||||
end Int
|
||||
previousTechStepId Int? @map("previous_tech_step_id")
|
||||
correctedTechStepId Int? @map("corrected_tech_step_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
consumedAt DateTime? @map("consumed_at")
|
||||
|
||||
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
||||
corrector UserProfile @relation(fields: [correctorId], references: [id], onDelete: Cascade)
|
||||
previousTechStep TechStep? @relation("PreviousTechStep", fields: [previousTechStepId], references: [id], onDelete: SetNull)
|
||||
correctedTechStep TechStep? @relation("CorrectedTechStep", fields: [correctedTechStepId], references: [id], onDelete: SetNull)
|
||||
trainingSuggestions TechStepTrainingSuggestion[]
|
||||
|
||||
@@map("step_tech_step_correction")
|
||||
}
|
||||
|
||||
/// A candidate addition to `TECH_STEP_TRAINING_DATA`
|
||||
/// (`tech-step-training-data.ts`), proposed by `services/tech-step-llm-worker`
|
||||
/// from one of two sources (`sourceType`):
|
||||
///
|
||||
/// - `"correction"` — a user's `StepTechStepCorrection`, turned into
|
||||
/// suggested synonyms/utterances by the worker's LLM
|
||||
/// (`transform-corrections` job).
|
||||
/// - `"llm_audit"` — a low-confidence NLP clause on an *existing* recipe the
|
||||
/// worker periodically samples and re-judges with its LLM
|
||||
/// (`audit-low-confidence` job); no `sourceCorrectionId` in this case.
|
||||
///
|
||||
/// Deliberately never auto-applied to `tech-step-training-data.ts` — a
|
||||
/// maintainer reviews `status: "pending"` rows (see
|
||||
/// `list-pending-training-suggestions.ts`) and edits that file by hand,
|
||||
/// same "generated suggestion, human-reviewed source of truth" split as a
|
||||
/// linter's autofix vs. a human-authored diff. `retrain-tech-steps.ts` then
|
||||
/// flips `status` to `"applied"`/`"rejected"` once a maintainer has acted on
|
||||
/// a batch, so the same suggestion isn't reviewed twice.
|
||||
///
|
||||
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
||||
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
||||
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
|
||||
/// proposed once for a human to read, not ids referencing another catalog
|
||||
/// table, so there's nothing for a join table to normalize against.
|
||||
model TechStepTrainingSuggestion {
|
||||
id Int @id @default(autoincrement())
|
||||
techStepId Int @map("tech_step_id")
|
||||
locale String
|
||||
suggestedSynonyms String[] @map("suggested_synonyms")
|
||||
suggestedUtterances String[] @map("suggested_utterances")
|
||||
sourceType String @map("source_type")
|
||||
sourceCorrectionId Int? @map("source_correction_id")
|
||||
status String @default("pending")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
techStep TechStep @relation(fields: [techStepId], references: [id])
|
||||
sourceCorrection StepTechStepCorrection? @relation(fields: [sourceCorrectionId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("tech_step_training_suggestion")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { syncRecipeSources } from "../src/db/recipe-source-sync.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
|
||||
// the rest of the app's config/env plumbing this doesn't need), run via
|
||||
|
|
@ -8,8 +10,10 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
|||
// `prisma migrate reset`. The actual data/logic lives in
|
||||
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
|
||||
const prisma = new PrismaClient();
|
||||
registerAllRecipeSources();
|
||||
|
||||
seedReferenceData(prisma)
|
||||
.then(() => syncRecipeSources(prisma))
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (err) => {
|
||||
console.error(err);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import { errorHandlerService } from "@batch-cooking/error-tools";
|
||||
import { ExpressServer, createErrorMiddleware } from "@batch-cooking/express-tools";
|
||||
import { createErrorMiddleware, ExpressServer } from "@batch-cooking/express-tools";
|
||||
import { ErrorCode } from "@batch-cooking/shared";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { env } from "./config/env.js";
|
||||
import { errorLogger } from "./middlewares/error-logger.js";
|
||||
import { requestLogger } from "./middlewares/request-logger.js";
|
||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||
import { houseRouter } from "./modules/house/house.routes.js";
|
||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||
import { preferencesRouter } from "./modules/preferences/preferences.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 { 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
|
||||
|
|
@ -19,6 +26,12 @@ import { referenceRouter } from "./modules/reference/reference.routes.js";
|
|||
*/
|
||||
export function createServer(): 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);
|
||||
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
||||
|
||||
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||
|
|
@ -27,9 +40,28 @@ export function createServer(): ExpressServer {
|
|||
|
||||
server.mountRouter("/auth", authRouter);
|
||||
server.mountRouter("/house", houseRouter);
|
||||
// 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);
|
||||
server.mountRouter("/planning", planningRouter);
|
||||
server.mountRouter("/preferences", preferencesRouter);
|
||||
server.mountRouter("/profile", profileRouter);
|
||||
server.mountRouter("/recipes", recipeRouter);
|
||||
server.mountRouter("/reference", referenceRouter);
|
||||
server.mountRouter("/shopping-list", shoppingListRouter);
|
||||
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
|
||||
// shared ErrorCode contract, so clients never special-case 404s.
|
||||
|
|
@ -37,10 +69,12 @@ export function createServer(): ExpressServer {
|
|||
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
|
||||
});
|
||||
|
||||
// Final error-handling middleware: every thrown/`next(err)`-ed error in
|
||||
// the app ends up here. All the "what status/body does this error map
|
||||
// to" logic lives in ErrorHandlerService, from @batch-cooking/error-tools
|
||||
// — this stays a thin adapter.
|
||||
// Two error-handling middlewares in a row (Express runs them in
|
||||
// registration order, same as regular middleware) — errorLogger logs the
|
||||
// error, then hands it on (`next(err)`) to the real one: all the "what
|
||||
// status/body does this error map to" logic lives in ErrorHandlerService,
|
||||
// from @batch-cooking/error-tools — this stays a thin adapter.
|
||||
server.setErrorHandler(errorLogger);
|
||||
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
|
||||
|
||||
return server;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
import "dotenv/config";
|
||||
import dotenv from "dotenv";
|
||||
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" });
|
||||
|
||||
/**
|
||||
* Schema for every environment variable the API reads. Parsing (below)
|
||||
* fails fast at startup if something required is missing/invalid, instead
|
||||
|
|
@ -24,6 +36,60 @@ const envSchema = z.object({
|
|||
AUTH_COOKIE_NAME: z.string().default("session"),
|
||||
/** Origin allowed by CORS — must match wherever apps/web is served from. */
|
||||
CORS_ORIGIN: z.string().default("http://localhost:5173"),
|
||||
/**
|
||||
* 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"),
|
||||
});
|
||||
|
||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||
|
|
|
|||
|
|
@ -1,3 +1,22 @@
|
|||
// 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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
90
apps/api/src/db/recipe-source-sync.ts
Normal file
90
apps/api/src/db/recipe-source-sync.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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
102
apps/api/src/lib/logger.service.ts
Normal file
102
apps/api/src/lib/logger.service.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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();
|
||||
466
apps/api/src/lib/recipe-matching/ingredient-matcher.ts
Normal file
466
apps/api/src/lib/recipe-matching/ingredient-matcher.ts
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
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({
|
||||
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
|
||||
}
|
||||
}
|
||||
102
apps/api/src/lib/recipe-matching/intent-service-client.ts
Normal file
102
apps/api/src/lib/recipe-matching/intent-service-client.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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";
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
export interface IntentServiceProcessResult {
|
||||
entities: IntentServiceEntity[];
|
||||
intent: string | null;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return await this._request("/v1/process", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ locale, text }),
|
||||
});
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Single shared instance — stateless, no reason for more than one (same reasoning as `techStepClassifier`/`prisma`). */
|
||||
export const intentServiceClient = new IntentServiceClient();
|
||||
331
apps/api/src/lib/recipe-matching/recipe-translation.ts
Normal file
331
apps/api/src/lib/recipe-matching/recipe-translation.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
263
apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts
Normal file
263
apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* 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: [],
|
||||
},
|
||||
];
|
||||
71
apps/api/src/lib/recipe-matching/tech-step-eval-runner.ts
Normal file
71
apps/api/src/lib/recipe-matching/tech-step-eval-runner.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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);
|
||||
}
|
||||
135
apps/api/src/lib/recipe-matching/tech-step-evaluator.ts
Normal file
135
apps/api/src/lib/recipe-matching/tech-step-evaluator.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* 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 };
|
||||
}
|
||||
618
apps/api/src/lib/recipe-matching/tech-step-matcher.ts
Normal file
618
apps/api/src/lib/recipe-matching/tech-step-matcher.ts
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
import { prisma } from "../../db/prisma.js";
|
||||
import {
|
||||
findIngredientMentions,
|
||||
type IngredientMention,
|
||||
loadIngredientCatalog,
|
||||
loadUnitCatalog,
|
||||
} from "./ingredient-matcher.js";
|
||||
import { 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 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 }];
|
||||
});
|
||||
|
||||
matches.push({
|
||||
techStepId,
|
||||
start: span.start,
|
||||
end: span.end,
|
||||
contextStart: clause.start,
|
||||
contextEnd: clause.end,
|
||||
ingredients,
|
||||
utensils,
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
188
apps/api/src/lib/recipe-sources/recipe-source-adapter.ts
Normal file
188
apps/api/src/lib/recipe-sources/recipe-source-adapter.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
37
apps/api/src/lib/recipe-sources/recipe-source-errors.ts
Normal file
37
apps/api/src/lib/recipe-sources/recipe-source-errors.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* 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";
|
||||
}
|
||||
}
|
||||
56
apps/api/src/lib/recipe-sources/recipe-source-registry.ts
Normal file
56
apps/api/src/lib/recipe-sources/recipe-source-registry.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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();
|
||||
}
|
||||
40
apps/api/src/middlewares/error-logger.ts
Normal file
40
apps/api/src/middlewares/error-logger.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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);
|
||||
}
|
||||
45
apps/api/src/middlewares/request-logger.ts
Normal file
45
apps/api/src/middlewares/request-logger.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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();
|
||||
}
|
||||
50
apps/api/src/middlewares/require-internal-worker.ts
Normal file
50
apps/api/src/middlewares/require-internal-worker.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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,7 +1,7 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import type { CookieOptions, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { env } from "../../config/env.js";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { deleteAccount, login, signup } from "./auth.service.js";
|
||||
|
|
@ -18,8 +18,10 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|||
/** Cookie options shared by every route that sets the session cookie. */
|
||||
const cookieOptions: CookieOptions = {
|
||||
httpOnly: true,
|
||||
// Only require HTTPS in production — local dev/CI serve over plain HTTP.
|
||||
secure: env.NODE_ENV === "production",
|
||||
// Defaults to requiring HTTPS in production, but overridable via
|
||||
// COOKIE_SECURE — see its doc comment in config/env.ts for why this
|
||||
// can't just be `NODE_ENV === "production"`.
|
||||
secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: SEVEN_DAYS_MS,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,28 +35,41 @@ const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
|||
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
||||
*/
|
||||
export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||
if (existing) {
|
||||
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
||||
try {
|
||||
const existing = await prisma.userProfile.findUnique({
|
||||
where: { email: input.email },
|
||||
});
|
||||
if (existing) {
|
||||
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(input.password, hashOptions);
|
||||
|
||||
// No household is created here — it's now an optional step of the
|
||||
// onboarding wizard (create or join one, or skip — see
|
||||
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
|
||||
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
|
||||
const profile = await prisma.userProfile.create({
|
||||
data: {
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
const token = signAuthToken({
|
||||
userProfileId: profile.id,
|
||||
tokenVersion: profile.tokenVersion,
|
||||
});
|
||||
return { profile: toSafeProfile(profile), token };
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which already
|
||||
// logs it, see `error-logger.ts`) is what actually handles it, this
|
||||
// service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(input.password, hashOptions);
|
||||
|
||||
// No household is created here — it's now an optional step of the
|
||||
// onboarding wizard (create or join one, or skip — see
|
||||
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
|
||||
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
|
||||
const profile = await prisma.userProfile.create({
|
||||
data: {
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||
return { profile: toSafeProfile(profile), token };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,15 +87,21 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
|
|||
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
||||
*/
|
||||
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
||||
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
|
||||
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
||||
}
|
||||
try {
|
||||
const profile = await prisma.userProfile.findUnique({
|
||||
where: { id: profileId },
|
||||
});
|
||||
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
||||
}
|
||||
|
||||
if (profile.houseId !== null) {
|
||||
await leaveCurrentHouse(profile.id, profile.houseId);
|
||||
if (profile.houseId !== null) {
|
||||
await leaveCurrentHouse(profile.id, profile.houseId);
|
||||
}
|
||||
await prisma.userProfile.delete({ where: { id: profile.id } });
|
||||
} catch (err) {
|
||||
throw err; // see signup()'s catch comment above
|
||||
}
|
||||
await prisma.userProfile.delete({ where: { id: profile.id } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,12 +112,21 @@ export async function deleteAccount(profileId: number, password: string): Promis
|
|||
* caller can never learn whether a given email has an account.
|
||||
*/
|
||||
export async function login(input: LoginInput): Promise<AuthResult> {
|
||||
const profile = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||
try {
|
||||
const profile = await prisma.userProfile.findUnique({
|
||||
where: { email: input.email },
|
||||
});
|
||||
|
||||
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
||||
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
||||
}
|
||||
|
||||
const token = signAuthToken({
|
||||
userProfileId: profile.id,
|
||||
tokenVersion: profile.tokenVersion,
|
||||
});
|
||||
return { profile: toSafeProfile(profile), token };
|
||||
} catch (err) {
|
||||
throw err; // see signup()'s catch comment above
|
||||
}
|
||||
|
||||
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||
return { profile: toSafeProfile(profile), token };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
ErrorCode,
|
||||
createHouseSchema,
|
||||
ErrorCode,
|
||||
joinHouseSchema,
|
||||
renameHouseSchema,
|
||||
updateHouseSourcesSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
|
|
@ -12,10 +13,12 @@ import {
|
|||
createHouse,
|
||||
deleteHouse,
|
||||
getCurrentHouse,
|
||||
getHouseSourceIds,
|
||||
joinHouse,
|
||||
leaveCurrentHouse,
|
||||
removeMember,
|
||||
renameHouse,
|
||||
updateHouseSources,
|
||||
} from "./house.service.js";
|
||||
|
||||
/** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */
|
||||
|
|
@ -91,6 +94,27 @@ houseRouter.delete(
|
|||
}),
|
||||
);
|
||||
|
||||
/** Which recipe sources the household currently sees in its recipe tabs — the source step of the onboarding wizard and the `/parametres/foyer` settings page both call this. */
|
||||
houseRouter.get(
|
||||
"/current/sources",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||
const sourceIds = await getHouseSourceIds(res.locals.userProfile.houseId);
|
||||
res.status(200).json(sourceIds);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Replaces the household's enabled-source set — same callers as the GET above. */
|
||||
houseRouter.patch(
|
||||
"/current/sources",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = updateHouseSourcesSchema.parse(req.body);
|
||||
const sourceIds = await updateHouseSources(res.locals.userProfile.houseId, input.sourceIds);
|
||||
res.status(200).json(sourceIds);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */
|
||||
houseRouter.delete(
|
||||
"/members/:memberId",
|
||||
|
|
|
|||
|
|
@ -44,10 +44,18 @@ const houseWithMembers = {
|
|||
|
||||
/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */
|
||||
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
|
||||
if (houseId === null) {
|
||||
return null;
|
||||
try {
|
||||
if (houseId === null) {
|
||||
return null;
|
||||
}
|
||||
return toHouseView(await findHouseOrThrow(houseId));
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
return toHouseView(await findHouseOrThrow(houseId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,16 +66,20 @@ export async function getCurrentHouse(houseId: number | null): Promise<HouseView
|
|||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||
*/
|
||||
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
await findHouseOrThrow(houseId);
|
||||
const house = await prisma.house.update({
|
||||
where: { id: houseId },
|
||||
data: { name },
|
||||
include: houseWithMembers,
|
||||
});
|
||||
return toHouseView(house);
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
await findHouseOrThrow(houseId);
|
||||
const house = await prisma.house.update({
|
||||
where: { id: houseId },
|
||||
data: { name },
|
||||
include: houseWithMembers,
|
||||
});
|
||||
return toHouseView(house);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,30 +93,45 @@ export async function createHouse(
|
|||
houseId: number | null,
|
||||
name: string,
|
||||
): Promise<HouseView> {
|
||||
if (houseId !== null) {
|
||||
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
|
||||
}
|
||||
|
||||
// Astronomically unlikely to collide (33^8 possibilities), but retried
|
||||
// rather than assumed — a `@unique` constraint failure is the only fully
|
||||
// reliable way to detect it.
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const house = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.house.create({
|
||||
data: { name, adminId: profileId, inviteCode: generateInviteCode() },
|
||||
});
|
||||
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } });
|
||||
return created;
|
||||
});
|
||||
return getCurrentHouseOrThrow(house.id);
|
||||
} catch (err) {
|
||||
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
|
||||
throw err;
|
||||
try {
|
||||
if (houseId !== null) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.ALREADY_HAS_HOUSE,
|
||||
"Profile already belongs to a household",
|
||||
);
|
||||
}
|
||||
|
||||
// Astronomically unlikely to collide (33^8 possibilities), but retried
|
||||
// rather than assumed — a `@unique` constraint failure is the only fully
|
||||
// reliable way to detect it.
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const house = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.house.create({
|
||||
data: {
|
||||
name,
|
||||
adminId: profileId,
|
||||
inviteCode: generateInviteCode(),
|
||||
},
|
||||
});
|
||||
await tx.userProfile.update({
|
||||
where: { id: profileId },
|
||||
data: { houseId: created.id },
|
||||
});
|
||||
return created;
|
||||
});
|
||||
return await getCurrentHouseOrThrow(house.id);
|
||||
} catch (err) {
|
||||
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to generate a unique invite code after several attempts");
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
throw new Error("Failed to generate a unique invite code after several attempts");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -118,21 +145,32 @@ export async function joinHouse(
|
|||
houseId: number | null,
|
||||
inviteCode: string,
|
||||
): Promise<HouseView> {
|
||||
if (houseId !== null) {
|
||||
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
|
||||
}
|
||||
try {
|
||||
if (houseId !== null) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.ALREADY_HAS_HOUSE,
|
||||
"Profile already belongs to a household",
|
||||
);
|
||||
}
|
||||
|
||||
const house = await prisma.house.findUnique({ where: { inviteCode } });
|
||||
if (!house) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INVITE_CODE_NOT_FOUND,
|
||||
"No household matches this invite code",
|
||||
);
|
||||
}
|
||||
const house = await prisma.house.findUnique({ where: { inviteCode } });
|
||||
if (!house) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INVITE_CODE_NOT_FOUND,
|
||||
"No household matches this invite code",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } });
|
||||
return getCurrentHouseOrThrow(house.id);
|
||||
await prisma.userProfile.update({
|
||||
where: { id: profileId },
|
||||
data: { houseId: house.id },
|
||||
});
|
||||
return await getCurrentHouseOrThrow(house.id);
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -149,28 +187,38 @@ export async function joinHouse(
|
|||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
|
||||
*/
|
||||
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
const remainingMembers = house.members.filter((member) => member.id !== profileId);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userProfile.update({
|
||||
where: { id: profileId },
|
||||
data: { houseId: null },
|
||||
});
|
||||
|
||||
if (house.adminId !== profileId) {
|
||||
return;
|
||||
}
|
||||
if (remainingMembers.length === 0) {
|
||||
await tx.house.delete({ where: { id: house.id } });
|
||||
return;
|
||||
}
|
||||
const nextAdmin = remainingMembers.reduce((oldest, member) =>
|
||||
member.id < oldest.id ? member : oldest,
|
||||
);
|
||||
await tx.house.update({
|
||||
where: { id: house.id },
|
||||
data: { adminId: nextAdmin.id },
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
const remainingMembers = house.members.filter((member) => member.id !== profileId);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } });
|
||||
|
||||
if (house.adminId !== profileId) {
|
||||
return;
|
||||
}
|
||||
if (remainingMembers.length === 0) {
|
||||
await tx.house.delete({ where: { id: house.id } });
|
||||
return;
|
||||
}
|
||||
const nextAdmin = remainingMembers.reduce((oldest, member) =>
|
||||
member.id < oldest.id ? member : oldest,
|
||||
);
|
||||
await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -183,21 +231,32 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
|
|||
* @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
|
||||
*/
|
||||
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
if (house.adminId !== profileId) {
|
||||
throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it");
|
||||
}
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
if (house.adminId !== profileId) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
ErrorCode.NOT_HOUSE_ADMIN,
|
||||
"Only the household's admin can delete it",
|
||||
);
|
||||
}
|
||||
|
||||
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
|
||||
// but clearing it explicitly first keeps the outcome obvious without
|
||||
// relying on that FK behavior being read alongside this function.
|
||||
await prisma.$transaction([
|
||||
prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }),
|
||||
prisma.house.delete({ where: { id: house.id } }),
|
||||
]);
|
||||
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
|
||||
// but clearing it explicitly first keeps the outcome obvious without
|
||||
// relying on that FK behavior being read alongside this function.
|
||||
await prisma.$transaction([
|
||||
prisma.userProfile.updateMany({
|
||||
where: { houseId: house.id },
|
||||
data: { houseId: null },
|
||||
}),
|
||||
prisma.house.delete({ where: { id: house.id } }),
|
||||
]);
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -215,35 +274,115 @@ export async function removeMember(
|
|||
houseId: number | null,
|
||||
targetMemberId: number,
|
||||
): Promise<HouseView> {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
if (house.adminId !== profileId) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
ErrorCode.NOT_HOUSE_ADMIN,
|
||||
"Only the household's admin can remove a member",
|
||||
);
|
||||
}
|
||||
if (targetMemberId === profileId) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
"Use POST /house/leave to remove yourself",
|
||||
);
|
||||
}
|
||||
if (!house.members.some((member) => member.id === targetMemberId)) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
|
||||
}
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
const house = await findHouseOrThrow(houseId);
|
||||
if (house.adminId !== profileId) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
ErrorCode.NOT_HOUSE_ADMIN,
|
||||
"Only the household's admin can remove a member",
|
||||
);
|
||||
}
|
||||
if (targetMemberId === profileId) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
"Use POST /house/leave to remove yourself",
|
||||
);
|
||||
}
|
||||
if (!house.members.some((member) => member.id === targetMemberId)) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
|
||||
}
|
||||
|
||||
await prisma.userProfile.update({ where: { id: targetMemberId }, data: { houseId: null } });
|
||||
return getCurrentHouseOrThrow(house.id);
|
||||
await prisma.userProfile.update({
|
||||
where: { id: targetMemberId },
|
||||
data: { houseId: null },
|
||||
});
|
||||
return await getCurrentHouseOrThrow(house.id);
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current enabled-source ids for a household — an empty array is normal
|
||||
* and is this household's starting state (opt-in: see `HouseSource` in
|
||||
* schema.prisma), not just "no preference set yet".
|
||||
*
|
||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||
*/
|
||||
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
const rows = await prisma.houseSource.findMany({
|
||||
where: { houseId },
|
||||
select: { sourceId: true },
|
||||
});
|
||||
return rows.map((row) => row.sourceId);
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a household's full set of enabled recipe sources (not a merge —
|
||||
* same "replace, not merge" contract as `profile.service.ts`'s
|
||||
* `updateAllergies`). Every recipe-catalog tab (`recipe.service.ts`'s
|
||||
* `listRecipes`) filters against this set — a source left out here simply
|
||||
* never shows its recipes to this household, in any tab.
|
||||
*
|
||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if any `sourceId` doesn't match a reference `Source` row.
|
||||
*/
|
||||
export async function updateHouseSources(
|
||||
houseId: number | null,
|
||||
sourceIds: number[],
|
||||
): Promise<number[]> {
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
if (sourceIds.length > 0) {
|
||||
const found = await prisma.source.findMany({
|
||||
where: { id: { in: sourceIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((source) => source.id));
|
||||
const missing = sourceIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.SOURCE_NOT_FOUND,
|
||||
`Unknown source id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.houseSource.deleteMany({ where: { houseId } }),
|
||||
prisma.houseSource.createMany({
|
||||
data: sourceIds.map((sourceId) => ({ houseId, sourceId })),
|
||||
}),
|
||||
]);
|
||||
|
||||
return sourceIds;
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
|
||||
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
|
||||
return toHouseView(await findHouseOrThrow(houseId));
|
||||
try {
|
||||
return toHouseView(await findHouseOrThrow(houseId));
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
|
||||
|
|
@ -265,12 +404,16 @@ function isUniqueInviteCodeViolation(err: unknown): boolean {
|
|||
* `HOUSE_NOT_FOUND` HttpError.
|
||||
*/
|
||||
async function findHouseOrThrow(houseId: number) {
|
||||
const house = await prisma.house.findUnique({
|
||||
where: { id: houseId },
|
||||
include: houseWithMembers,
|
||||
});
|
||||
if (!house) {
|
||||
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
||||
try {
|
||||
const house = await prisma.house.findUnique({
|
||||
where: { id: houseId },
|
||||
include: houseWithMembers,
|
||||
});
|
||||
if (!house) {
|
||||
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
||||
}
|
||||
return house;
|
||||
} catch (err) {
|
||||
throw err; // see getCurrentHouse()'s catch comment above
|
||||
}
|
||||
return house;
|
||||
}
|
||||
|
|
|
|||
49
apps/api/src/modules/internal/tech-step-worker.routes.ts
Normal file
49
apps/api/src/modules/internal/tech-step-worker.routes.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
auditBatchQuerySchema,
|
||||
submitTrainingSuggestionsSchema,
|
||||
workerBatchQuerySchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
|
||||
import {
|
||||
getAuditBatch,
|
||||
getPendingCorrections,
|
||||
submitTrainingSuggestions,
|
||||
} from "./tech-step-worker.service.js";
|
||||
|
||||
/**
|
||||
* Router mounted at `/internal/tech-steps` in app.ts — every route requires
|
||||
* {@link requireInternalWorker}, never {@link requireAuth}
|
||||
* (`middlewares/require-auth.ts`): this is `services/tech-step-llm-worker`
|
||||
* authenticating as itself, not a user session. See that middleware's own
|
||||
* doc comment for why the two are deliberately separate mechanisms.
|
||||
*/
|
||||
export const techStepWorkerRouter = Router();
|
||||
|
||||
techStepWorkerRouter.get(
|
||||
"/audit-batch",
|
||||
requireInternalWorker,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
const input = auditBatchQuerySchema.parse(req.query);
|
||||
res.status(200).json(await getAuditBatch(input.locale, input.limit));
|
||||
}),
|
||||
);
|
||||
|
||||
techStepWorkerRouter.get(
|
||||
"/pending-corrections",
|
||||
requireInternalWorker,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
const input = workerBatchQuerySchema.parse(req.query);
|
||||
res.status(200).json(await getPendingCorrections(input.limit));
|
||||
}),
|
||||
);
|
||||
|
||||
techStepWorkerRouter.post(
|
||||
"/training-suggestions",
|
||||
requireInternalWorker,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
const input = submitTrainingSuggestionsSchema.parse(req.body);
|
||||
res.status(201).json(await submitTrainingSuggestions(input));
|
||||
}),
|
||||
);
|
||||
201
apps/api/src/modules/internal/tech-step-worker.service.ts
Normal file
201
apps/api/src/modules/internal/tech-step-worker.service.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
ErrorCode,
|
||||
type PendingTechStepCorrectionView,
|
||||
type SubmitTrainingSuggestionsInput,
|
||||
type TechStepAuditClauseView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import {
|
||||
CONFIDENCE_THRESHOLD,
|
||||
techStepClassifier,
|
||||
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
||||
|
||||
/**
|
||||
* Read/write surface `services/tech-step-llm-worker` calls through
|
||||
* `/internal/tech-steps/*` (`tech-step-worker.routes.ts`, guarded by
|
||||
* `requireInternalWorker`) — the worker has no Prisma client or database
|
||||
* credentials of its own (see that service's own README), so every
|
||||
* corrections/audit-sample read and every suggestion write goes through
|
||||
* here rather than the worker touching this schema directly. Keeps
|
||||
* `apps/api` the single owner of the schema/migrations, and keeps the
|
||||
* worker a pure "read some text, run inference, post a suggestion" process
|
||||
* with nothing to keep in sync if the schema changes shape.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How many of the most recently created `Step`s {@link getAuditBatch} scans
|
||||
* per call before filtering down to low-confidence clauses — a fixed
|
||||
* recency-biased sample, not every `Step` in the database, to keep this
|
||||
* endpoint's cost bounded regardless of how large the recipe catalog gets.
|
||||
* Recently-added steps are also the steps most likely to still use
|
||||
* vocabulary the training corpus hasn't caught up with yet, which is
|
||||
* exactly what this audit is for. A smarter sampling strategy (e.g.
|
||||
* weighted by how often a recipe is actually viewed/planned) is future
|
||||
* work, not needed for this feature's first version.
|
||||
*/
|
||||
const AUDIT_SAMPLE_SIZE = 200;
|
||||
|
||||
/**
|
||||
* Every low-confidence clause found across a recency-biased sample of
|
||||
* existing `Step`s (see {@link AUDIT_SAMPLE_SIZE}), for
|
||||
* `services/tech-step-llm-worker`'s `audit-low-confidence` job to get a
|
||||
* second opinion on. "Low-confidence" mirrors exactly what
|
||||
* `TechStepClassifierService._classifyClause` itself distrusts (a clause
|
||||
* with an NER anchor but a classifier score under
|
||||
* {@link CONFIDENCE_THRESHOLD}) — the same clauses that pipeline already
|
||||
* has to fall back to keyword-anchor guessing for, not an arbitrary
|
||||
* separate cutoff.
|
||||
*/
|
||||
export async function getAuditBatch(
|
||||
locale: string,
|
||||
limit: number,
|
||||
): Promise<TechStepAuditClauseView[]> {
|
||||
try {
|
||||
const steps = await prisma.step.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: AUDIT_SAMPLE_SIZE,
|
||||
select: { id: true, recipeId: true, description: true },
|
||||
});
|
||||
|
||||
const results: TechStepAuditClauseView[] = [];
|
||||
for (const step of steps) {
|
||||
if (results.length >= limit) break;
|
||||
const clauses = await techStepClassifier.classifyClauses(step.description, locale);
|
||||
for (const clause of clauses) {
|
||||
if (results.length >= limit) break;
|
||||
const isLowConfidence = clause.anchorUid !== null && clause.score < CONFIDENCE_THRESHOLD;
|
||||
if (!isLowConfidence) continue;
|
||||
results.push({
|
||||
stepId: step.id,
|
||||
recipeId: step.recipeId,
|
||||
clauseText: clause.clauseText,
|
||||
anchorKey: clause.anchorUid,
|
||||
intentKey: clause.intentUid,
|
||||
score: clause.score,
|
||||
locale,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} catch (err) {
|
||||
throw err; // see recipe.service.ts's equivalent catch comment
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `StepTechStepCorrection` not yet turned into a
|
||||
* `TechStepTrainingSuggestion` (`consumedAt IS NULL`), oldest first — a
|
||||
* FIFO queue the worker's `transform-corrections` job drains, `limit` at a
|
||||
* time.
|
||||
*
|
||||
* `correctedTechStepId IS NOT NULL` on top of `consumedAt IS NULL`: a
|
||||
* correction that *removes* a match ("no technique belongs here",
|
||||
* `correctedTechStepId: null` — see `StepTechStepCorrection`'s schema doc
|
||||
* comment) has no technique to propose new positive training data *for*.
|
||||
* Surfacing it here would leave it permanently unconsumable (the worker
|
||||
* has nothing to submit a suggestion for, so it would never stamp
|
||||
* `consumedAt`, and it would keep re-appearing in every future batch
|
||||
* forever) — excluded at the source instead, not filtered/skipped
|
||||
* downstream by the worker.
|
||||
*/
|
||||
export async function getPendingCorrections(
|
||||
limit: number,
|
||||
): Promise<PendingTechStepCorrectionView[]> {
|
||||
try {
|
||||
const corrections = await prisma.stepTechStepCorrection.findMany({
|
||||
where: { consumedAt: null, correctedTechStepId: { not: null } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: limit,
|
||||
include: {
|
||||
step: { select: { id: true, recipeId: true, description: true } },
|
||||
previousTechStep: { select: { key: true } },
|
||||
correctedTechStep: { select: { key: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return corrections.map((correction) => ({
|
||||
id: correction.id,
|
||||
stepId: correction.step.id,
|
||||
recipeId: correction.step.recipeId,
|
||||
clauseText: correction.step.description.slice(correction.start, correction.end),
|
||||
start: correction.start,
|
||||
end: correction.end,
|
||||
previousTechStepKey: correction.previousTechStep?.key ?? null,
|
||||
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err; // see recipe.service.ts's equivalent catch comment
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a batch of `TechStepTrainingSuggestion`s and, for every
|
||||
* suggestion sourced from a correction, stamps that correction's
|
||||
* `consumedAt` in the same transaction — so a worker run that crashes
|
||||
* partway through never leaves a correction consumed with no matching
|
||||
* suggestion, or a suggestion created against a correction still (wrongly)
|
||||
* eligible to be picked up again by the next run.
|
||||
*
|
||||
* @throws {HttpError} `404 TECH_STEP_NOT_FOUND` if any `techStepKey` in the
|
||||
* batch doesn't match a reference `TechStep` — rejects the *whole* batch
|
||||
* rather than skipping the bad entries, on the theory that a worker
|
||||
* sending an unknown key is more likely a version-skew bug (its own
|
||||
* taxonomy copy, `services/tech-step-llm-worker/src/tech-step-taxonomy.ts`,
|
||||
* drifting from this API's `TechStep` catalog) than a one-off it should
|
||||
* silently tolerate.
|
||||
*/
|
||||
export async function submitTrainingSuggestions(
|
||||
input: SubmitTrainingSuggestionsInput,
|
||||
): Promise<{ created: number }> {
|
||||
try {
|
||||
const techStepKeys = [
|
||||
...new Set(input.suggestions.map((suggestion) => suggestion.techStepKey)),
|
||||
];
|
||||
const techSteps = await prisma.techStep.findMany({
|
||||
where: { key: { in: techStepKeys } },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const techStepIdByKey = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
||||
const missingKeys = techStepKeys.filter((key) => !techStepIdByKey.has(key));
|
||||
if (missingKeys.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.TECH_STEP_NOT_FOUND,
|
||||
`Unknown techStepKey(s): ${missingKeys.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const suggestion of input.suggestions) {
|
||||
// Non-null by construction — every key in `input.suggestions` was
|
||||
// just confirmed present in `techStepIdByKey` above (the `missingKeys`
|
||||
// check would have thrown otherwise).
|
||||
const techStepId = techStepIdByKey.get(suggestion.techStepKey);
|
||||
if (techStepId === undefined) continue;
|
||||
|
||||
await tx.techStepTrainingSuggestion.create({
|
||||
data: {
|
||||
techStepId,
|
||||
locale: suggestion.locale,
|
||||
suggestedSynonyms: suggestion.suggestedSynonyms,
|
||||
suggestedUtterances: suggestion.suggestedUtterances,
|
||||
sourceType: suggestion.sourceType,
|
||||
sourceCorrectionId: suggestion.sourceCorrectionId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (suggestion.sourceCorrectionId !== null && suggestion.sourceCorrectionId !== undefined) {
|
||||
await tx.stepTechStepCorrection.update({
|
||||
where: { id: suggestion.sourceCorrectionId },
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { created: input.suggestions.length };
|
||||
} catch (err) {
|
||||
throw err; // see recipe.service.ts's equivalent catch comment
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
|
||||
import { addPlanningItemSchema, ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { getPlanningForDate } from "./planning.service.js";
|
||||
import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js";
|
||||
|
||||
/** Router mounted at `/planning` in app.ts. */
|
||||
export const planningRouter = Router();
|
||||
|
|
@ -34,3 +34,48 @@ planningRouter.get(
|
|||
res.status(200).json(planning);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Parses and validates the `:id` route param shared by every `/items/:id` route below. */
|
||||
function parsePlanningItemId(rawId: string | undefined): number {
|
||||
const id = Number(rawId);
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "id must be an integer");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a recipe to one (day, meal) slot of the authenticated user's
|
||||
* household's planning, creating that week's `Planning` row on the fly if
|
||||
* needed (see {@link addPlanningItem}).
|
||||
*/
|
||||
planningRouter.post(
|
||||
"/items",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = addPlanningItemSchema.parse(req.body);
|
||||
const date = parseDateOnly(input.date);
|
||||
if (date === null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
`Not a real calendar date: ${input.date}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
const item = await addPlanningItem(houseId, viewerId, houseId, date, input);
|
||||
res.status(201).json(item);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Removes one recipe from a planning slot. */
|
||||
planningRouter.delete(
|
||||
"/items/:id",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parsePlanningItemId(req.params.id);
|
||||
await removePlanningItem(id, res.locals.userProfile.houseId);
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { type DateTime, toDateOnly } from "@batch-cooking/date-tools";
|
||||
import type { PlanningView } from "@batch-cooking/shared";
|
||||
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
||||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
type AddPlanningItemInput,
|
||||
ErrorCode,
|
||||
type PlanningItemView,
|
||||
type PlanningView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { assertRecipeVisible } from "../recipe/recipe.service.js";
|
||||
|
||||
/**
|
||||
* Finds the household's planning that covers `date` and shapes it into a
|
||||
|
|
@ -12,54 +19,175 @@ import { prisma } from "../../db/prisma.js";
|
|||
* Returns `null` for two distinct, both entirely normal states — a
|
||||
* `houseId` of `null` (the profile has no household yet — households are no
|
||||
* longer created automatically at signup, see `auth.service.ts`) and "no
|
||||
* planning row covers this date" (the expected case until planning
|
||||
* creation is built) — neither is an error, so both collapse to the same
|
||||
* "nothing to show yet" result rather than throwing.
|
||||
* planning row covers this date yet" (e.g. a week nobody has added a recipe
|
||||
* to via {@link addPlanningItem}) — neither is an error, so both collapse
|
||||
* to the same "nothing to show yet" result rather than throwing.
|
||||
*/
|
||||
export async function getPlanningForDate(
|
||||
houseId: number | null,
|
||||
date: DateTime,
|
||||
): Promise<PlanningView | null> {
|
||||
if (houseId === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (houseId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
||||
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
||||
// how Postgres stores/returns them, regardless of the server's local
|
||||
// timezone.
|
||||
const dateOnly = toDateOnly(date).toJSDate();
|
||||
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
||||
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
||||
// how Postgres stores/returns them, regardless of the server's local
|
||||
// timezone.
|
||||
const dateOnly = toDateOnly(date).toJSDate();
|
||||
|
||||
const planning = await prisma.planning.findFirst({
|
||||
where: {
|
||||
houseId,
|
||||
startDate: { lte: dateOnly },
|
||||
finishDate: { gte: dateOnly },
|
||||
},
|
||||
// A household should never have two plannings covering the same day,
|
||||
// but nothing in the schema enforces that yet — pick the most recently
|
||||
// started one rather than letting the query fail if it ever happens.
|
||||
orderBy: { startDate: "desc" },
|
||||
include: {
|
||||
items: {
|
||||
include: { recipe: { select: { id: true, name: true } } },
|
||||
const planning = await prisma.planning.findFirst({
|
||||
where: {
|
||||
houseId,
|
||||
startDate: { lte: dateOnly },
|
||||
finishDate: { gte: dateOnly },
|
||||
},
|
||||
},
|
||||
});
|
||||
// A household should never have two plannings covering the same day,
|
||||
// but nothing in the schema enforces that yet — pick the most recently
|
||||
// started one rather than letting the query fail if it ever happens.
|
||||
orderBy: { startDate: "desc" },
|
||||
include: {
|
||||
items: {
|
||||
include: { recipe: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!planning) {
|
||||
return null;
|
||||
if (!planning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: planning.id,
|
||||
startDate: planning.startDate.toISOString(),
|
||||
finishDate: planning.finishDate.toISOString(),
|
||||
items: planning.items.map((item) => ({
|
||||
id: item.id,
|
||||
weekDay: item.weekDay,
|
||||
meal: item.meal,
|
||||
portions: item.portions,
|
||||
recipe: item.recipe,
|
||||
})),
|
||||
};
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: planning.id,
|
||||
startDate: planning.startDate.toISOString(),
|
||||
finishDate: planning.finishDate.toISOString(),
|
||||
items: planning.items.map((item) => ({
|
||||
/**
|
||||
* Finds the household's `Planning` covering the week `weekStart` (a Monday)
|
||||
* starts, creating it on the fly (spanning the full Monday-to-Sunday week)
|
||||
* if none exists yet. Matched by exact `startDate`, not a covering range
|
||||
* like {@link getPlanningForDate} — that query is for "what covers today",
|
||||
* this one is for "the row `addPlanningItem` should attach to", so it needs
|
||||
* to land on the same row a second add to the same week would reuse rather
|
||||
* than risk matching some other overlapping planning.
|
||||
*
|
||||
* Not wrapped in a transaction with the `findFirst` — no unique constraint
|
||||
* exists on `(houseId, startDate)` (see {@link getPlanningForDate}'s doc
|
||||
* comment on the same gap), so two concurrent first-adds to an empty week
|
||||
* could each create their own `Planning` row. Accepted at this project's
|
||||
* scale rather than adding a migration + retry-on-conflict loop for it.
|
||||
*/
|
||||
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
|
||||
try {
|
||||
const startDate = weekStart.toJSDate();
|
||||
const existing = await prisma.planning.findFirst({
|
||||
where: { houseId, startDate },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return await prisma.planning.create({
|
||||
data: {
|
||||
houseId,
|
||||
startDate,
|
||||
finishDate: weekStart.plus({ days: 6 }).toJSDate(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a recipe to one (day, meal) slot of `houseId`'s planning for the
|
||||
* week containing `date`, creating that week's `Planning` row first if it
|
||||
* doesn't exist yet (see {@link findOrCreatePlanningForWeek}) — this is the
|
||||
* only place a `Planning` row gets created at all today, there's no
|
||||
* separate "create an empty planning" action. `date` is a caller-parsed
|
||||
* `DateTime` (see `planning.routes.ts`, which validates `input.date` the
|
||||
* same way `GET /planning` validates its own `?date=`, before calling
|
||||
* here) rather than the raw `input.date` string — only its week matters,
|
||||
* not the exact day.
|
||||
*
|
||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null` (the caller has no household).
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `input.recipeId` doesn't match a recipe visible to `viewerId` — see `recipe.service.ts`'s `assertRecipeVisible`.
|
||||
*/
|
||||
export async function addPlanningItem(
|
||||
houseId: number | null,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
date: DateTime,
|
||||
input: AddPlanningItemInput,
|
||||
): Promise<PlanningItemView> {
|
||||
try {
|
||||
if (houseId === null) {
|
||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||
}
|
||||
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
|
||||
|
||||
const weekStart = getWeekStart(toDateOnly(date));
|
||||
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
||||
|
||||
const item = await prisma.planningItem.create({
|
||||
data: {
|
||||
planningId: planning.id,
|
||||
weekDay: input.weekDay,
|
||||
meal: input.meal,
|
||||
recipeId: input.recipeId,
|
||||
portions: input.portions,
|
||||
},
|
||||
include: { recipe: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
weekDay: item.weekDay,
|
||||
meal: item.meal,
|
||||
portions: item.portions,
|
||||
recipe: item.recipe,
|
||||
})),
|
||||
};
|
||||
};
|
||||
} catch (err) {
|
||||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one planning item outright. The parent `Planning` row is left in
|
||||
* place even if this was its last item — an empty planning is a normal,
|
||||
* already-handled state on the read side (`getPlanningForDate`'s grid just
|
||||
* shows every slot's "+"), not worth a cleanup pass here.
|
||||
*
|
||||
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
|
||||
*/
|
||||
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
|
||||
try {
|
||||
const item = await prisma.planningItem.findUnique({
|
||||
where: { id },
|
||||
include: { planning: true },
|
||||
});
|
||||
if (!item || houseId === null || item.planning.houseId !== houseId) {
|
||||
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
|
||||
}
|
||||
await prisma.planningItem.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
apps/api/src/modules/preferences/preferences.routes.ts
Normal file
27
apps/api/src/modules/preferences/preferences.routes.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { updatePreferencesSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { getPreferences, updatePreferences } from "./preferences.service.js";
|
||||
|
||||
/** Router mounted at `/preferences` in app.ts. Every route requires a session — this is the authenticated user's own preferences. */
|
||||
export const preferencesRouter = Router();
|
||||
|
||||
preferencesRouter.get(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||
const preferences = await getPreferences(res.locals.userProfile.id);
|
||||
res.status(200).json(preferences);
|
||||
}),
|
||||
);
|
||||
|
||||
preferencesRouter.patch(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = updatePreferencesSchema.parse(req.body);
|
||||
const preferences = await updatePreferences(res.locals.userProfile.id, input.theme);
|
||||
res.status(200).json(preferences);
|
||||
}),
|
||||
);
|
||||
45
apps/api/src/modules/preferences/preferences.service.ts
Normal file
45
apps/api/src/modules/preferences/preferences.service.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { PreferencesView, ThemePreference } from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
|
||||
/**
|
||||
* A profile's personalization preferences. `SYSTEM` (the schema default)
|
||||
* is returned both when a row already says so *and* when there's no row
|
||||
* yet at all — same "absent means the default" philosophy as
|
||||
* `dietId`/allergies elsewhere in `profile.service.ts`, no row is created
|
||||
* just to read it.
|
||||
*/
|
||||
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
||||
try {
|
||||
const preferences = await prisma.userPreference.findUnique({
|
||||
where: { userProfileId },
|
||||
});
|
||||
return { theme: preferences?.theme ?? "SYSTEM" };
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a profile's theme preference, creating its preferences row on first
|
||||
* write (an `upsert` rather than requiring a separate "create" step — a
|
||||
* profile never needs to explicitly initialize this row before using it).
|
||||
*/
|
||||
export async function updatePreferences(
|
||||
userProfileId: number,
|
||||
theme: ThemePreference,
|
||||
): Promise<PreferencesView> {
|
||||
try {
|
||||
const preferences = await prisma.userPreference.upsert({
|
||||
where: { userProfileId },
|
||||
create: { userProfileId, theme },
|
||||
update: { theme },
|
||||
});
|
||||
return { theme: preferences.theme };
|
||||
} catch (err) {
|
||||
throw err; // see getPreferences()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,18 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { updateAllergiesSchema, updateDietSchema } from "@batch-cooking/shared";
|
||||
import {
|
||||
updateAllergiesSchema,
|
||||
updateDietSchema,
|
||||
updateDislikedIngredientsSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { getAllergyIds, updateAllergies, updateDiet } from "./profile.service.js";
|
||||
import {
|
||||
getAllergyIds,
|
||||
getDislikedIngredientIds,
|
||||
updateAllergies,
|
||||
updateDiet,
|
||||
updateDislikedIngredients,
|
||||
} from "./profile.service.js";
|
||||
|
||||
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
|
||||
export const profileRouter = Router();
|
||||
|
|
@ -37,3 +47,26 @@ profileRouter.patch(
|
|||
res.status(200).json(allergyIds);
|
||||
}),
|
||||
);
|
||||
|
||||
profileRouter.get(
|
||||
"/disliked-ingredients",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||
const ids = await getDislikedIngredientIds(res.locals.userProfile.id);
|
||||
res.status(200).json(ids);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Personal taste preference — distinct from `/allergies`, which is medical. Managed from `/parametres/preferences` (see `PreferencesPage.tsx`). */
|
||||
profileRouter.patch(
|
||||
"/disliked-ingredients",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = updateDislikedIngredientsSchema.parse(req.body);
|
||||
const ids = await updateDislikedIngredients(
|
||||
res.locals.userProfile.id,
|
||||
input.dislikedIngredientIds,
|
||||
);
|
||||
res.status(200).json(ids);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,27 +14,39 @@ export async function updateDiet(
|
|||
userProfileId: number,
|
||||
dietId: number | null,
|
||||
): Promise<SafeUserProfile> {
|
||||
if (dietId !== null) {
|
||||
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
|
||||
if (!diet) {
|
||||
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
|
||||
try {
|
||||
if (dietId !== null) {
|
||||
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
|
||||
if (!diet) {
|
||||
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const profile = await prisma.userProfile.update({
|
||||
where: { id: userProfileId },
|
||||
data: { dietId },
|
||||
});
|
||||
return toSafeProfile(profile);
|
||||
const profile = await prisma.userProfile.update({
|
||||
where: { id: userProfileId },
|
||||
data: { dietId },
|
||||
});
|
||||
return toSafeProfile(profile);
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */
|
||||
export async function getAllergyIds(userProfileId: number): Promise<number[]> {
|
||||
const rows = await prisma.userProfileAllergy.findMany({
|
||||
where: { userProfileId },
|
||||
select: { allergyId: true },
|
||||
});
|
||||
return rows.map((row) => row.allergyId);
|
||||
try {
|
||||
const rows = await prisma.userProfileAllergy.findMany({
|
||||
where: { userProfileId },
|
||||
select: { allergyId: true },
|
||||
});
|
||||
return rows.map((row) => row.allergyId);
|
||||
} catch (err) {
|
||||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -49,28 +61,91 @@ export async function updateAllergies(
|
|||
userProfileId: number,
|
||||
allergyIds: number[],
|
||||
): Promise<number[]> {
|
||||
if (allergyIds.length > 0) {
|
||||
const found = await prisma.allergy.findMany({
|
||||
where: { id: { in: allergyIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((allergy) => allergy.id));
|
||||
const missing = allergyIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.ALLERGY_NOT_FOUND,
|
||||
`Unknown allergy id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
try {
|
||||
if (allergyIds.length > 0) {
|
||||
const found = await prisma.allergy.findMany({
|
||||
where: { id: { in: allergyIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((allergy) => allergy.id));
|
||||
const missing = allergyIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.ALLERGY_NOT_FOUND,
|
||||
`Unknown allergy id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }),
|
||||
prisma.userProfileAllergy.createMany({
|
||||
data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })),
|
||||
}),
|
||||
]);
|
||||
|
||||
return allergyIds;
|
||||
} catch (err) {
|
||||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */
|
||||
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
|
||||
try {
|
||||
const rows = await prisma.userProfileDislikedIngredient.findMany({
|
||||
where: { userProfileId },
|
||||
select: { ingredientId: true },
|
||||
});
|
||||
return rows.map((row) => row.ingredientId);
|
||||
} catch (err) {
|
||||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a profile's full disliked-ingredient set (not a merge — the
|
||||
* caller sends the complete list every time, same contract as
|
||||
* {@link updateAllergies}).
|
||||
*
|
||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference `Ingredient` row.
|
||||
*/
|
||||
export async function updateDislikedIngredients(
|
||||
userProfileId: number,
|
||||
dislikedIngredientIds: number[],
|
||||
): Promise<number[]> {
|
||||
try {
|
||||
if (dislikedIngredientIds.length > 0) {
|
||||
const found = await prisma.ingredient.findMany({
|
||||
where: { id: { in: dislikedIngredientIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||
`Unknown ingredient id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.userProfileDislikedIngredient.deleteMany({
|
||||
where: { userProfileId },
|
||||
}),
|
||||
prisma.userProfileDislikedIngredient.createMany({
|
||||
data: dislikedIngredientIds.map((ingredientId) => ({
|
||||
userProfileId,
|
||||
ingredientId,
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
|
||||
return dislikedIngredientIds;
|
||||
} catch (err) {
|
||||
throw err; // see updateDiet()'s catch comment above
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }),
|
||||
prisma.userProfileAllergy.createMany({
|
||||
data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })),
|
||||
}),
|
||||
]);
|
||||
|
||||
return allergyIds;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,506 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
ErrorCode,
|
||||
type StepTechStepCorrectionView,
|
||||
type SubmitTechStepCorrectionInput,
|
||||
type SubmitTechStepCorrectionResult,
|
||||
} from "@batch-cooking/shared";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
|
||||
|
||||
/**
|
||||
* User-submitted corrections to a step's detected techniques
|
||||
* (`StepTechStepCorrection` in schema.prisma) — kept in its own module
|
||||
* rather than folded into `recipe.service.ts`, same "one file per concern"
|
||||
* split that file itself follows for `tech-step-matcher.ts`. Deliberately
|
||||
* open to *any* viewer who can see the recipe, not just its author (unlike
|
||||
* every write path in `recipe.service.ts`, which uses `assertIsAuthor`) —
|
||||
* correcting a mislabeled technique isn't editing the recipe's own
|
||||
* content, and restricting it to authors would starve the training-data
|
||||
* feedback loop (`services/tech-step-llm-worker`) of the volume it needs.
|
||||
*/
|
||||
|
||||
type CorrectionWithTechSteps = Prisma.StepTechStepCorrectionGetPayload<{
|
||||
include: { previousTechStep: true; correctedTechStep: true };
|
||||
}>;
|
||||
|
||||
const correctionInclude = {
|
||||
previousTechStep: true,
|
||||
correctedTechStep: true,
|
||||
} satisfies Prisma.StepTechStepCorrectionInclude;
|
||||
|
||||
/**
|
||||
* Loads `stepId`'s current `description` length (the only thing a
|
||||
* correction needs from the step itself), or throws — `404 STEP_NOT_FOUND`
|
||||
* if no such step exists, or if it exists but doesn't belong to `recipeId`
|
||||
* (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a
|
||||
* request naming a real step under the wrong recipe should look identical
|
||||
* to naming one that doesn't exist, same "don't leak which part was wrong"
|
||||
* posture `assertRecipeVisible` already has for visibility). Otherwise
|
||||
* whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`,
|
||||
* never `403`) if the recipe exists but isn't visible to the viewer.
|
||||
*/
|
||||
async function loadVisibleStepOrThrow(
|
||||
recipeId: number,
|
||||
stepId: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<{ id: number; descriptionLength: number }> {
|
||||
try {
|
||||
const step = await prisma.step.findUnique({
|
||||
where: { id: stepId },
|
||||
select: { id: true, recipeId: true, description: true },
|
||||
});
|
||||
if (!step || step.recipeId !== recipeId) {
|
||||
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
|
||||
}
|
||||
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
|
||||
return { id: step.id, descriptionLength: step.description.length };
|
||||
} catch (err) {
|
||||
throw err; // see recipe.service.ts's equivalent catch comment
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 TECH_STEP_NOT_FOUND` if any id in `ids` doesn't match a reference `TechStep` row — same shape as `recipe.service.ts`'s `assertIngredientsExist`/`assertUnitsExist` for the recipe payload's own reference ids. */
|
||||
async function assertTechStepsExist(ids: number[]): Promise<void> {
|
||||
try {
|
||||
if (ids.length === 0) return;
|
||||
const found = await prisma.techStep.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((techStep) => techStep.id));
|
||||
const missing = ids.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.TECH_STEP_NOT_FOUND,
|
||||
`TechStep ids not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 INGREDIENT_NOT_FOUND` if any id in `ids` doesn't match a reference `Ingredient` row — same shape as {@link assertTechStepsExist}, checking `input.ingredients[].ingredientId` instead. */
|
||||
async function assertIngredientsExist(ids: number[]): Promise<void> {
|
||||
try {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (uniqueIds.length === 0) return;
|
||||
const found = await prisma.ingredient.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||
`Ingredient ids not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 UNIT_NOT_FOUND` if any id in `ids` doesn't match a reference `Unit` row — same shape as {@link assertIngredientsExist}, checking `input.ingredients[].unitId` instead. */
|
||||
async function assertUnitsExist(ids: number[]): Promise<void> {
|
||||
try {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (uniqueIds.length === 0) return;
|
||||
const found = await prisma.unit.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((unit) => unit.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.UNIT_NOT_FOUND,
|
||||
`Unit ids not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 UTENSIL_NOT_FOUND` if any id in `ids` doesn't match a reference `Utensil` row — same shape as {@link assertIngredientsExist}, checking `input.utensils[].utensilId` instead. */
|
||||
async function assertUtensilsExist(ids: number[]): Promise<void> {
|
||||
try {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (uniqueIds.length === 0) return;
|
||||
const found = await prisma.utensil.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((utensil) => utensil.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.UTENSIL_NOT_FOUND,
|
||||
`Utensil ids not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
|
||||
* ascending `start` (nulls-still-possible legacy rows, see that model's
|
||||
* schema doc comment, sort last) — the dense, reading-order 0-based
|
||||
* sequence `@@id([stepId, order])` requires, regardless of whether a
|
||||
* caller just inserted, updated, or deleted a row. Simpler and less
|
||||
* error-prone than shifting only the affected neighbors' `order` by hand.
|
||||
*
|
||||
* Two passes, through a disjoint negative range first: updating straight
|
||||
* into the final 0..N-1 positions in one pass risks a transient
|
||||
* `(stepId, order)` collision (e.g. the row destined for `order: 0` isn't
|
||||
* necessarily the one already sitting there) — `order` is always `>= 0`
|
||||
* in real usage, so a negative range can never collide with a live row.
|
||||
*
|
||||
* Exported for `scripts/backfill-tech-steps.ts` to reuse after it
|
||||
* recomputes just the `"auto"` subset of a step's rows, so the combined
|
||||
* `"auto"` + `"manual"` sequence still ends up in one coherent
|
||||
* reading-order.
|
||||
*/
|
||||
export async function renumberStepTechSteps(
|
||||
tx: Prisma.TransactionClient,
|
||||
stepId: number,
|
||||
): Promise<void> {
|
||||
const rows = await tx.stepTechStep.findMany({ where: { stepId } });
|
||||
const sorted = [...rows].sort(
|
||||
(a, b) => (a.start ?? Number.POSITIVE_INFINITY) - (b.start ?? Number.POSITIVE_INFINITY),
|
||||
);
|
||||
for (const [index, row] of sorted.entries()) {
|
||||
await tx.stepTechStep.update({
|
||||
where: { stepId_order: { stepId, order: row.order } },
|
||||
data: { order: -(index + 1) },
|
||||
});
|
||||
}
|
||||
for (const [index] of sorted.entries()) {
|
||||
await tx.stepTechStep.update({
|
||||
where: { stepId_order: { stepId, order: -(index + 1) } },
|
||||
data: { order: index },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** One ingredient/utensil mention the viewer themselves selected, ready to persist — see {@link applyManualCorrection}'s own doc comment for the "manual replaces all" semantics these are written under. */
|
||||
interface ManualIngredientMention {
|
||||
ingredientId: number;
|
||||
quantity: number | null;
|
||||
unitId: number | null;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
interface ManualUtensilMention {
|
||||
utensilId: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
|
||||
* sequence, immediately — not just recorded as a pending suggestion for
|
||||
* `services/tech-step-llm-worker` to eventually process (see
|
||||
* `StepTechStepCorrection`'s schema doc comment; this is *in addition to*
|
||||
* that offline feedback loop, not instead of it). `previousTechStepId`/
|
||||
* `correctedTechStepId` mean exactly what they do on
|
||||
* `StepTechStepCorrection` itself (`SubmitTechStepCorrectionInput`'s doc
|
||||
* comment, `packages/shared`):
|
||||
*
|
||||
* - `correctedTechStepId` set (add or relabel): a `"manual"` row is
|
||||
* written at the correction's own `[start, end)` — updating the
|
||||
* existing entry in place when one matching `previousTechStepId`
|
||||
* overlaps this span, otherwise inserting a new one. No `contextStart`/
|
||||
* `contextEnd` — a correction only ever carries the tight span the user
|
||||
* themselves selected/clicked, nothing wider to highlight around it.
|
||||
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
|
||||
* matching existing entry is deleted outright (cascading away any
|
||||
* ingredient/utensil metadata attached to it, auto or manual — nothing
|
||||
* left to attach metadata to once the technique itself is gone). A
|
||||
* no-op if none matches (nothing to remove).
|
||||
*
|
||||
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
|
||||
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
|
||||
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
|
||||
* this occurrence — `source: "auto"` (the classifier's own detection) and
|
||||
* any earlier `"manual"` set alike — with the newly-submitted one. This is
|
||||
* "le manuel remplace tout" (confirmed with the user): the resolved
|
||||
* `order` this technique ends up at (whichever branch above produced it)
|
||||
* is the same `techStepOrder` both metadata tables key on, so the same
|
||||
* `deleteMany` + `createMany` pair below is correct whether this call just
|
||||
* updated an existing row (which may already carry auto-detected
|
||||
* metadata) or created a brand new one (nothing to delete yet — a no-op
|
||||
* `deleteMany`, not a special case).
|
||||
*
|
||||
* Runs inside the same transaction {@link submitTechStepCorrection} uses
|
||||
* for the audit-trail insert, so a request never leaves any of these
|
||||
* effects (the permanent correction record, the live sequence change, the
|
||||
* metadata replacement) only partially applied.
|
||||
*/
|
||||
async function applyManualCorrection(
|
||||
tx: Prisma.TransactionClient,
|
||||
stepId: number,
|
||||
span: { start: number; end: number },
|
||||
previousTechStepId: number | null,
|
||||
correctedTechStepId: number | null,
|
||||
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
|
||||
): Promise<void> {
|
||||
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
|
||||
|
||||
const target =
|
||||
previousTechStepId !== null
|
||||
? existing.find(
|
||||
(row) =>
|
||||
row.techStepId === previousTechStepId &&
|
||||
row.start !== null &&
|
||||
row.end !== null &&
|
||||
row.start < span.end &&
|
||||
span.start < row.end,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (correctedTechStepId !== null) {
|
||||
const order = target
|
||||
? target.order
|
||||
: existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
|
||||
if (target) {
|
||||
await tx.stepTechStep.update({
|
||||
where: { stepId_order: { stepId, order } },
|
||||
data: {
|
||||
techStepId: correctedTechStepId,
|
||||
start: span.start,
|
||||
end: span.end,
|
||||
contextStart: null,
|
||||
contextEnd: null,
|
||||
source: "manual",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.stepTechStep.create({
|
||||
data: {
|
||||
stepId,
|
||||
techStepId: correctedTechStepId,
|
||||
order,
|
||||
start: span.start,
|
||||
end: span.end,
|
||||
source: "manual",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (metadata !== undefined) {
|
||||
await tx.stepTechStepIngredient.deleteMany({ where: { stepId, techStepOrder: order } });
|
||||
await tx.stepTechStepUtensil.deleteMany({ where: { stepId, techStepOrder: order } });
|
||||
if (metadata.ingredients.length > 0) {
|
||||
await tx.stepTechStepIngredient.createMany({
|
||||
data: metadata.ingredients.map((ingredient) => ({
|
||||
stepId,
|
||||
techStepOrder: order,
|
||||
ingredientId: ingredient.ingredientId,
|
||||
quantity: ingredient.quantity,
|
||||
unitId: ingredient.unitId,
|
||||
start: ingredient.start,
|
||||
end: ingredient.end,
|
||||
source: "manual",
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (metadata.utensils.length > 0) {
|
||||
await tx.stepTechStepUtensil.createMany({
|
||||
data: metadata.utensils.map((utensil) => ({
|
||||
stepId,
|
||||
techStepOrder: order,
|
||||
utensilId: utensil.utensilId,
|
||||
start: utensil.start,
|
||||
end: utensil.end,
|
||||
source: "manual",
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (target) {
|
||||
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
|
||||
}
|
||||
|
||||
await renumberStepTechSteps(tx, stepId);
|
||||
}
|
||||
|
||||
function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorrectionView {
|
||||
return {
|
||||
id: correction.id,
|
||||
start: correction.start,
|
||||
end: correction.end,
|
||||
previousTechStep: correction.previousTechStep
|
||||
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
|
||||
: null,
|
||||
correctedTechStep: correction.correctedTechStep
|
||||
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
|
||||
: null,
|
||||
createdAt: correction.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one correction to `stepId`'s detected techniques, submitted by
|
||||
* `correctorId`, and immediately applies its effect to the step's real
|
||||
* `StepTechStep` sequence (a `"manual"`-tagged row — see
|
||||
* {@link applyManualCorrection}) — see
|
||||
* {@link SubmitTechStepCorrectionInput}'s doc comment (`packages/shared`)
|
||||
* for what `previousTechStepId`/`correctedTechStepId` each mean. The audit
|
||||
* record itself is never edited/deleted afterward (see
|
||||
* `StepTechStepCorrection`'s schema doc comment) — only the live sequence
|
||||
* changes on a later correction to the same span.
|
||||
*
|
||||
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
|
||||
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
|
||||
* `start`/`end` (the correction's own span, or any of
|
||||
* `input.ingredients`/`input.utensils`' own spans) fall outside the
|
||||
* step's current `description` (it may have been edited since the user
|
||||
* last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
|
||||
* `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
|
||||
* doesn't exist.
|
||||
*/
|
||||
export async function submitTechStepCorrection(
|
||||
recipeId: number,
|
||||
stepId: number,
|
||||
input: SubmitTechStepCorrectionInput,
|
||||
correctorId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<SubmitTechStepCorrectionResult> {
|
||||
try {
|
||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
||||
|
||||
const spans = [
|
||||
{ start: input.start, end: input.end },
|
||||
...(input.ingredients ?? []),
|
||||
...(input.utensils ?? []),
|
||||
];
|
||||
for (const span of spans) {
|
||||
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.INVALID_CORRECTION_SPAN,
|
||||
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
|
||||
(id): id is number => id !== null && id !== undefined,
|
||||
);
|
||||
await assertTechStepsExist(techStepIds);
|
||||
await assertIngredientsExist((input.ingredients ?? []).map((i) => i.ingredientId));
|
||||
await assertUnitsExist(
|
||||
(input.ingredients ?? []).flatMap((i) =>
|
||||
i.unitId !== null && i.unitId !== undefined ? [i.unitId] : [],
|
||||
),
|
||||
);
|
||||
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
||||
|
||||
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
|
||||
const createdCorrection = await tx.stepTechStepCorrection.create({
|
||||
data: {
|
||||
stepId: step.id,
|
||||
correctorId,
|
||||
start: input.start,
|
||||
end: input.end,
|
||||
previousTechStepId: input.previousTechStepId ?? null,
|
||||
correctedTechStepId: input.correctedTechStepId ?? null,
|
||||
},
|
||||
include: correctionInclude,
|
||||
});
|
||||
|
||||
await applyManualCorrection(
|
||||
tx,
|
||||
step.id,
|
||||
{ start: input.start, end: input.end },
|
||||
input.previousTechStepId ?? null,
|
||||
input.correctedTechStepId ?? null,
|
||||
input.ingredients === undefined && input.utensils === undefined
|
||||
? undefined
|
||||
: {
|
||||
ingredients: (input.ingredients ?? []).map((ingredient) => ({
|
||||
ingredientId: ingredient.ingredientId,
|
||||
quantity: ingredient.quantity ?? null,
|
||||
unitId: ingredient.unitId ?? null,
|
||||
start: ingredient.start,
|
||||
end: ingredient.end,
|
||||
})),
|
||||
utensils: (input.utensils ?? []).map((utensil) => ({
|
||||
utensilId: utensil.utensilId,
|
||||
start: utensil.start,
|
||||
end: utensil.end,
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
// Same nested `ingredients`/`utensils` include as `recipe.service.ts`'s
|
||||
// `recipeInclude` — `toStepTechStepViews` (reused below) expects it,
|
||||
// so the fresh sequence read right after a manual correction resolves
|
||||
// exactly the same way a normal `GET /recipes/:id` would.
|
||||
const freshTechSteps = await tx.stepTechStep.findMany({
|
||||
where: { stepId: step.id },
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
techStep: true,
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: {
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
},
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
utensils: { include: { utensil: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return { correction: createdCorrection, techSteps: freshTechSteps };
|
||||
});
|
||||
|
||||
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
||||
} catch (err) {
|
||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every correction submitted so far for `stepId`, most recent first —
|
||||
* mainly useful for a user checking what's already been submitted (by
|
||||
* anyone) for a span before adding another (see `StepTechStepCorrectionView`'s
|
||||
* doc comment, `packages/shared`).
|
||||
*
|
||||
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see {@link loadVisibleStepOrThrow}.
|
||||
*/
|
||||
export async function listTechStepCorrections(
|
||||
recipeId: number,
|
||||
stepId: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<StepTechStepCorrectionView[]> {
|
||||
try {
|
||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, viewerId, viewerHouseId);
|
||||
const corrections = await prisma.stepTechStepCorrection.findMany({
|
||||
where: { stepId: step.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: correctionInclude,
|
||||
});
|
||||
return corrections.map(toCorrectionView);
|
||||
} catch (err) {
|
||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
||||
}
|
||||
}
|
||||
151
apps/api/src/modules/recipe/recipe.routes.ts
Normal file
151
apps/api/src/modules/recipe/recipe.routes.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
createRecipeSchema,
|
||||
ErrorCode,
|
||||
listRecipesSchema,
|
||||
submitTechStepCorrectionSchema,
|
||||
updateRecipeSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import {
|
||||
addFavorite,
|
||||
createRecipe,
|
||||
deleteRecipe,
|
||||
getRecipe,
|
||||
listRecipes,
|
||||
removeFavorite,
|
||||
updateRecipe,
|
||||
} from "./recipe.service.js";
|
||||
import {
|
||||
listTechStepCorrections,
|
||||
submitTechStepCorrection,
|
||||
} from "./recipe-tech-step-correction.service.js";
|
||||
|
||||
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
|
||||
export const recipeRouter = Router();
|
||||
|
||||
/** Parses and validates an `:id` route param, shared by every route below that targets one recipe. */
|
||||
function parseRecipeId(rawId: string | undefined): number {
|
||||
const id = Number(rawId);
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "id must be an integer");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Same shape as {@link parseRecipeId}, for the `:stepId` route param of the tech-step-correction routes below — a distinct function only so the error message names the right param. */
|
||||
function parseStepId(rawId: string | undefined): number {
|
||||
const id = Number(rawId);
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "stepId must be an integer");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
recipeRouter.get(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = listRecipesSchema.parse(req.query);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
res.status(200).json(
|
||||
await listRecipes(viewerId, houseId, input.tab, {
|
||||
search: input.search,
|
||||
suitableForHousehold: input.suitableForHousehold,
|
||||
ingredientIds: input.ingredientIds,
|
||||
dietIds: input.dietIds,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.get(
|
||||
"/:id",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
res.status(200).json(await getRecipe(id, viewerId, houseId));
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.post(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = createRecipeSchema.parse(req.body);
|
||||
const { id: authorId, houseId } = res.locals.userProfile;
|
||||
res.status(201).json(await createRecipe(input, authorId, houseId));
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.patch(
|
||||
"/:id",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
const input = updateRecipeSchema.parse(req.body);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
res.status(200).json(await updateRecipe(id, input, viewerId, houseId));
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.delete(
|
||||
"/:id",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
await deleteRecipe(id, viewerId, houseId);
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.post(
|
||||
"/:id/favorite",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
await addFavorite(id, viewerId, houseId);
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.delete(
|
||||
"/:id/favorite",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
await removeFavorite(id, res.locals.userProfile.id);
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
|
||||
// Open to any authenticated viewer who can see the recipe, not just its
|
||||
// author — see recipe-tech-step-correction.service.ts's own doc comment
|
||||
// for why.
|
||||
recipeRouter.post(
|
||||
"/:id/steps/:stepId/corrections",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
const stepId = parseStepId(req.params.stepId);
|
||||
const input = submitTechStepCorrectionSchema.parse(req.body);
|
||||
const { id: correctorId, houseId } = res.locals.userProfile;
|
||||
res.status(201).json(await submitTechStepCorrection(id, stepId, input, correctorId, houseId));
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.get(
|
||||
"/:id/steps/:stepId/corrections",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
const stepId = parseStepId(req.params.stepId);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
res.status(200).json(await listTechStepCorrections(id, stepId, viewerId, houseId));
|
||||
}),
|
||||
);
|
||||
893
apps/api/src/modules/recipe/recipe.service.ts
Normal file
893
apps/api/src/modules/recipe/recipe.service.ts
Normal file
|
|
@ -0,0 +1,893 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
type AllergyView,
|
||||
type CreateRecipeInput,
|
||||
type DietView,
|
||||
ErrorCode,
|
||||
type IngredientView,
|
||||
type RecipeSummaryView,
|
||||
type RecipeTab,
|
||||
type RecipeView,
|
||||
type StepTechStepView,
|
||||
type UnitView,
|
||||
type UpdateRecipeInput,
|
||||
} from "@batch-cooking/shared";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import {
|
||||
type TechStepMatch,
|
||||
techStepClassifier,
|
||||
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
||||
|
||||
// No user-language preference exists anywhere in the app yet (a single
|
||||
// "fr" translation file, no locale field on User/UserProfile) — steps are
|
||||
// matched against this hardcoded locale for now. See
|
||||
// `tech-step-matcher.ts`'s `TechStepClassifierService` for why the locale
|
||||
// is a parameter rather than baked into that module.
|
||||
const DEFAULT_TECH_STEP_LOCALE = "fr";
|
||||
|
||||
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
|
||||
function recipeInclude(viewerId: number) {
|
||||
return {
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: {
|
||||
include: {
|
||||
allergies: {
|
||||
include: { allergy: { include: { category: true } } },
|
||||
},
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
},
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
techSteps: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
techStep: true,
|
||||
// Same `allergies`/`diets` nesting as this function's own
|
||||
// top-level `ingredients` include above — reused by
|
||||
// `toIngredientView` so a mentioned ingredient resolves to the
|
||||
// exact same `IngredientView` shape as the recipe's main
|
||||
// ingredient list, not a second, thinner shape.
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: {
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
},
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
utensils: { include: { utensil: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
diets: { include: { diet: true } },
|
||||
favoritedBy: { where: { userProfileId: viewerId } },
|
||||
} satisfies Prisma.RecipeInclude;
|
||||
}
|
||||
|
||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
||||
include: ReturnType<typeof recipeInclude>;
|
||||
}>;
|
||||
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
|
||||
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
|
||||
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
||||
|
||||
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
|
||||
export function toUnitView(unit: UnitWithDetails): UnitView {
|
||||
return {
|
||||
id: unit.id,
|
||||
key: unit.key,
|
||||
type: unit.type,
|
||||
toBaseFactor: Number(unit.toBaseFactor),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same flattening as `reference.service.ts`'s `getIngredients`. Exported — see {@link toUnitView}'s doc comment, same reuse by `shopping-list.service.ts`. */
|
||||
export function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
||||
return {
|
||||
id: ingredient.id,
|
||||
key: ingredient.key,
|
||||
icon: ingredient.icon,
|
||||
category: ingredient.category,
|
||||
subcategory: ingredient.subcategory,
|
||||
reproducible: ingredient.reproducible,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
key: allergy.category.key,
|
||||
kind: allergy.category.kind,
|
||||
})),
|
||||
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
|
||||
};
|
||||
}
|
||||
|
||||
function toDietView(diet: { id: number; key: string }): DietView {
|
||||
return { id: diet.id, key: diet.key };
|
||||
}
|
||||
|
||||
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
|
||||
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
||||
const byId = new Map<number, AllergyView>();
|
||||
for (const ingredient of ingredients) {
|
||||
for (const allergen of ingredient.allergens) {
|
||||
byId.set(allergen.id, allergen);
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
|
||||
function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
|
||||
const allergens = aggregateAllergens(
|
||||
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
|
||||
);
|
||||
return {
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
description: recipe.description,
|
||||
picture: recipe.picture,
|
||||
portions: recipe.portions,
|
||||
authorId: recipe.authorId,
|
||||
visibility: recipe.visibility,
|
||||
allergens,
|
||||
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
|
||||
isFavorite: recipe.favoritedBy.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shapes a step's `StepTechStep` rows into {@link StepTechStepView}s — a row
|
||||
* whose `start`/`end` is still `null` (a pre-existing row saved before that
|
||||
* column pair existed, not yet recomputed by a resave — see the schema doc
|
||||
* comment on `StepTechStep`) is dropped rather than surfaced with a null
|
||||
* span, so the frontend only ever deals with real, highlightable matches.
|
||||
* `contextStart`/`contextEnd` are treated more leniently — a row with a
|
||||
* real keyword span but no context (saved before *that* column pair
|
||||
* existed) still has a perfectly good match to show, just without the
|
||||
* wider highlight, so those two are included only when both are present
|
||||
* rather than dropping the whole entry over a still-missing "nice to have".
|
||||
*
|
||||
* Exported — also called by `recipe-tech-step-correction.service.ts` to
|
||||
* shape the fresh `StepTechStep` sequence it returns right after applying
|
||||
* a manual correction, so both places convert the exact same way rather
|
||||
* than risking two slightly different views of the same rows.
|
||||
*/
|
||||
export function toStepTechStepViews(
|
||||
techSteps: RecipeWithDetails["steps"][number]["techSteps"],
|
||||
): StepTechStepView[] {
|
||||
const views: StepTechStepView[] = [];
|
||||
for (const stepTechStep of techSteps) {
|
||||
const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } =
|
||||
stepTechStep;
|
||||
if (start === null || end === null) continue;
|
||||
views.push({
|
||||
techStep: { id: techStep.id, key: techStep.key },
|
||||
start,
|
||||
end,
|
||||
// `source` is a plain DB `String`, not a Prisma enum (see
|
||||
// `StepTechStep`'s schema doc comment) — narrowed here rather than
|
||||
// trusting the column's own type, so a value this app never wrote
|
||||
// (a manual DB edit, a future migration gone wrong) degrades to the
|
||||
// safer "auto" reading instead of surfacing an invalid
|
||||
// `StepTechStepView.source` to the frontend.
|
||||
source: source === "manual" ? "manual" : "auto",
|
||||
...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}),
|
||||
ingredients: ingredients.map((stepTechStepIngredient) => ({
|
||||
ingredient: toIngredientView(stepTechStepIngredient.ingredient),
|
||||
quantity:
|
||||
stepTechStepIngredient.quantity === null ? null : Number(stepTechStepIngredient.quantity),
|
||||
unit: stepTechStepIngredient.unit === null ? null : toUnitView(stepTechStepIngredient.unit),
|
||||
start: stepTechStepIngredient.start,
|
||||
end: stepTechStepIngredient.end,
|
||||
// Same narrowing posture as the technique's own `source` above.
|
||||
source: stepTechStepIngredient.source === "manual" ? "manual" : "auto",
|
||||
})),
|
||||
utensils: utensils.map((stepTechStepUtensil) => ({
|
||||
utensil: { id: stepTechStepUtensil.utensil.id, key: stepTechStepUtensil.utensil.key },
|
||||
start: stepTechStepUtensil.start,
|
||||
end: stepTechStepUtensil.end,
|
||||
source: stepTechStepUtensil.source === "manual" ? "manual" : "auto",
|
||||
})),
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
|
||||
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
|
||||
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
|
||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||
quantity: Number(recipeIngredient.quantity),
|
||||
unit: toUnitView(recipeIngredient.unit),
|
||||
}));
|
||||
return {
|
||||
...toRecipeSummaryView(recipe),
|
||||
ingredients,
|
||||
steps: recipe.steps.map((step) => ({
|
||||
id: step.id,
|
||||
description: step.description,
|
||||
picture: step.picture,
|
||||
order: step.order,
|
||||
techSteps: toStepTechStepViews(step.techSteps),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `viewerId`/`viewerHouseId` may *read* this recipe — the author
|
||||
* always can, whatever the current visibility (even a `HOUSE` recipe if
|
||||
* they've since left that household — access to your own creations never
|
||||
* regresses). Otherwise follows `visibility` as documented on
|
||||
* `RecipeVisibility` in schema.prisma.
|
||||
*/
|
||||
function canView(
|
||||
recipe: {
|
||||
authorId: number;
|
||||
authorHouseId: number | null;
|
||||
visibility: string;
|
||||
},
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): boolean {
|
||||
if (recipe.authorId === viewerId) return true;
|
||||
if (recipe.visibility === "PUBLIC") return true;
|
||||
if (recipe.visibility === "HOUSE") {
|
||||
return viewerHouseId !== null && recipe.authorHouseId === viewerHouseId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** `Recipe` rows `viewerId`/`viewerHouseId` may read at all — the shared base every tab (except `perso`, which is already narrower) further restricts. Mirrors {@link canView} as a query filter. */
|
||||
function visibleToViewerWhere(
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Prisma.RecipeWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{ authorId: viewerId },
|
||||
{ visibility: "PUBLIC" },
|
||||
...(viewerHouseId !== null
|
||||
? [{ visibility: "HOUSE" as const, authorHouseId: viewerHouseId }]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `Recipe` rows that avoid every member of `houseId`'s declared allergens
|
||||
* and, for every member with a declared regime, are tagged with that
|
||||
* regime — the planning recipe picker's "convient à tout le foyer" toggle
|
||||
* (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
|
||||
* extra query to gather the household's members' allergy/regime ids)
|
||||
* rather than exposed to the client as raw per-member data: a member's
|
||||
* allergies/regime are private the same way visibility already keeps a
|
||||
* recipe's existence private (404, never 403) — nothing here should let
|
||||
* one member infer another's medical/dietary info from the shape of a
|
||||
* filtered list. Deliberately excludes `UserProfileDislikedIngredient` —
|
||||
* the schema already treats disliked ingredients as a taste preference,
|
||||
* not a safety constraint (see that model's doc comment), so it doesn't
|
||||
* belong in a filter framed around what's safe/appropriate to serve.
|
||||
*/
|
||||
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
|
||||
try {
|
||||
const members = await prisma.userProfile.findMany({
|
||||
where: { houseId },
|
||||
select: { dietId: true, allergies: { select: { allergyId: true } } },
|
||||
});
|
||||
const requiredDietIds = [
|
||||
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
|
||||
];
|
||||
const excludedAllergyIds = [
|
||||
...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))),
|
||||
];
|
||||
|
||||
const conditions: Prisma.RecipeWhereInput[] = [];
|
||||
if (requiredDietIds.length > 0) {
|
||||
// Every diet declared by a member must be among this recipe's tags —
|
||||
// not "at least one", since a recipe suiting a vegetarian member
|
||||
// doesn't automatically suit a gluten-free one too.
|
||||
conditions.push({
|
||||
AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })),
|
||||
});
|
||||
}
|
||||
if (excludedAllergyIds.length > 0) {
|
||||
conditions.push({
|
||||
ingredients: {
|
||||
none: {
|
||||
ingredient: {
|
||||
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return { AND: conditions };
|
||||
} 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`/`async` body
|
||||
// without a try/catch per the repo's convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `Recipe` rows a household is allowed to see given which sources it has
|
||||
* enabled (`HouseSource`, schema.prisma — opt-in, no row means hidden).
|
||||
* Applied unconditionally in {@link listRecipes}, across every tab: a
|
||||
* manually-authored recipe (`sourceId` `null`) is always visible, this
|
||||
* only ever hides a recipe that came from an external source the viewer's
|
||||
* household hasn't turned on. A viewer with no household yet
|
||||
* (`houseId === null`) has nothing enabled by construction (there's no
|
||||
* household row for `HouseSource` to reference), so every sourced recipe
|
||||
* is hidden for them until they join or create one and configure it.
|
||||
*/
|
||||
async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.RecipeWhereInput> {
|
||||
try {
|
||||
const enabledSourceIds =
|
||||
houseId === null
|
||||
? []
|
||||
: (
|
||||
await prisma.houseSource.findMany({
|
||||
where: { houseId },
|
||||
select: { sourceId: true },
|
||||
})
|
||||
).map((row) => row.sourceId);
|
||||
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional narrowing filters for {@link listRecipes}, on top of the
|
||||
* mandatory `tab`/`viewerId`/`viewerHouseId` — grouped into one object
|
||||
* rather than a growing list of positional optional params now that the
|
||||
* planning recipe picker adds two more on top of `search`/
|
||||
* `suitableForHousehold`.
|
||||
*/
|
||||
export interface ListRecipesFilters {
|
||||
/** Case-insensitive name substring. */
|
||||
search?: string;
|
||||
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
|
||||
suitableForHousehold?: boolean;
|
||||
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
|
||||
ingredientIds?: number[];
|
||||
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
|
||||
dietIds?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipes visible to `viewerId` under one catalog tab, alphabetically,
|
||||
* optionally filtered further (see {@link ListRecipesFilters}). No
|
||||
* "toutes" tab — every recipe a viewer can see falls under exactly one of
|
||||
* `perso`/`foyer`/`publique` (its own visibility); `favoris` is an
|
||||
* orthogonal, cross-cutting filter on top (and re-applies
|
||||
* {@link visibleToViewerWhere} in case access to a previously-favorited
|
||||
* recipe has since changed, e.g. leaving the house that granted it).
|
||||
*/
|
||||
export async function listRecipes(
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
tab: RecipeTab,
|
||||
filters: ListRecipesFilters = {},
|
||||
): Promise<RecipeSummaryView[]> {
|
||||
try {
|
||||
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
||||
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
||||
if (search) {
|
||||
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
||||
}
|
||||
// No-op without a household — nothing to filter against, same posture as
|
||||
// the `foyer` tab returning everything it can rather than throwing.
|
||||
if (suitableForHousehold && viewerHouseId !== null) {
|
||||
conditions.push(await suitableForHouseholdWhere(viewerHouseId));
|
||||
}
|
||||
if (ingredientIds && ingredientIds.length > 0) {
|
||||
// One condition per required id (AND) — a recipe must carry all of
|
||||
// them, not just one, same "every one, not any one" posture as
|
||||
// suitableForHouseholdWhere's requiredDietIds.
|
||||
conditions.push({
|
||||
AND: ingredientIds.map((ingredientId) => ({
|
||||
ingredients: { some: { ingredientId } },
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (dietIds && dietIds.length > 0) {
|
||||
conditions.push({
|
||||
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
|
||||
});
|
||||
}
|
||||
|
||||
switch (tab) {
|
||||
case "favoris":
|
||||
conditions.push({ favoritedBy: { some: { userProfileId: viewerId } } });
|
||||
conditions.push(visibleToViewerWhere(viewerId, viewerHouseId));
|
||||
break;
|
||||
case "perso":
|
||||
conditions.push({ visibility: "PERSONAL", authorId: viewerId });
|
||||
break;
|
||||
case "foyer":
|
||||
// No household — nothing can carry this viewer's authorHouseId.
|
||||
if (viewerHouseId === null) return [];
|
||||
conditions.push({ visibility: "HOUSE", authorHouseId: viewerHouseId });
|
||||
break;
|
||||
case "publique":
|
||||
conditions.push({ visibility: "PUBLIC" });
|
||||
break;
|
||||
}
|
||||
|
||||
const recipes = await prisma.recipe.findMany({
|
||||
where: { AND: conditions },
|
||||
include: recipeInclude(viewerId),
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return recipes.map(toRecipeSummaryView);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single recipe's full detail.
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or if it does but `viewerId` isn't allowed to see it (never `403` — a `PERSONAL`/`HOUSE` recipe belonging to someone else should look indistinguishable from a nonexistent one).
|
||||
*/
|
||||
export async function getRecipe(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
try {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
return toRecipeView(recipe);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a recipe with its ingredients, ordered steps and diet tags in one
|
||||
* go — steps' `order` is derived from their position in `input.steps`,
|
||||
* ingredients reference existing reference `Ingredient` rows by id (see
|
||||
* `GET /reference/ingredients`; there's no way to create one here).
|
||||
* `authorId`/`authorHouseId` are fixed at creation and never change on
|
||||
* later edits (see {@link updateRecipe}).
|
||||
*
|
||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||
*/
|
||||
export async function createRecipe(
|
||||
input: CreateRecipeInput,
|
||||
authorId: number,
|
||||
authorHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
try {
|
||||
return await createRecipeInternal(input, authorId, authorHouseId, null);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes an import from an external source — same validation/creation
|
||||
* path as {@link createRecipe} (by the time this is called, `input` has
|
||||
* already been reviewed and every ingredient resolved to a real catalog
|
||||
* id, same as a manual creation — see `sources.service.ts`'s
|
||||
* `importSourceItem`, the only caller), plus stamping `sourceId`/
|
||||
* `externalId` and matching techniques against `locale` (the source's own
|
||||
* — e.g. `"en"` for TheMealDB) instead of the hardcoded French default,
|
||||
* since the step text is still in whatever language the source wrote it
|
||||
* in.
|
||||
*
|
||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||
*/
|
||||
export async function createImportedRecipe(
|
||||
input: CreateRecipeInput,
|
||||
authorId: number,
|
||||
authorHouseId: number | null,
|
||||
source: { sourceId: number; externalId: string; locale: string },
|
||||
): Promise<RecipeView> {
|
||||
try {
|
||||
return await createRecipeInternal(input, authorId, authorHouseId, source);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** One input step, bundled with its own technique matches — see {@link matchStepsTechSteps}. */
|
||||
interface StepWithTechSteps<T> {
|
||||
step: T;
|
||||
matches: TechStepMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches every one of `steps`' technique sequence against `locale`, in
|
||||
* parallel, each bundled back with its own originating step (rather than
|
||||
* returned as a same-length array callers would have to re-zip with
|
||||
* `steps` by index — `noUncheckedIndexedAccess` makes that genuinely
|
||||
* awkward for no benefit, since every match list is only ever read back
|
||||
* once) — the shared prep step {@link createRecipeInternal}/
|
||||
* {@link updateRecipe} both need before building their (synchronous)
|
||||
* Prisma `create` payload, now that matching itself is async
|
||||
* (`techStepClassifier`, a trained model rather than a pure regex test —
|
||||
* see `tech-step-matcher.ts`).
|
||||
*/
|
||||
async function matchStepsTechSteps<T extends { description: string }>(
|
||||
steps: T[],
|
||||
locale: string,
|
||||
): Promise<StepWithTechSteps<T>[]> {
|
||||
return Promise.all(
|
||||
steps.map(async (step) => ({
|
||||
step,
|
||||
matches: await techStepClassifier.matchTechStepSpans(step.description, locale),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function createRecipeInternal(
|
||||
input: CreateRecipeInput,
|
||||
authorId: number,
|
||||
authorHouseId: number | null,
|
||||
source: { sourceId: number; externalId: string; locale: string } | null,
|
||||
): Promise<RecipeView> {
|
||||
try {
|
||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||
await assertDietsExist(input.dietIds);
|
||||
// Matched up front (one call per step, in parallel) rather than inline
|
||||
// inside the `steps.create` map below — `techStepClassifier` is async
|
||||
// (a trained model, not a pure regex test), so its result has to
|
||||
// already be in hand by the time this synchronous Prisma payload is
|
||||
// built.
|
||||
const stepsWithTechSteps = await matchStepsTechSteps(
|
||||
input.steps,
|
||||
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
||||
);
|
||||
|
||||
const created = await prisma.recipe.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
picture: input.picture ?? null,
|
||||
portions: input.portions,
|
||||
authorId,
|
||||
authorHouseId,
|
||||
visibility: input.visibility,
|
||||
sourceId: source?.sourceId ?? null,
|
||||
externalId: source?.externalId ?? null,
|
||||
ingredients: {
|
||||
create: input.ingredients.map((ingredient) => ({
|
||||
ingredientId: ingredient.ingredientId,
|
||||
quantity: ingredient.quantity,
|
||||
unitId: ingredient.unitId,
|
||||
})),
|
||||
},
|
||||
steps: {
|
||||
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
|
||||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
techSteps: {
|
||||
create: 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,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
},
|
||||
include: recipeInclude(authorId),
|
||||
});
|
||||
return toRecipeView(created);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a recipe's whole content — name/description/picture/visibility
|
||||
* and the complete ingredient/step/diet lists (not a partial merge: a line
|
||||
* missing from `input` is removed, same contract as `PATCH
|
||||
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
|
||||
* never transfers ownership.
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||
*/
|
||||
export async function updateRecipe(
|
||||
id: number,
|
||||
input: UpdateRecipeInput,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
try {
|
||||
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||
await assertDietsExist(input.dietIds);
|
||||
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
||||
prisma.step.deleteMany({ where: { recipeId: id } }),
|
||||
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
|
||||
prisma.recipe.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
picture: input.picture ?? null,
|
||||
portions: input.portions,
|
||||
visibility: input.visibility,
|
||||
ingredients: {
|
||||
create: input.ingredients.map((ingredient) => ({
|
||||
ingredientId: ingredient.ingredientId,
|
||||
quantity: ingredient.quantity,
|
||||
unitId: ingredient.unitId,
|
||||
})),
|
||||
},
|
||||
steps: {
|
||||
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
|
||||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
techSteps: {
|
||||
create: matches.map((match, order) => ({
|
||||
techStepId: match.techStepId,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
contextStart: match.contextStart,
|
||||
contextEnd: match.contextEnd,
|
||||
order,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a recipe outright — its ingredients/steps/diet tags/favorites
|
||||
* cascade away (see `onDelete: Cascade` in schema.prisma).
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||
* @throws {HttpError} `409 RECIPE_IN_USE` if the recipe is still referenced by a `PlanningItem` — `PlanningItem.recipeId` has no cascade of its own on purpose (removing a recipe shouldn't silently blow a hole in a planning), so this is surfaced as a normal, actionable conflict rather than a raw FK violation.
|
||||
*/
|
||||
export async function deleteRecipe(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||
|
||||
const usedInPlanning = await prisma.planningItem.findFirst({
|
||||
where: { recipeId: id },
|
||||
});
|
||||
if (usedInPlanning) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.RECIPE_IN_USE,
|
||||
"Recipe is still used by at least one planning item",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.recipe.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorites a recipe for `viewerId` — idempotent (favoriting an
|
||||
* already-favorited recipe is a no-op, not an error).
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId` — favoriting something you can't see isn't a valid action.
|
||||
*/
|
||||
export async function addFavorite(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
await prisma.recipeFavorite.upsert({
|
||||
where: {
|
||||
userProfileId_recipeId: { userProfileId: viewerId, recipeId: id },
|
||||
},
|
||||
update: {},
|
||||
create: { userProfileId: viewerId, recipeId: id },
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Unfavorites a recipe for `viewerId` — idempotent, no error if it wasn't favorited (or doesn't exist/isn't visible: unfavoriting is always safe, nothing to leak). */
|
||||
export async function removeFavorite(id: number, viewerId: number): Promise<void> {
|
||||
try {
|
||||
await prisma.recipeFavorite.deleteMany({
|
||||
where: { userProfileId: viewerId, recipeId: id },
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for other modules that need to confirm a recipe is visible to a
|
||||
* viewer before referencing it (e.g. `planning.service.ts`'s
|
||||
* `addPlanningItem`, before creating a `PlanningItem` pointing at it) —
|
||||
* exported rather than duplicating {@link canView} at the call site.
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or does but isn't visible to `viewerId` — never `403`, same reasoning as `getRecipe`.
|
||||
*/
|
||||
export async function assertRecipeVisible(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */
|
||||
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
|
||||
try {
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id },
|
||||
include: recipeInclude(viewerId),
|
||||
});
|
||||
if (!recipe) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
return recipe;
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */
|
||||
async function assertIsAuthor(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
if (recipe.authorId !== viewerId) {
|
||||
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */
|
||||
async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
|
||||
try {
|
||||
const uniqueIds = [...new Set(ingredientIds)];
|
||||
const found = await prisma.ingredient.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||
`Ingredient(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */
|
||||
async function assertUnitsExist(unitIds: number[]): Promise<void> {
|
||||
try {
|
||||
const uniqueIds = [...new Set(unitIds)];
|
||||
const found = await prisma.unit.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(found.map((unit) => unit.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.UNIT_NOT_FOUND,
|
||||
`Unit(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
|
||||
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
||||
try {
|
||||
const uniqueIds = [...new Set(dietIds)];
|
||||
if (uniqueIds.length === 0) return;
|
||||
const found = await prisma.diet.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(found.map((diet) => diet.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.DIET_NOT_FOUND,
|
||||
`Diet(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,23 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { Router } from "express";
|
||||
import { getAllergies, getDiets } from "./reference.service.js";
|
||||
import {
|
||||
getAllergies,
|
||||
getDiets,
|
||||
getIngredients,
|
||||
getSources,
|
||||
getTechSteps,
|
||||
getUnits,
|
||||
getUtensils,
|
||||
} from "./reference.service.js";
|
||||
|
||||
/**
|
||||
* Router mounted at `/reference` in app.ts. Both routes are deliberately
|
||||
* Router mounted at `/reference` in app.ts. Every route is deliberately
|
||||
* public (no `requireAuth`) — this is static reference data, not
|
||||
* per-household state, and the signup wizard (household/regime/allergen
|
||||
* steps) needs to read it before an account — and therefore a session —
|
||||
* exists.
|
||||
* exists. `/ingredients` follows the same reasoning even though it's only
|
||||
* consumed post-login (the recipe catalog) — it's still non-administrable
|
||||
* reference data, no reason to require a session to read it.
|
||||
*/
|
||||
export const referenceRouter = Router();
|
||||
|
||||
|
|
@ -24,3 +34,38 @@ referenceRouter.get(
|
|||
res.status(200).json(await getAllergies());
|
||||
}),
|
||||
);
|
||||
|
||||
referenceRouter.get(
|
||||
"/ingredients",
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await getIngredients());
|
||||
}),
|
||||
);
|
||||
|
||||
referenceRouter.get(
|
||||
"/units",
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await getUnits());
|
||||
}),
|
||||
);
|
||||
|
||||
referenceRouter.get(
|
||||
"/tech-steps",
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await getTechSteps());
|
||||
}),
|
||||
);
|
||||
|
||||
referenceRouter.get(
|
||||
"/utensils",
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await getUtensils());
|
||||
}),
|
||||
);
|
||||
|
||||
referenceRouter.get(
|
||||
"/sources",
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await getSources());
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,166 @@
|
|||
import type { AllergyView, DietView } from "@batch-cooking/shared";
|
||||
import type {
|
||||
AllergyView,
|
||||
DietView,
|
||||
IngredientView,
|
||||
SourceView,
|
||||
TechStepView,
|
||||
UnitView,
|
||||
UtensilView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
|
||||
/** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */
|
||||
/**
|
||||
* All reference dietary regimes, ordered by `key` — small, static list (see
|
||||
* prisma/seed.ts). `key` is a stable slug, not the display label (see
|
||||
* {@link DietView}), so this is an alphabetical-by-slug order rather than a
|
||||
* true French alphabetical one — close enough for a 5-item list, and the
|
||||
* server has no other order to offer now that the label itself only exists
|
||||
* client-side (`apps/web`'s `locales/fr/translation.json`).
|
||||
*/
|
||||
export async function getDiets(): Promise<DietView[]> {
|
||||
return prisma.diet.findMany({ orderBy: { name: "asc" } });
|
||||
try {
|
||||
return await prisma.diet.findMany({ orderBy: { key: "asc" } });
|
||||
} 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 `async` body without a
|
||||
// try/catch per the repo's convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All reference allergens, alphabetically. `Allergy` carries no `name` of
|
||||
* its own — it's the selectable instance of a named `Category` (see
|
||||
* schema.prisma) — so this resolves each allergen's display name from its
|
||||
* category and flattens the split away for callers.
|
||||
* All reference allergens, ordered by key (see {@link getDiets} for why key,
|
||||
* not label). `Allergy` carries no `key` of its own — it's the selectable
|
||||
* instance of a keyed `Category` (see schema.prisma) — so this resolves
|
||||
* each allergen's key from its category and flattens the split away for
|
||||
* callers.
|
||||
*/
|
||||
export async function getAllergies(): Promise<AllergyView[]> {
|
||||
const allergies = await prisma.allergy.findMany({
|
||||
include: { category: { select: { name: true, kind: true } } },
|
||||
orderBy: { category: { name: "asc" } },
|
||||
});
|
||||
return allergies.map((allergy) => ({
|
||||
id: allergy.id,
|
||||
name: allergy.category.name,
|
||||
kind: allergy.category.kind,
|
||||
}));
|
||||
try {
|
||||
const allergies = await prisma.allergy.findMany({
|
||||
include: { category: { select: { key: true, kind: true } } },
|
||||
orderBy: { category: { key: "asc" } },
|
||||
});
|
||||
return allergies.map((allergy) => ({
|
||||
id: allergy.id,
|
||||
key: allergy.category.key,
|
||||
kind: allergy.category.kind,
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All reference recipe-ingredient units, ordered by key (see {@link getDiets}
|
||||
* for why) — small, static list (see `reference-seed-data.ts`'s `UNITS`).
|
||||
* `toBaseFactor` comes back as a Prisma `Decimal`, converted to a plain
|
||||
* `number` here the same way `recipe.service.ts` does for
|
||||
* `RecipeIngredient.quantity`.
|
||||
*/
|
||||
export async function getUnits(): Promise<UnitView[]> {
|
||||
try {
|
||||
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
|
||||
return units.map((unit) => ({
|
||||
id: unit.id,
|
||||
key: unit.key,
|
||||
type: unit.type,
|
||||
toBaseFactor: Number(unit.toBaseFactor),
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All reference cooking techniques, ordered by key (see {@link getDiets}
|
||||
* for why) — small, static list (see `reference-seed-data.ts`'s
|
||||
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
|
||||
*/
|
||||
export async function getTechSteps(): Promise<TechStepView[]> {
|
||||
try {
|
||||
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All reference cooking utensils, ordered by key (see {@link getDiets} for
|
||||
* why) — small, static list (see `reference-seed-data.ts`'s `UTENSILS`),
|
||||
* same bare `id`/`key` shape as {@link getTechSteps}.
|
||||
*/
|
||||
export async function getUtensils(): Promise<UtensilView[]> {
|
||||
try {
|
||||
return await prisma.utensil.findMany({ orderBy: { key: "asc" } });
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every implemented recipe source, ordered by name (not `key` — unlike
|
||||
* every other reference catalog, `name` here *is* the display string a
|
||||
* household picks from, see {@link SourceView}, so alphabetical-by-name is
|
||||
* what a real picker should show). Empty until a concrete adapter is
|
||||
* registered (see `recipe-source-registry.ts`) and synced (see
|
||||
* `recipe-source-sync.ts`'s `syncRecipeSources`).
|
||||
*/
|
||||
export async function getSources(): Promise<SourceView[]> {
|
||||
try {
|
||||
// Explicit `select` — `url` exists on the `Source` row but isn't part of
|
||||
// `SourceView` yet, so it must not leak into the response the way a bare
|
||||
// `findMany()` would let it.
|
||||
return await prisma.source.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
name: true,
|
||||
official: true,
|
||||
iconUrl: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All reference ingredients, ordered by key (see {@link getDiets} for why),
|
||||
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
|
||||
* and compatible diet regimes (see `IngredientDiet`) — same flattening
|
||||
* approach as {@link getAllergies}. Ingredients with no linked
|
||||
* allergen/diet come back with `allergens: []`/`diets: []`.
|
||||
*/
|
||||
export async function getIngredients(): Promise<IngredientView[]> {
|
||||
try {
|
||||
const ingredients = await prisma.ingredient.findMany({
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
orderBy: { key: "asc" },
|
||||
});
|
||||
return ingredients.map((ingredient) => ({
|
||||
id: ingredient.id,
|
||||
key: ingredient.key,
|
||||
icon: ingredient.icon,
|
||||
category: ingredient.category,
|
||||
subcategory: ingredient.subcategory,
|
||||
reproducible: ingredient.reproducible,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
key: allergy.category.key,
|
||||
kind: allergy.category.kind,
|
||||
})),
|
||||
diets: ingredient.diets.map(({ diet }) => ({
|
||||
id: diet.id,
|
||||
key: diet.key,
|
||||
})),
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
apps/api/src/modules/shopping-list/shopping-list.routes.ts
Normal file
36
apps/api/src/modules/shopping-list/shopping-list.routes.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { getShoppingListForDate } from "./shopping-list.service.js";
|
||||
|
||||
/** Router mounted at `/shopping-list` in app.ts. */
|
||||
export const shoppingListRouter = Router();
|
||||
|
||||
/**
|
||||
* Returns the authenticated user's household's shopping list for the week
|
||||
* covering `?date=` (`YYYY-MM-DD`) — every ingredient line of every recipe
|
||||
* planned that week, summed (see {@link getShoppingListForDate}). Always
|
||||
* `200`, never `null` — no household or nothing planned that week both
|
||||
* come back as a normal `ShoppingListView` with an empty `items` array.
|
||||
*/
|
||||
shoppingListRouter.get(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = getShoppingListSchema.parse(req.query);
|
||||
const date = parseDateOnly(input.date);
|
||||
if (date === null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
`Not a real calendar date: ${input.date}`,
|
||||
);
|
||||
}
|
||||
|
||||
const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date);
|
||||
res.status(200).json(shoppingList);
|
||||
}),
|
||||
);
|
||||
154
apps/api/src/modules/shopping-list/shopping-list.service.ts
Normal file
154
apps/api/src/modules/shopping-list/shopping-list.service.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
||||
import type {
|
||||
IngredientView,
|
||||
ShoppingListItemView,
|
||||
ShoppingListView,
|
||||
UnitView,
|
||||
} from "@batch-cooking/shared";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
|
||||
|
||||
/** Prisma `include` for a `Planning` query that needs, for every item, just enough of its recipe to compute a shopping list — `portions` (to scale `RecipeIngredient.quantity`) and the ingredient lines themselves, each resolved the same way `recipe.service.ts`'s own `recipeInclude` resolves them (so {@link toIngredientView}/{@link toUnitView} can be reused as-is). Deliberately narrower than a full `RecipeView` fetch — steps/diets/favorites are never read here. */
|
||||
function shoppingListPlanningInclude() {
|
||||
return {
|
||||
items: {
|
||||
include: {
|
||||
recipe: {
|
||||
select: {
|
||||
portions: true,
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: {
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
},
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.PlanningInclude;
|
||||
}
|
||||
|
||||
type PlanningWithIngredients = Prisma.PlanningGetPayload<{
|
||||
include: ReturnType<typeof shoppingListPlanningInclude>;
|
||||
}>;
|
||||
|
||||
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
|
||||
interface RunningTotal {
|
||||
ingredient: IngredientView;
|
||||
unit: UnitView;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums every ingredient line across `items`, each scaled by that planning
|
||||
* item's own portion count relative to its recipe's as-written yield
|
||||
* (`RecipeIngredient.quantity × PlanningItem.portions / Recipe.portions`,
|
||||
* see `PlanningItem.portions`'s doc comment in schema.prisma for why the
|
||||
* two can differ). Grouped by `(ingredientId, unitId)` — **not** just
|
||||
* `ingredientId` — since summing across units isn't implemented yet (see
|
||||
* `ShoppingListItemView`'s doc comment): the same ingredient requested in
|
||||
* two different units stays two separate lines rather than silently
|
||||
* guessing a conversion. Pure/synchronous, factored out from
|
||||
* {@link getShoppingListForDate} so the aggregation itself is testable
|
||||
* without a database round-trip.
|
||||
*/
|
||||
function aggregateShoppingList(items: PlanningWithIngredients["items"]): ShoppingListItemView[] {
|
||||
const totals = new Map<string, RunningTotal>();
|
||||
|
||||
for (const item of items) {
|
||||
const scale = item.portions / item.recipe.portions;
|
||||
for (const recipeIngredient of item.recipe.ingredients) {
|
||||
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
|
||||
const addedQuantity = Number(recipeIngredient.quantity) * scale;
|
||||
|
||||
const existing = totals.get(key);
|
||||
if (existing) {
|
||||
existing.quantity += addedQuantity;
|
||||
} else {
|
||||
totals.set(key, {
|
||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||
unit: toUnitView(recipeIngredient.unit),
|
||||
quantity: addedQuantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic order (by the ingredient's stable `key`, not its id —
|
||||
// insertion order would otherwise depend on which recipe happened to be
|
||||
// read first) — the frontend re-sorts by translated label/aisle for
|
||||
// display, this is just so two identical plannings always produce the
|
||||
// same JSON.
|
||||
return [...totals.values()].sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the household's shopping list for the week covering `date` —
|
||||
* every ingredient line of every recipe planned that week, aggregated (see
|
||||
* {@link aggregateShoppingList}). `date` is whatever the caller wants "that
|
||||
* week" to mean, same convention as `planning.service.ts`'s
|
||||
* `getPlanningForDate` (a caller-parsed `?date=`, not necessarily a
|
||||
* Monday).
|
||||
*
|
||||
* Unlike `getPlanningForDate`, this **never** returns `null` — no household
|
||||
* and "no planning covers this week yet" both degrade to an empty `items`
|
||||
* array on an otherwise normal `ShoppingListView` (the week's date range is
|
||||
* always computable from `date` alone, even with nothing planned in it),
|
||||
* rather than a separate "nothing to show" state the frontend would have to
|
||||
* branch on.
|
||||
*/
|
||||
export async function getShoppingListForDate(
|
||||
houseId: number | null,
|
||||
date: DateTime,
|
||||
): Promise<ShoppingListView> {
|
||||
try {
|
||||
const weekStart = getWeekStart(toDateOnly(date));
|
||||
const weekFinish = weekStart.plus({ days: 6 });
|
||||
const emptyList: ShoppingListView = {
|
||||
startDate: weekStart.toJSDate().toISOString(),
|
||||
finishDate: weekFinish.toJSDate().toISOString(),
|
||||
items: [],
|
||||
};
|
||||
|
||||
if (houseId === null) {
|
||||
return emptyList;
|
||||
}
|
||||
|
||||
// Same "covering range" lookup as getPlanningForDate — see that
|
||||
// function's doc comment for why this compares against a UTC-midnight
|
||||
// JS Date rather than `weekStart`/`weekFinish` directly.
|
||||
const dateOnly = toDateOnly(date).toJSDate();
|
||||
const planning = await prisma.planning.findFirst({
|
||||
where: {
|
||||
houseId,
|
||||
startDate: { lte: dateOnly },
|
||||
finishDate: { gte: dateOnly },
|
||||
},
|
||||
orderBy: { startDate: "desc" },
|
||||
include: shoppingListPlanningInclude(),
|
||||
});
|
||||
|
||||
if (!planning) {
|
||||
return emptyList;
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: planning.startDate.toISOString(),
|
||||
finishDate: planning.finishDate.toISOString(),
|
||||
items: aggregateShoppingList(planning.items),
|
||||
};
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
71
apps/api/src/modules/sources/sources.routes.ts
Normal file
71
apps/api/src/modules/sources/sources.routes.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { browseSourceSchema, createRecipeSchema, ErrorCode } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { browseSource, importSourceItem, previewSourceItem } from "./sources.service.js";
|
||||
|
||||
/**
|
||||
* Router mounted at `/sources` in app.ts — browsing/previewing a
|
||||
* household's *enabled* external recipe sources (see `sources.service.ts`).
|
||||
* Every route requires a session, same posture as `/recipes`/`/house`: this
|
||||
* is app content scoped to the viewer's household, not signup-time
|
||||
* reference data (contrast `/reference/sources`, which just lists what
|
||||
* exists, public, no auth needed).
|
||||
*/
|
||||
export const sourcesRouter = Router();
|
||||
|
||||
/** Route params are typed `string | undefined` by Express even for a segment that always matches when the route does — this just satisfies TS, the branch is unreachable in practice. */
|
||||
function requireParam(value: string | undefined): string {
|
||||
if (value === undefined) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Missing route parameter");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
sourcesRouter.get(
|
||||
"/:sourceKey/browse",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = browseSourceSchema.parse(req.query);
|
||||
const { houseId } = res.locals.userProfile;
|
||||
res.status(200).json(await browseSource(requireParam(req.params.sourceKey), houseId, input));
|
||||
}),
|
||||
);
|
||||
|
||||
sourcesRouter.get(
|
||||
"/:sourceKey/preview/:externalId",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const { houseId } = res.locals.userProfile;
|
||||
res
|
||||
.status(200)
|
||||
.json(
|
||||
await previewSourceItem(
|
||||
requireParam(req.params.sourceKey),
|
||||
requireParam(req.params.externalId),
|
||||
houseId,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
sourcesRouter.post(
|
||||
"/:sourceKey/import/:externalId",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = createRecipeSchema.parse(req.body);
|
||||
const { id: authorId, houseId } = res.locals.userProfile;
|
||||
res
|
||||
.status(201)
|
||||
.json(
|
||||
await importSourceItem(
|
||||
requireParam(req.params.sourceKey),
|
||||
requireParam(req.params.externalId),
|
||||
input,
|
||||
authorId,
|
||||
houseId,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
332
apps/api/src/modules/sources/sources.service.ts
Normal file
332
apps/api/src/modules/sources/sources.service.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
type BrowsableSourceItemView,
|
||||
type CreateRecipeInput,
|
||||
type DraftRecipeIngredientView,
|
||||
type DraftRecipeStepView,
|
||||
ErrorCode,
|
||||
type RecipeImportDraftView,
|
||||
type RecipeView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
|
||||
import {
|
||||
loadIngredientCatalog,
|
||||
loadUnitCatalog,
|
||||
} from "../../lib/recipe-matching/ingredient-matcher.js";
|
||||
import {
|
||||
mergeDuplicateIngredients,
|
||||
translateRecipeIngredients,
|
||||
} from "../../lib/recipe-matching/recipe-translation.js";
|
||||
import { techStepClassifier } from "../../lib/recipe-matching/tech-step-matcher.js";
|
||||
import {
|
||||
markAlreadyImported,
|
||||
type RecipeSourceAdapter,
|
||||
} from "../../lib/recipe-sources/recipe-source-adapter.js";
|
||||
import { RecipeSourceError } from "../../lib/recipe-sources/recipe-source-errors.js";
|
||||
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
|
||||
import { getHouseSourceIds } from "../house/house.service.js";
|
||||
import { createImportedRecipe } from "../recipe/recipe.service.js";
|
||||
import { getIngredients, getUnits, getUtensils } from "../reference/reference.service.js";
|
||||
|
||||
/**
|
||||
* Browsing, previewing, and importing a household's *enabled* external
|
||||
* recipe sources (`HouseSource`) — the "onglet Sources" feature (see the
|
||||
* project plan). Browsing lists what a source offers
|
||||
* (`RecipeSourceAdapter.list()`); previewing fully translates one item
|
||||
* (`translateRecipeIngredients`, `matchTechStepSpans` — same building
|
||||
* blocks `recipe.service.ts` uses at real save time) without persisting
|
||||
* it; importing (`importSourceItem`) is the only function here that
|
||||
* actually saves — by the time it's called, the caller (the review screen)
|
||||
* has already resolved every ingredient to a real catalog id, same as a
|
||||
* manual `POST /recipes`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `sourceKey` must both exist as a `Source` (household-enabled, via
|
||||
* `HouseSource`) *and* still be a registered adapter (`recipe-source-registry.ts`)
|
||||
* — the two can drift apart (a `Source` row outlives its adapter being
|
||||
* unregistered, exactly what `jsonLdRecipe` was cleaned up from — see
|
||||
* `sources/index.ts`), so both are checked. Either failure looks like "this
|
||||
* source doesn't exist" to the caller (404 `SOURCE_NOT_FOUND`), same
|
||||
* "don't distinguish not-found from not-visible" posture `recipe.service.ts`
|
||||
* takes for a recipe the viewer can't see.
|
||||
*
|
||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
|
||||
*/
|
||||
async function assertSourceEnabled(
|
||||
houseId: number | null,
|
||||
sourceKey: string,
|
||||
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
|
||||
try {
|
||||
const enabledSourceIds = await getHouseSourceIds(houseId);
|
||||
const source = await prisma.source.findUnique({
|
||||
where: { key: sourceKey },
|
||||
});
|
||||
if (!source || !enabledSourceIds.includes(source.id)) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.SOURCE_NOT_FOUND,
|
||||
`Source "${sourceKey}" is not enabled for this household`,
|
||||
);
|
||||
}
|
||||
const adapter = getRecipeSource(sourceKey);
|
||||
if (!adapter) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.SOURCE_NOT_FOUND,
|
||||
`Source "${sourceKey}" has no registered adapter`,
|
||||
);
|
||||
}
|
||||
return { adapter, sourceId: source.id };
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of `sourceKey`'s own catalog, each item flagged with whether
|
||||
* it's already been imported (and, if so, its real `Recipe` id — see
|
||||
* `findImportedRecipeIds`).
|
||||
*
|
||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
|
||||
*/
|
||||
export async function browseSource(
|
||||
sourceKey: string,
|
||||
houseId: number | null,
|
||||
params: { query?: string; cursor?: string },
|
||||
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
|
||||
try {
|
||||
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
||||
const result = await adapter.list({
|
||||
query: params.query,
|
||||
cursor: params.cursor,
|
||||
});
|
||||
|
||||
const importedRecipeIds = await findImportedRecipeIds(
|
||||
prisma,
|
||||
sourceKey,
|
||||
result.items.map((item) => item.externalId),
|
||||
);
|
||||
const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys()));
|
||||
|
||||
return {
|
||||
items: marked.map((item) => ({
|
||||
externalId: item.externalId,
|
||||
title: item.title,
|
||||
picture: item.picture,
|
||||
url: item.url,
|
||||
alreadyImported: item.alreadyImported,
|
||||
recipeId: importedRecipeIds.get(item.externalId) ?? null,
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err; // see assertSourceEnabled()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully translates one source item into an unsaved {@link RecipeImportDraftView}
|
||||
* — fetches + parses it (`fetchDetail`/`parse`), then resolves its
|
||||
* ingredients/units (`translateRecipeIngredients`) and detects each step's
|
||||
* techniques with their exact matched span (`matchTechStepSpans`, the same
|
||||
* function `recipe.service.ts` uses at real save time — see its doc
|
||||
* comment), all against `adapter.locale`'s catalogs. Ingredient/unit
|
||||
* matching has data for `"en"`/`"fr"` today (see `ingredient-matcher.ts`);
|
||||
* `loadIngredientCatalog`/`loadUnitCatalog` are always called with
|
||||
* `adapter.locale` directly, never specially skipped for a particular
|
||||
* one — a source whose locale has no label table of its own just gets back
|
||||
* empty catalogs from those two loaders, so every line's `ingredient`/
|
||||
* `unit` end up `null` the same way, the same graceful "no
|
||||
* matching-language data" degradation `translateRecipe` already has.
|
||||
*
|
||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `externalId` couldn't be fetched or parsed into a usable recipe (a `RecipeSourceError` — `recipe-source-errors.ts` — from the adapter).
|
||||
*/
|
||||
export async function previewSourceItem(
|
||||
sourceKey: string,
|
||||
externalId: string,
|
||||
houseId: number | null,
|
||||
): Promise<RecipeImportDraftView> {
|
||||
try {
|
||||
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
||||
|
||||
let parsed: ReturnType<typeof adapter.parse>;
|
||||
try {
|
||||
const raw = await adapter.fetchDetail(externalId);
|
||||
parsed = adapter.parse(raw);
|
||||
} catch (err) {
|
||||
if (err instanceof RecipeSourceError) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [stepsWithTechStepMatches, ingredientCatalog, unitCatalog, techStepsByKey] =
|
||||
await Promise.all([
|
||||
Promise.all(
|
||||
parsed.steps.map(async (step) => ({
|
||||
step,
|
||||
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
|
||||
})),
|
||||
),
|
||||
loadIngredientCatalog(adapter.locale),
|
||||
loadUnitCatalog(adapter.locale),
|
||||
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
||||
]);
|
||||
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
||||
|
||||
const translatedIngredients = translateRecipeIngredients(
|
||||
parsed.ingredients,
|
||||
ingredientCatalog,
|
||||
unitCatalog,
|
||||
adapter.locale,
|
||||
);
|
||||
const [ingredientViews, unitViews, utensilViews] = await Promise.all([
|
||||
getIngredients(),
|
||||
getUnits(),
|
||||
getUtensils(),
|
||||
]);
|
||||
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
|
||||
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
||||
const utensilById = new Map(utensilViews.map((view) => [view.id, view]));
|
||||
|
||||
// A source's raw ingredient lines aren't deduplicated by the matcher —
|
||||
// two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
|
||||
// catalog ingredient. Folded into one line per ingredient (quantities
|
||||
// summed where that's safe) before the draft ever reaches the review
|
||||
// screen, rather than surfacing the recipe with two rows for "Œuf" and
|
||||
// making the person sort it out — see issue #53's follow-up.
|
||||
const mergedIngredients = mergeDuplicateIngredients(translatedIngredients, unitViews);
|
||||
|
||||
const ingredients: DraftRecipeIngredientView[] = mergedIngredients.map((ingredient) => ({
|
||||
rawText: ingredient.rawText,
|
||||
quantity: ingredient.quantity,
|
||||
ingredient:
|
||||
ingredient.ingredientId !== null
|
||||
? (ingredientById.get(ingredient.ingredientId) ?? null)
|
||||
: null,
|
||||
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
|
||||
}));
|
||||
|
||||
const steps: DraftRecipeStepView[] = stepsWithTechStepMatches.map(({ step, matches }) => ({
|
||||
description: step.description,
|
||||
picture: step.picture,
|
||||
techSteps: matches.flatMap((match) => {
|
||||
const techStep = techStepById.get(match.techStepId);
|
||||
return techStep
|
||||
? [
|
||||
{
|
||||
techStep,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
contextStart: match.contextStart,
|
||||
contextEnd: match.contextEnd,
|
||||
// A draft preview has no persisted `StepTechStep` row to
|
||||
// read a real `source` from at all (it isn't a saved
|
||||
// recipe yet — see `DraftRecipeStepView`'s own doc
|
||||
// comment) — always the classifier's own live match,
|
||||
// never a correction, so always "auto".
|
||||
source: "auto",
|
||||
ingredients: match.ingredients.flatMap((mention) => {
|
||||
const ingredient = ingredientById.get(mention.ingredientId);
|
||||
// Same drift guard as `techStep` above — an ingredientId
|
||||
// the matcher resolved but that's since vanished from the
|
||||
// catalog is dropped rather than shown with a hole in it.
|
||||
if (!ingredient) return [];
|
||||
return [
|
||||
{
|
||||
ingredient,
|
||||
quantity: mention.quantity,
|
||||
unit: mention.unitId !== null ? (unitById.get(mention.unitId) ?? null) : null,
|
||||
start: mention.start,
|
||||
end: mention.end,
|
||||
// Same reasoning as this match's own `source` above — a draft preview only ever holds live classifier output.
|
||||
source: "auto" as const,
|
||||
},
|
||||
];
|
||||
}),
|
||||
utensils: match.utensils.flatMap((mention) => {
|
||||
const utensil = utensilById.get(mention.utensilId);
|
||||
return utensil
|
||||
? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
|
||||
: [];
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
sourceKey,
|
||||
externalId,
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
picture: parsed.picture,
|
||||
portions: parsed.portions,
|
||||
sourceUrl: parsed.sourceUrl,
|
||||
ingredients,
|
||||
steps,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err; // see assertSourceEnabled()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes an import — the review screen (pre-filled from
|
||||
* {@link previewSourceItem}'s draft, unresolved ingredients fixed up by
|
||||
* the user via the normal `IngredientPicker`) submits `input` as a
|
||||
* regular {@link CreateRecipeInput}, exactly like a manually-authored
|
||||
* recipe. This just adds two things `createRecipe` itself can't:
|
||||
* confirming `externalId` isn't already imported (the DB's own
|
||||
* `@@unique([sourceId, externalId])` would reject a second attempt too,
|
||||
* but as a raw constraint violation — checking first gives a clean,
|
||||
* expected error instead), and stamping `sourceId`/`externalId` plus
|
||||
* matching techniques against the source's own locale
|
||||
* (`createImportedRecipe`, `recipe.service.ts`).
|
||||
*
|
||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
|
||||
* @throws {HttpError} `409 RECIPE_ALREADY_IMPORTED` if `externalId` was already imported from this source.
|
||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||
*/
|
||||
export async function importSourceItem(
|
||||
sourceKey: string,
|
||||
externalId: string,
|
||||
input: CreateRecipeInput,
|
||||
authorId: number,
|
||||
authorHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
try {
|
||||
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
|
||||
|
||||
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
|
||||
if (alreadyImported.has(externalId)) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.RECIPE_ALREADY_IMPORTED,
|
||||
`"${externalId}" from source "${sourceKey}" is already imported`,
|
||||
);
|
||||
}
|
||||
|
||||
return await createImportedRecipe(input, authorId, authorHouseId, {
|
||||
sourceId,
|
||||
externalId,
|
||||
locale: adapter.locale,
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see assertSourceEnabled()'s catch comment above
|
||||
}
|
||||
}
|
||||
123
apps/api/src/scripts/backfill-tech-steps.ts
Normal file
123
apps/api/src/scripts/backfill-tech-steps.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { pathToFileURL } from "node:url";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
||||
import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js";
|
||||
|
||||
/**
|
||||
* Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
|
||||
* entries against the *current* classifier
|
||||
* (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`),
|
||||
* the same way `updateRecipe` does when a user resaves a recipe through the UI —
|
||||
* always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's
|
||||
* no persisted per-recipe locale to recover for a step that already
|
||||
* exists, so this matches real resave behavior exactly rather than
|
||||
* guessing).
|
||||
*
|
||||
* Needed because tech-step detection only ever runs at create/update time
|
||||
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a
|
||||
* step saved before a classifier/corpus change (new vocabulary, or the
|
||||
* `contextStart`/`contextEnd` columns a previous session added) keeps
|
||||
* whatever it was matched with at the time until it's next resaved.
|
||||
*
|
||||
* `"manual"`-sourced entries (a viewer's correction, applied immediately —
|
||||
* see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`)
|
||||
* are never touched by this: only rows with `source: "auto"` are deleted
|
||||
* and recreated, and any fresh classifier match overlapping an existing
|
||||
* `"manual"` entry's span is dropped rather than inserted — a manual
|
||||
* correction is meant to *override* the classifier at that exact spot,
|
||||
* and recomputing must never silently reintroduce (or duplicate-highlight)
|
||||
* what a user already corrected. `renumberStepTechSteps`
|
||||
* (`recipe-tech-step-correction.service.ts`) folds the surviving `"auto"` +
|
||||
* untouched `"manual"` rows back into one coherent reading-order sequence
|
||||
* afterward.
|
||||
*
|
||||
* Exported (not just called from this file's own CLI guard below) so
|
||||
* `retrain-tech-steps.ts` can run it as one step of its own larger
|
||||
* maintainer workflow, without shelling out to a second process.
|
||||
*
|
||||
* Safe to re-run: with no manual entries and no corpus change since the
|
||||
* last run, this is a no-op (the same `"auto"` matches get deleted and
|
||||
* recreated identically); with manual entries present, they're preserved
|
||||
* on every run by construction.
|
||||
*/
|
||||
export async function backfillTechSteps(): Promise<{ total: number; changed: number }> {
|
||||
const steps = await prisma.step.findMany({ select: { id: true, description: true } });
|
||||
console.info(`Recomputing tech steps for ${steps.length} step(s)...`);
|
||||
|
||||
let changed = 0;
|
||||
for (const step of steps) {
|
||||
const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr");
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const manualRows = await tx.stepTechStep.findMany({
|
||||
where: { stepId: step.id, source: "manual" },
|
||||
});
|
||||
|
||||
const nonOverlappingMatches = matches.filter(
|
||||
(match) =>
|
||||
!manualRows.some(
|
||||
(manual) =>
|
||||
manual.start !== null &&
|
||||
manual.end !== null &&
|
||||
manual.start < match.end &&
|
||||
match.start < manual.end,
|
||||
),
|
||||
);
|
||||
|
||||
await tx.stepTechStep.deleteMany({ where: { stepId: step.id, source: "auto" } });
|
||||
|
||||
if (nonOverlappingMatches.length > 0) {
|
||||
// Placeholder orders, disjoint from the untouched manual rows'
|
||||
// existing ones (`renumberStepTechSteps` below folds everything
|
||||
// into a clean 0..N-1 sequence right after — these just need to
|
||||
// not collide with `@@id([stepId, order])` for this insert).
|
||||
const startOrder = manualRows.reduce((max, row) => Math.max(max, row.order), -1) + 1;
|
||||
await tx.stepTechStep.createMany({
|
||||
data: nonOverlappingMatches.map((match, index) => ({
|
||||
stepId: step.id,
|
||||
techStepId: match.techStepId,
|
||||
order: startOrder + index,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
contextStart: match.contextStart,
|
||||
contextEnd: match.contextEnd,
|
||||
source: "auto",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await renumberStepTechSteps(tx, step.id);
|
||||
});
|
||||
changed += 1;
|
||||
}
|
||||
|
||||
console.info(`Done — ${changed} step(s) recomputed.`);
|
||||
return { total: steps.length, changed };
|
||||
}
|
||||
|
||||
// Only runs when this file is executed directly (`tsx
|
||||
// src/scripts/backfill-tech-steps.ts`), not when `retrain-tech-steps.ts`
|
||||
// imports `backfillTechSteps` above — the standard ESM "is this the entry
|
||||
// module" check, first needed in this codebase by that new script; every
|
||||
// prior script here (`seed-runtime.ts`) was always only ever run directly,
|
||||
// never imported. `pathToFileURL` (not a naive `` `file://${process.argv[1]}` ``
|
||||
// concatenation) is required for this to actually work on Windows — a
|
||||
// native Windows path (backslashes, no leading slash before the drive
|
||||
// letter) doesn't survive being pasted directly after `file://`, so the
|
||||
// comparison against `import.meta.url` (already a real, correctly-escaped
|
||||
// `file:///D:/...` URL) always came out false: this guard silently never
|
||||
// matched, so running this script directly (`tsx
|
||||
// src/scripts/backfill-tech-steps.ts`) did *nothing* — no error, no
|
||||
// output, `backfillTechSteps()` simply never called — found only by
|
||||
// running it for real and noticing zero output where several log lines
|
||||
// were expected.
|
||||
const isMainModule = import.meta.url === pathToFileURL(process.argv[1] ?? "").href;
|
||||
if (isMainModule) {
|
||||
backfillTechSteps()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (err) => {
|
||||
console.error(err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
101
apps/api/src/scripts/calibrate-tech-step-threshold.ts
Normal file
101
apps/api/src/scripts/calibrate-tech-step-threshold.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { prisma } from "../db/prisma.js";
|
||||
import { TECH_STEP_EVAL_DATASET } from "../lib/recipe-matching/tech-step-eval-dataset.js";
|
||||
import {
|
||||
computeTechStepMetrics,
|
||||
type TechStepEvalOutcome,
|
||||
} from "../lib/recipe-matching/tech-step-evaluator.js";
|
||||
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
||||
|
||||
/**
|
||||
* Candidate thresholds to sweep, `0.05` to `0.95` in `0.05` steps — fine
|
||||
* enough to find a good value without an unreasonable number of full
|
||||
* `TECH_STEP_EVAL_DATASET` passes (each threshold only needs one
|
||||
* {@link techStepClassifier.classifyClauses} call per eval case, not a
|
||||
* retrain — see this file's own doc comment for why).
|
||||
*/
|
||||
const CANDIDATE_THRESHOLDS = Array.from({ length: 19 }, (_, i) => Math.round((i + 1) * 5) / 100);
|
||||
|
||||
/**
|
||||
* One-off maintainer tool for recalibrating `CONFIDENCE_THRESHOLD`
|
||||
* (`tech-step-matcher.ts`) after a change to the underlying intent
|
||||
* classifier — most notably, the migration from `node-nlp` to
|
||||
* `services/tech-step-intent-service` (spaCy): a different model produces a
|
||||
* differently-shaped confidence score distribution, so a threshold tuned
|
||||
* against the old classifier has no reason to still be the right cutoff for
|
||||
* the new one.
|
||||
*
|
||||
* Reuses `techStepClassifier.classifyClauses` — already public, and
|
||||
* deliberately *not* threshold-applied (see that method's own doc comment)
|
||||
* — to get every eval case's raw `{anchorUid, intentUid, score}` per clause
|
||||
* exactly once, then replays `_classifyClause`'s own decision rule
|
||||
* (`intentUid` if confident enough, `anchorUid` otherwise) locally in this
|
||||
* script for every candidate threshold. This is what makes a full sweep
|
||||
* cheap: one classifier pass per eval case regardless of how many
|
||||
* thresholds are being compared, rather than one full pass *per threshold*.
|
||||
*
|
||||
* Prints a threshold -> precision/recall/F1 table and the threshold that
|
||||
* maximizes aggregate F1 — does **not** edit `tech-step-matcher.ts` itself.
|
||||
* A maintainer reads the table, updates `CONFIDENCE_THRESHOLD` by hand (with
|
||||
* an updated doc comment recording what run/F1 the new value was calibrated
|
||||
* against, same as the existing comment's own format), then re-runs
|
||||
* `retrain-tech-steps.ts` to confirm the change clears `MIN_OVERALL_F1`.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* pnpm --filter api exec tsx src/scripts/calibrate-tech-step-threshold.ts
|
||||
*/
|
||||
async function calibrateTechStepThreshold(): Promise<void> {
|
||||
console.info(`Classifying ${TECH_STEP_EVAL_DATASET.length} eval case(s)...`);
|
||||
|
||||
// One classifier pass per eval case, all clauses' raw verdicts kept
|
||||
// alongside the case's own `expectedKeys` — reused for every candidate
|
||||
// threshold in the loop below.
|
||||
const casesWithClauses = await Promise.all(
|
||||
TECH_STEP_EVAL_DATASET.map(async (evalCase) => ({
|
||||
expectedKeys: evalCase.expectedKeys,
|
||||
clauses: await techStepClassifier.classifyClauses(evalCase.description, evalCase.locale),
|
||||
})),
|
||||
);
|
||||
|
||||
console.info("\nthreshold precision recall f1");
|
||||
let bestThreshold = CANDIDATE_THRESHOLDS[0] ?? 0;
|
||||
let bestF1 = -1;
|
||||
|
||||
for (const threshold of CANDIDATE_THRESHOLDS) {
|
||||
const outcomes: TechStepEvalOutcome[] = casesWithClauses.map(({ expectedKeys, clauses }) => {
|
||||
const actualKeys = clauses
|
||||
// Mirrors `_classifyClause`'s own decision rule exactly (see that
|
||||
// method, `tech-step-matcher.ts`) — the classifier's own verdict
|
||||
// when confident enough, otherwise its clause's NER anchor, `null`
|
||||
// when neither applies (no keyword, no confident classification).
|
||||
.map((clause) =>
|
||||
clause.intentUid !== null && clause.score >= threshold
|
||||
? clause.intentUid
|
||||
: clause.anchorUid,
|
||||
)
|
||||
.filter((key): key is string => key !== null);
|
||||
return { expectedKeys, actualKeys };
|
||||
});
|
||||
|
||||
const { overall } = computeTechStepMetrics(outcomes);
|
||||
console.info(
|
||||
`${threshold.toFixed(2)} ${overall.precision.toFixed(3)} ${overall.recall.toFixed(3)} ${overall.f1.toFixed(3)}`,
|
||||
);
|
||||
if (overall.f1 > bestF1) {
|
||||
bestF1 = overall.f1;
|
||||
bestThreshold = threshold;
|
||||
}
|
||||
}
|
||||
|
||||
console.info(
|
||||
`\nBest aggregate F1 ${bestF1.toFixed(3)} at threshold ${bestThreshold.toFixed(2)} — update CONFIDENCE_THRESHOLD in tech-step-matcher.ts by hand if this differs from the current value.`,
|
||||
);
|
||||
}
|
||||
|
||||
calibrateTechStepThreshold()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (err) => {
|
||||
console.error(err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
72
apps/api/src/scripts/list-pending-training-suggestions.ts
Normal file
72
apps/api/src/scripts/list-pending-training-suggestions.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { prisma } from "../db/prisma.js";
|
||||
|
||||
/**
|
||||
* Maintainer-facing report of every `TechStepTrainingSuggestion` still
|
||||
* `status: "pending"` (`TechStepTrainingSuggestion`'s own schema doc
|
||||
* comment) — generated by `services/tech-step-llm-worker`'s scheduled
|
||||
* jobs, from either a user correction or the worker's own low-confidence
|
||||
* audit (`sourceType`). What a maintainer reads *before* hand-editing
|
||||
* `services/tech-step-intent-service/intent_service/training_data.py` and
|
||||
* running `retrain-tech-steps.ts` — this script never writes anything,
|
||||
* purely a read-only report to stdout:
|
||||
*
|
||||
* pnpm --filter api exec tsx src/scripts/list-pending-training-suggestions.ts
|
||||
*
|
||||
* Grouped by technique key so every suggestion for the same entry in
|
||||
* `training_data.py`'s `TECH_STEP_TRAINING_DATA` is read together, matching
|
||||
* how that file itself is organized (one block per technique).
|
||||
*/
|
||||
async function listPendingTrainingSuggestions(): Promise<void> {
|
||||
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
|
||||
where: { status: "pending" },
|
||||
orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
|
||||
include: { techStep: { select: { key: true } } },
|
||||
});
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
console.info("No pending training suggestions.");
|
||||
return;
|
||||
}
|
||||
|
||||
const byTechStepKey = new Map<string, typeof suggestions>();
|
||||
for (const suggestion of suggestions) {
|
||||
const key = suggestion.techStep.key;
|
||||
const group = byTechStepKey.get(key);
|
||||
if (group) {
|
||||
group.push(suggestion);
|
||||
} else {
|
||||
byTechStepKey.set(key, [suggestion]);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [`# Pending tech-step training suggestions (${suggestions.length})`, ""];
|
||||
for (const [techStepKey, group] of byTechStepKey) {
|
||||
lines.push(`## ${techStepKey}`, "");
|
||||
for (const suggestion of group) {
|
||||
const source =
|
||||
suggestion.sourceCorrectionId !== null
|
||||
? `${suggestion.sourceType} (correction #${suggestion.sourceCorrectionId})`
|
||||
: suggestion.sourceType;
|
||||
lines.push(`- id ${suggestion.id} · locale ${suggestion.locale} · source: ${source}`);
|
||||
if (suggestion.suggestedSynonyms.length > 0) {
|
||||
lines.push(` - synonyms: ${suggestion.suggestedSynonyms.join(", ")}`);
|
||||
}
|
||||
if (suggestion.suggestedUtterances.length > 0) {
|
||||
lines.push(
|
||||
` - utterances: ${suggestion.suggestedUtterances.map((u) => `"${u}"`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
console.info(lines.join("\n"));
|
||||
}
|
||||
|
||||
listPendingTrainingSuggestions()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (err) => {
|
||||
console.error(err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
103
apps/api/src/scripts/retrain-tech-steps.ts
Normal file
103
apps/api/src/scripts/retrain-tech-steps.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { prisma } from "../db/prisma.js";
|
||||
import {
|
||||
MIN_OVERALL_F1,
|
||||
runTechStepEvalSuite,
|
||||
} from "../lib/recipe-matching/tech-step-eval-runner.js";
|
||||
import { backfillTechSteps } from "./backfill-tech-steps.js";
|
||||
|
||||
/** Parses `--applied=1,2,3`/`--rejected=4,5` from argv into id arrays — both optional, both empty by default (a run with neither flag only re-gates + backfills, doesn't touch any suggestion's status). */
|
||||
function parseSuggestionIds(flag: "applied" | "rejected"): number[] {
|
||||
const prefix = `--${flag}=`;
|
||||
const arg = process.argv.find((value) => value.startsWith(prefix));
|
||||
if (arg === undefined) return [];
|
||||
return arg
|
||||
.slice(prefix.length)
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0)
|
||||
.map((value) => {
|
||||
const id = Number(value);
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error(`--${flag}: "${value}" is not a valid integer id`);
|
||||
}
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintainer workflow closing the loop on a training-corpus change (see
|
||||
* this feature's plan document):
|
||||
*
|
||||
* 1. A maintainer has already hand-edited
|
||||
* `services/tech-step-intent-service/intent_service/training_data.py`
|
||||
* (informed by `list-pending-training-suggestions.ts`'s report),
|
||||
* decided which `TechStepTrainingSuggestion` ids they incorporated
|
||||
* (`--applied=`) or explicitly discarded (`--rejected=`), **and
|
||||
* restarted `tech-step-intent-service`** so it retrains from the
|
||||
* edited corpus — that service only ever trains once, at its own
|
||||
* startup (see its README), so this script's eval gate below is
|
||||
* meaningless against a service still running the old corpus.
|
||||
* 2. This script re-runs the F1 regression gate
|
||||
* ({@link runTechStepEvalSuite} against {@link MIN_OVERALL_F1}) —
|
||||
* refuses to backfill at all if the edited corpus scores worse than
|
||||
* the floor, so a bad edit never reaches every existing recipe.
|
||||
* 3. Backfills every `Step`'s `StepTechStep` sequence against the new
|
||||
* corpus ({@link backfillTechSteps}).
|
||||
* 4. Marks the given suggestion ids `applied`/`rejected`, so
|
||||
* `list-pending-training-suggestions.ts`'s next report doesn't
|
||||
* surface them again.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* pnpm --filter api exec tsx src/scripts/retrain-tech-steps.ts --applied=12,13 --rejected=14
|
||||
*
|
||||
* `--applied`/`--rejected` are both optional — omitting both still runs
|
||||
* the gate + backfill, just leaves every suggestion's `status` untouched
|
||||
* (useful for re-running the backfill alone after a corpus edit made with
|
||||
* no suggestions involved at all).
|
||||
*/
|
||||
async function retrainTechSteps(): Promise<void> {
|
||||
const appliedIds = parseSuggestionIds("applied");
|
||||
const rejectedIds = parseSuggestionIds("rejected");
|
||||
|
||||
console.info("Evaluating the current classifier against the labeled evaluation set...");
|
||||
const { overall } = await runTechStepEvalSuite();
|
||||
console.info(
|
||||
`F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)})`,
|
||||
);
|
||||
if (overall.f1 < MIN_OVERALL_F1) {
|
||||
throw new Error(
|
||||
`Aggregate F1 ${overall.f1.toFixed(3)} is below the ${MIN_OVERALL_F1} regression floor — refusing to backfill. Revert or fix the corpus change and re-run.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { total, changed } = await backfillTechSteps();
|
||||
console.info(`Backfilled ${changed}/${total} step(s).`);
|
||||
|
||||
if (appliedIds.length > 0) {
|
||||
// `updateMany`'s own `count` (rows actually matched/updated), not
|
||||
// `appliedIds.length` (what was merely *asked for*) — an id that
|
||||
// doesn't exist (typo, already-processed id) would otherwise log a
|
||||
// success count that silently doesn't match what really changed.
|
||||
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
|
||||
where: { id: { in: appliedIds } },
|
||||
data: { status: "applied" },
|
||||
});
|
||||
console.info(`Marked ${count}/${appliedIds.length} suggestion(s) as applied.`);
|
||||
}
|
||||
if (rejectedIds.length > 0) {
|
||||
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
|
||||
where: { id: { in: rejectedIds } },
|
||||
data: { status: "rejected" },
|
||||
});
|
||||
console.info(`Marked ${count}/${rejectedIds.length} suggestion(s) as rejected.`);
|
||||
}
|
||||
}
|
||||
|
||||
retrainTechSteps()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (err) => {
|
||||
console.error(err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
43
apps/api/src/scripts/seed-runtime.ts
Normal file
43
apps/api/src/scripts/seed-runtime.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { prisma } from "../db/prisma.js";
|
||||
import { syncRecipeSources } from "../db/recipe-source-sync.js";
|
||||
import { seedReferenceData } from "../db/reference-seed-data.js";
|
||||
import { registerAllRecipeSources } from "../sources/index.js";
|
||||
|
||||
/**
|
||||
* Runtime seed entry point for the production Docker image — run via
|
||||
* `node dist/scripts/seed-runtime.js` in apps/api/Dockerfile's CMD, after
|
||||
* `prisma migrate deploy` and before starting the server.
|
||||
*
|
||||
* Deliberately separate from `prisma/seed.ts` (the dev-time entry point
|
||||
* wired to `prisma db seed`/`prisma migrate reset`): that one imports
|
||||
* `seedReferenceData` from `../src/db/...` and runs via `tsx`, but the
|
||||
* runtime image only ships compiled `dist` output, not `src` (see the
|
||||
* Dockerfile) — this lives under `src/` instead, so `tsc` compiles it
|
||||
* alongside everything else, and it runs under plain `node`, no tsx
|
||||
* needed at runtime.
|
||||
*
|
||||
* Also registers and syncs the recipe-source registry
|
||||
* (`registerAllRecipeSources`/`syncRecipeSources`) — mirroring
|
||||
* `prisma/seed.ts`'s own two calls. Without this, `server.ts`'s own
|
||||
* `registerAllRecipeSources()` call only populates *that* process' in-memory
|
||||
* registry (each `node` invocation in the Docker CMD chain is a separate
|
||||
* process), so the `Source` table itself would stay permanently empty in
|
||||
* production and `GET /reference/sources` would always return `[]` — which
|
||||
* is exactly what silently hid the whole "sources" section of
|
||||
* `HouseholdSettingsPage` (`apps/web`) until this was added.
|
||||
*
|
||||
* Safe to run on every container start: `seedReferenceData` upserts by
|
||||
* each row's unique name, and `syncRecipeSources` is equally idempotent
|
||||
* (see its own doc comment) — re-running both against a database that
|
||||
* already has this data is a no-op.
|
||||
*/
|
||||
registerAllRecipeSources();
|
||||
|
||||
seedReferenceData(prisma)
|
||||
.then(() => syncRecipeSources(prisma))
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (err) => {
|
||||
console.error(err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue