Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
328 changed files with 3179 additions and 47917 deletions
|
|
@ -6,12 +6,6 @@
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "pnpm",
|
||||||
"runtimeArgs": ["--filter", "web", "dev"],
|
"runtimeArgs": ["--filter", "web", "dev"],
|
||||||
"port": 5173
|
"port": 5173
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "api",
|
|
||||||
"runtimeExecutable": "pnpm",
|
|
||||||
"runtimeArgs": ["--filter", "api", "dev"],
|
|
||||||
"port": 3000
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
.env.example
27
.env.example
|
|
@ -14,30 +14,3 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# Optional — host port for the Docker review stack's single app container
|
# Optional — host port for the Docker review stack's single app container
|
||||||
# (docker-compose.yml), serving both the API and the built frontend.
|
# (docker-compose.yml), serving both the API and the built frontend.
|
||||||
# APP_PORT=3000
|
# APP_PORT=3000
|
||||||
|
|
||||||
# Optional — 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
|
|
||||||
|
|
|
||||||
96
.github/workflows/ci.yml
vendored
96
.github/workflows/ci.yml
vendored
|
|
@ -12,32 +12,22 @@ on:
|
||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
|
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
||||||
# Test-only secret, never used outside CI — real deployments must set their own.
|
# Test-only secret, never used outside CI — real deployments must set their own.
|
||||||
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
||||||
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
|
|
||||||
# exercise the success path (matching secret), not just the "unset"
|
|
||||||
# rejection every environment that doesn't set this gets by default.
|
|
||||||
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
|
|
||||||
# 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:
|
jobs:
|
||||||
# Five independent jobs, no needs: between them — each starts in parallel
|
# Four independent jobs, no needs: between them — each starts in parallel
|
||||||
# and reports as its own check, instead of the previous single chained
|
# and reports as its own check, instead of the previous single chained
|
||||||
# "lint-and-test then e2e" pipeline.
|
# "lint-and-test then e2e" pipeline.
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -55,80 +45,35 @@ jobs:
|
||||||
POSTGRES_PASSWORD: ci
|
POSTGRES_PASSWORD: ci
|
||||||
POSTGRES_DB: batchcooking_ci
|
POSTGRES_DB: batchcooking_ci
|
||||||
ports:
|
ports:
|
||||||
- 5433:5432
|
- 5432:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- 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 install --frozen-lockfile
|
||||||
- run: pnpm --filter api exec prisma migrate deploy
|
- run: pnpm --filter api exec prisma migrate deploy
|
||||||
- run: pnpm --filter api test
|
- run: pnpm --filter api test
|
||||||
|
- run: pnpm --filter api test:bdd
|
||||||
intent-service-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
enable-cache: true
|
|
||||||
|
|
||||||
- name: Install services/tech-step-intent-service
|
|
||||||
working-directory: services/tech-step-intent-service
|
|
||||||
run: uv sync --frozen
|
|
||||||
- name: Run pytest
|
|
||||||
working-directory: services/tech-step-intent-service
|
|
||||||
run: uv run pytest -q
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -139,22 +84,17 @@ jobs:
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Xvfb and Cypress dependencies
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth dbus-x11
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Cache Cypress binary
|
- name: Cache Cypress binary
|
||||||
uses: https://github.com/actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/Cypress
|
path: ~/.cache/Cypress
|
||||||
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
@ -164,12 +104,4 @@ jobs:
|
||||||
# download (see apps/web's cypress caveat in the README) — install it
|
# download (see apps/web's cypress caveat in the README) — install it
|
||||||
# explicitly so `cypress run` finds it.
|
# explicitly so `cypress run` finds it.
|
||||||
- run: pnpm --filter web exec cypress install
|
- run: pnpm --filter web exec cypress install
|
||||||
- name: Run E2E tests
|
- run: pnpm --filter web e2e
|
||||||
env:
|
|
||||||
HOST: "0.0.0.0"
|
|
||||||
run: pnpm --filter web e2e
|
|
||||||
# No dev server needed here — Cypress spins up its own Vite dev
|
|
||||||
# server internally for component testing (see cypress.config.ts's
|
|
||||||
# `component.devServer`), unlike `e2e` above which needs the real app
|
|
||||||
# running first.
|
|
||||||
- run: pnpm --filter web cy:run:component
|
|
||||||
|
|
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -69,14 +69,6 @@ web_modules/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.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/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
|
|
@ -158,8 +150,3 @@ tmp-mockups/
|
||||||
|
|
||||||
# IA
|
# IA
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Cypress run artifacts — regenerated locally/in CI, never meant to be committed
|
|
||||||
apps/web/cypress/screenshots/
|
|
||||||
apps/web/cypress/videos/
|
|
||||||
apps/web/cypress/downloads/
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Instructions du projet
|
|
||||||
|
|
||||||
Avant toute action de génération de code, lis attentivement le fichier `specs\dev-conventions.md` pour prendre connaissance de l'ensemble des normes de développement à appliquer impérativement
|
|
||||||
465
README.md
465
README.md
|
|
@ -4,21 +4,13 @@
|
||||||
|
|
||||||
Monorepo pnpm workspaces :
|
Monorepo pnpm workspaces :
|
||||||
|
|
||||||
- `apps/api` — backend Express/TypeScript : auth, foyer, planning (grille de la
|
- `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha + Cucumber/BDD)
|
||||||
semaine), catalogue de recettes (favoris/perso/foyer/publique), import de
|
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
|
||||||
recettes depuis des sources externes, préférences (thème, régime, allergies,
|
Page de connexion/inscription en place ; le reste est encore un squelette générique.
|
||||||
ingrédients détestés). Tests Mocha (base Postgres réelle, isolée de la base
|
- `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
|
||||||
de dev — voir plus bas).
|
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
|
||||||
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par
|
numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
|
||||||
Capacitor plus tard. Espace connecté complet (planning, recettes, réglages)
|
même règles des deux côtés, pas de risque de dérive entre front et back.
|
||||||
derrière une sidebar, wizard d'inscription, thème clair/sombre/système.
|
|
||||||
- `packages/shared` — code partagé entre `api` et `web` : schémas zod, types
|
|
||||||
(`RecipeView`, `PlanningView`, `HouseView`, `SafeUserProfile`...), le contrat
|
|
||||||
d'erreurs (`ErrorCode` numérique, `ApiErrorResponse`, voir
|
|
||||||
[specs/error-handling.md](specs/error-handling.md)) et les libellés anglais
|
|
||||||
du catalogue d'ingrédients (`data/catalog-labels-en.ts`, utilisés par le
|
|
||||||
matching de recettes importées — voir plus bas) — même règles des deux
|
|
||||||
côtés, pas de risque de dérive entre front et back.
|
|
||||||
- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
|
- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
|
||||||
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
|
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
|
||||||
d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
|
d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
|
||||||
|
|
@ -27,16 +19,11 @@ Monorepo pnpm workspaces :
|
||||||
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
|
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
|
||||||
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
|
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
|
||||||
pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
|
pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
|
||||||
- `packages/date-tools` — utilitaires de date partagés (Luxon) : convention
|
|
||||||
"date-only = minuit UTC" (`parseDateOnly`/`formatDateOnly`/`toDateOnly`),
|
|
||||||
calcul de semaine lundi-first (`getWeekStart`/`addWeeks`/`buildCalendarMonth`)
|
|
||||||
— utilisés à la fois par `apps/api` (validation de date de planning) et
|
|
||||||
`apps/web` (grille/navigateur de semaine).
|
|
||||||
|
|
||||||
`packages/shared`, `packages/error-tools`, `packages/express-tools` et
|
`packages/shared`, `packages/error-tools` et `packages/express-tools` ont un vrai
|
||||||
`packages/date-tools` ont un vrai build (`tsc` → `dist/`, voir leur
|
build (`tsc` → `dist/`, voir leur `package.json`) : consommés en JS compilé, pas en
|
||||||
`package.json`) : consommés en JS compilé, pas en TS brut — nécessaire pour un
|
TS brut — nécessaire pour un runtime Node pur (Docker, pas de transpilation à la
|
||||||
runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
|
volée), voir la note dans
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
|
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
@ -44,8 +31,6 @@ runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
|
||||||
- Node.js 22 (voir `.nvmrc`)
|
- Node.js 22 (voir `.nvmrc`)
|
||||||
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
|
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
|
||||||
- Docker (pour Postgres en local)
|
- Docker (pour Postgres en local)
|
||||||
- Python 3.12+ et [`uv`](https://docs.astral.sh/uv/) (pour
|
|
||||||
`services/tech-step-intent-service` en dev natif — requis, voir plus bas)
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -64,9 +49,6 @@ sont pas définis dans `.env` — pas de valeur par défaut en dur dans les fich
|
||||||
Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le
|
Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le
|
||||||
tien, voir le commentaire dans `apps/api/.env.example`).
|
tien, voir le commentaire dans `apps/api/.env.example`).
|
||||||
|
|
||||||
Si tu comptes lancer `pnpm --filter api test` (voir [Qualité / Tests](#qualité--tests)),
|
|
||||||
crée aussi `apps/api/.env.test` — voir la section dédiée plus bas.
|
|
||||||
|
|
||||||
### Cypress : téléchargement du binaire
|
### Cypress : téléchargement du binaire
|
||||||
|
|
||||||
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
|
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
|
||||||
|
|
@ -93,26 +75,11 @@ hors du repo).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Base de données Postgres locale
|
# Base de données Postgres locale
|
||||||
docker compose up -d postgres
|
docker compose up -d
|
||||||
|
|
||||||
# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
|
# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
|
||||||
pnpm --filter api exec prisma migrate dev
|
pnpm --filter api exec prisma migrate dev
|
||||||
|
|
||||||
# Peuple les données de référence (régimes, allergènes, ingrédients, unités,
|
|
||||||
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
|
|
||||||
pnpm --filter api prisma:seed
|
|
||||||
|
|
||||||
# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api`
|
|
||||||
# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en
|
|
||||||
# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage
|
|
||||||
# (~11 minutes pour le corpus actuel, voir son propre README) avant de
|
|
||||||
# répondre quoi que ce soit sur /health.
|
|
||||||
cd services/tech-step-intent-service
|
|
||||||
uv sync
|
|
||||||
cp .env.example .env # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env
|
|
||||||
uv run uvicorn intent_service.main:app --reload --port 8000
|
|
||||||
cd ../..
|
|
||||||
|
|
||||||
# Backend (http://localhost:3000)
|
# Backend (http://localhost:3000)
|
||||||
pnpm dev:api
|
pnpm dev:api
|
||||||
|
|
||||||
|
|
@ -127,56 +94,43 @@ pnpm dev:web
|
||||||
> Dans ce cas, mets `POSTGRES_PORT=5433` (ou autre) dans ton `.env` **et** adapte le
|
> Dans ce cas, mets `POSTGRES_PORT=5433` (ou autre) dans ton `.env` **et** adapte le
|
||||||
> port dans la `DATABASE_URL` de `apps/api/.env`.
|
> port dans la `DATABASE_URL` de `apps/api/.env`.
|
||||||
|
|
||||||
> **Toujours cibler `postgres`, jamais `docker compose up -d` tout court.** Le même
|
|
||||||
> `docker-compose.yml` définit aussi le service `app` (voir [Déploiement](#déploiement)) —
|
|
||||||
> celui que Portainer construit en production. Sans nom de service, `docker compose up -d`
|
|
||||||
> démarre les deux : ça déclenche un `pnpm install` sur tout le monorepo (donc aussi le
|
|
||||||
> `cypress` d'`apps/web`, avec son téléchargement de binaire) rien que pour builder une
|
|
||||||
> image dont le dev local n'a pas besoin (on sert le front/back directement via
|
|
||||||
> `pnpm dev:web`/`pnpm dev:api`, pas ce conteneur).
|
|
||||||
|
|
||||||
## Qualité / Tests
|
## Qualité / Tests
|
||||||
|
|
||||||
Conventions de code (classes vs objets littéraux, préfixe `_` sur les membres
|
|
||||||
privés, règles Biome actives, logs côté serveur, etc.) :
|
|
||||||
[specs/dev-conventions.md](specs/dev-conventions.md).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm lint # Biome (lint + format check)
|
pnpm lint # Biome (lint + format check)
|
||||||
pnpm lint:fix # Biome --write
|
pnpm lint:fix # Biome --write
|
||||||
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
||||||
pnpm --filter web e2e # tests e2e (Cypress + Cucumber, démarre le serveur dev automatiquement)
|
pnpm --filter api test:bdd # tests d'intégration BDD (Cucumber/Gherkin, apps/api)
|
||||||
pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress component testing)
|
pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
|
||||||
pnpm build # build de tous les workspaces
|
pnpm build # build de tous les workspaces
|
||||||
```
|
```
|
||||||
|
|
||||||
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute cinq jobs indépendants (`lint`, `test`, `intent-service-test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. `test` démarre `services/tech-step-intent-service` en arrière-plan (voir ce fichier) puisque la suite Mocha ne mocke jamais un service interne. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
||||||
|
|
||||||
### Base de test isolée de la base de dev (`apps/api`)
|
### Cucumber (apps/api)
|
||||||
|
|
||||||
`pnpm --filter api test` exécute une `TRUNCATE ... CASCADE` sur presque tout le
|
Tests d'intégration lisibles en Gherkin, en complément de Mocha (qui reste pour les
|
||||||
schéma **avant chaque test** (`test-support/reset-db.ts`). Pour ne jamais
|
tests unitaires purs) :
|
||||||
risquer de vider une vraie base de dev locale, `NODE_ENV=test` (posé par le
|
|
||||||
script `test`) fait charger `apps/api/.env.test` au lieu de `.env` — un
|
|
||||||
fichier **à créer toi-même**, pas fourni automatiquement :
|
|
||||||
|
|
||||||
```bash
|
- `apps/api/features/*.feature` — scénarios en Given/When/Then (`health.feature` sert
|
||||||
cp apps/api/.env.test.example apps/api/.env.test
|
d'exemple)
|
||||||
# puis édite-le : mêmes identifiants Postgres que ton .env, mais une base
|
- `apps/api/features/step-definitions/*.steps.ts` — implémentation des steps
|
||||||
# différente (ex. batchcooking_test) — .env.test.example documente les
|
- `apps/api/features/support/world.ts` — contexte partagé entre les steps d'un
|
||||||
# commandes exactes pour la créer et lui appliquer le schéma.
|
scénario (instancie l'app Express in-process via `createApp()`, comme le fait déjà
|
||||||
```
|
supertest côté Mocha — pas besoin de lancer un vrai serveur)
|
||||||
|
- `apps/api/cucumber.cjs` — config (extension `.cjs` volontaire, voir la remarque
|
||||||
|
TypeScript/ESM ci-dessous)
|
||||||
|
|
||||||
Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
|
Pour ajouter un scénario : écrire le `.feature`, lancer `pnpm --filter api test:bdd`,
|
||||||
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
implémenter les steps manquants (Cucumber affiche des snippets tout prêts pour ceux
|
||||||
seule base qu'il doit rejeter est ta vraie base de dev.
|
qui n'existent pas encore).
|
||||||
|
|
||||||
`services/tech-step-intent-service` doit aussi tourner en local avant
|
> **Piège TypeScript/ESM à connaître** (déjà rencontré avec `cypress.config.ts`) :
|
||||||
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
|
> les fichiers de config d'outils tiers qui font du chargement dynamique de TS
|
||||||
par le vrai service (jamais un mock, voir
|
> (`cucumber.cjs`, `cypress.config.ts`…) sont sensibles au `"type": "module"` du
|
||||||
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
|
> `package.json`. `cucumber.cjs` évite le problème *pour sa propre config* en étant
|
||||||
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
|
> explicitement CommonJS ; les steps/world restent en `.ts` ESM classique et sont
|
||||||
section [Développement](#développement) ci-dessus.
|
> chargés via `tsx` (`NODE_OPTIONS=--import=tsx`, voir le script `test:bdd`).
|
||||||
|
|
||||||
## Déploiement
|
## Déploiement
|
||||||
|
|
||||||
|
|
@ -190,22 +144,10 @@ client) via `FRONTEND_DIST_DIR` — voir `packages/express-tools/src/express-ser
|
||||||
en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir
|
en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir
|
||||||
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.
|
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.
|
||||||
|
|
||||||
Le `CMD` de l'image enchaîne trois étapes, chacune dans son propre processus
|
`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un
|
||||||
`node` : `prisma migrate deploy` (applique les migrations), puis
|
seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
||||||
`node dist/scripts/seed-runtime.js` (seed des données de référence **et**
|
coordonner entre deux origines, le frontend et l'API sont désormais servis depuis
|
||||||
synchronisation de la table `sources` depuis le registre d'adaptateurs de code
|
la même origine).
|
||||||
— nécessaire à chaque démarrage : le registre en mémoire peuplé par
|
|
||||||
`server.ts` ne survit pas au changement de processus, voir
|
|
||||||
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation)),
|
|
||||||
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
|
|
||||||
répéter à chaque redémarrage du conteneur.
|
|
||||||
|
|
||||||
Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port
|
|
||||||
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
|
**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
|
production est reliée directement au dépôt Git et reconstruit elle-même
|
||||||
|
|
@ -242,14 +184,10 @@ Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpO
|
||||||
l'email ou le mot de passe qui soit incorrect
|
l'email ou le mot de passe qui soit incorrect
|
||||||
- `POST /auth/logout` — efface le cookie (204)
|
- `POST /auth/logout` — efface le cookie (204)
|
||||||
- `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon)
|
- `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon)
|
||||||
- `DELETE /auth/me` — supprime définitivement le compte après re-saisie du mot
|
|
||||||
de passe (`{ password }`, 401 `INVALID_CREDENTIALS` si incorrect) ; gère le
|
|
||||||
départ/transfert d'adminship du foyer avant suppression (voir Foyer plus bas)
|
|
||||||
|
|
||||||
Mots de passe hachés avec argon2. `UserProfile.tokenVersion` existe pour
|
Mots de passe hachés avec argon2. Le hash est indépendant du foyer : un profil crée
|
||||||
invalider les JWT déjà émis (ex. futur changement de mot de passe) mais rien
|
toujours son propre foyer à l'inscription (rejoindre un foyer existant n'est pas
|
||||||
ne l'incrémente encore — pas de route de changement d'email/mot de passe
|
encore implémenté).
|
||||||
aujourd'hui, seulement la suppression de compte.
|
|
||||||
|
|
||||||
> **argon2 : version pinnée à `0.31.2`, pas de `^`.** La version `0.45.1` (dernière au
|
> **argon2 : version pinnée à `0.31.2`, pas de `^`.** La version `0.45.1` (dernière au
|
||||||
> moment de l'écriture) segfault au runtime sur au moins une configuration Windows —
|
> moment de l'écriture) segfault au runtime sur au moins une configuration Windows —
|
||||||
|
|
@ -258,184 +196,179 @@ aujourd'hui, seulement la suppression de compte.
|
||||||
> concrètement (`argon2.hash(...)` dans un `node -e`) avant de merger, un `pnpm build`
|
> concrètement (`argon2.hash(...)` dans un `node -e`) avant de merger, un `pnpm build`
|
||||||
> qui passe ne suffit pas à détecter un crash runtime.
|
> qui passe ne suffit pas à détecter un crash runtime.
|
||||||
|
|
||||||
Les tests (Mocha) tournent avec un coût argon2 réduit
|
Les tests (Mocha + Cucumber) tournent avec un coût argon2 réduit
|
||||||
(`NODE_ENV=test`, voir `auth.service.ts`) — le coût par défaut est volontairement
|
(`NODE_ENV=test`, voir `auth.service.ts`) — le coût par défaut est volontairement
|
||||||
élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI
|
élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI
|
||||||
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
||||||
`prisma migrate deploy` avant les tests.
|
`prisma migrate deploy` avant les tests.
|
||||||
|
|
||||||
## Foyer — création, invitation, admin, sources externes (apps/api)
|
> **Les tests automatisés et `pnpm dev:api` partagent la même base Postgres locale.**
|
||||||
|
> Lancer `pnpm test`/`test:bdd` **vide `user_profiles`/`house`** (`TRUNCATE ... CASCADE`,
|
||||||
Un foyer (`house`) a un admin (`adminId`) et un code d'invitation à 8
|
> voir `test-support/reset-db.ts`) — si tu es en train de tester manuellement à la main
|
||||||
caractères (`inviteCode`, alphabet sans caractères ambigus `0`/`O`/`1`/`I`).
|
> (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle
|
||||||
|
> efface tes données de test sans prévenir. Pas un bug, juste à savoir.
|
||||||
- `GET`/`PATCH /house/current` — foyer courant. `PATCH { name }` ouvert à tout membre.
|
|
||||||
- `POST /house` — crée un foyer (l'appelant devient admin) ; `POST /house/join
|
|
||||||
{ inviteCode }` — rejoint un foyer existant. Les deux 409 `ALREADY_HAS_HOUSE`
|
|
||||||
si le profil a déjà un foyer.
|
|
||||||
- `POST /house/leave` — quitte le foyer courant. Si le partant était l'admin,
|
|
||||||
l'adminship passe au membre restant le plus ancien ; si plus personne ne
|
|
||||||
reste, le foyer est supprimé (un foyer ne peut jamais rester sans admin).
|
|
||||||
- `DELETE /house/current` — supprime le foyer (403 `NOT_HOUSE_ADMIN` si appelé
|
|
||||||
par un non-admin). `DELETE /house/members/:id` — retire un membre (admin
|
|
||||||
seulement, pas de self-retrait par cette route, utiliser `/leave`).
|
|
||||||
- `GET`/`PATCH /house/current/sources` — quelles sources externes de recettes
|
|
||||||
(voir plus bas) le foyer voit dans son catalogue — `{ sourceIds: number[] }`,
|
|
||||||
remplace (pas de fusion), opt-in (aucune source activée par défaut).
|
|
||||||
|
|
||||||
Détail complet (génération du code, transfert d'adminship) :
|
|
||||||
[specs/backend-architecture.md](specs/backend-architecture.md#house--foyer-adminship-code-dinvitation-sources-activées).
|
|
||||||
|
|
||||||
## Planning (apps/api)
|
## Planning (apps/api)
|
||||||
|
|
||||||
- `GET /planning?date=YYYY-MM-DD` — planning de la semaine (lundi→dimanche)
|
- `GET /planning/current` — nécessite le cookie de session (401 sinon). Renvoie le
|
||||||
couvrant `date`, pour le foyer de l'utilisateur connecté — `PlanningView |
|
planning du foyer de l'utilisateur connecté qui couvre la date du jour (`Planning`
|
||||||
null` (`null` = pas de foyer, ou aucun planning pour cette semaine, deux cas
|
dont `start_date <= aujourd'hui <= finish_date`), items inclus avec leur recette
|
||||||
normaux confondus, jamais une erreur).
|
résolue en `{ id, name }` — ou `null` s'il n'y en a aucun (foyer sans planning en
|
||||||
- `POST /planning/items` — ajoute une recette à un créneau :
|
cours, ou profil sans foyer). `null` est une réponse **valide** (200), pas une
|
||||||
`{ date, weekDay, meal, recipeId, portions }`. `portions` est saisi
|
erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul
|
||||||
indépendamment du rendement propre de la recette (`Recipe.portions`) — un
|
batch-cooking », voir [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md),
|
||||||
créneau peut mettre à l'échelle.
|
reste à construire), donc c'est l'état attendu tant que ce module n'existe pas.
|
||||||
- `DELETE /planning/items/:id` — retire un item du planning.
|
- Type de réponse partagé : `PlanningView` (`packages/shared/src/types/planning.ts`),
|
||||||
|
consommé tel quel par `apps/web`.
|
||||||
|
|
||||||
Le planning d'une semaine est créé à la demande (première recette ajoutée),
|
Détail de `AsyncRequestHandler`/`wrapAsyncHandler` (`packages/express-tools`) —
|
||||||
jamais en avance.
|
premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, ce qui
|
||||||
|
a mis au jour une contrainte générique trop stricte, corrigée à la source :
|
||||||
|
[specs/backend-architecture.md](specs/backend-architecture.md).
|
||||||
|
|
||||||
## Recettes — catalogue, favoris, import depuis une source externe (apps/api)
|
## Données de référence — régimes & allergènes (apps/api)
|
||||||
|
|
||||||
- `GET /recipes?tab=favoris|perso|foyer|publique&search=&suitableForHousehold=&ingredientIds=&dietIds=`
|
- `GET /reference/diets` — liste des régimes alimentaires (`Diet`, 5 valeurs seedées).
|
||||||
— catalogue filtré par onglet + filtres optionnels. `PERSONAL`/`HOUSE`/`PUBLIC`
|
- `GET /reference/allergies` — liste des allergènes sélectionnables, `{ id, name }`
|
||||||
(`Recipe.visibility`) contrôlent qui peut **lire** une recette (jamais qui
|
(le nom vient de `Category.name` — la table `allergy` elle-même ne porte pas de
|
||||||
peut l'éditer, toujours réservé à l'auteur) ; les recettes issues d'une
|
nom, voir `schema.prisma` — chaque allergène = une `Category` + une unique
|
||||||
source externe non activée pour le foyer du viewer sont masquées de tous les
|
`Allergy` sous cette catégorie).
|
||||||
onglets.
|
|
||||||
- `GET /recipes/:id`, `POST /recipes`, `PATCH /recipes/:id`,
|
|
||||||
`DELETE /recipes/:id` (409 `RECIPE_IN_USE` si encore référencée par un
|
|
||||||
planning), `POST`/`DELETE /recipes/:id/favorite`.
|
|
||||||
- **Import depuis une source externe** (`/sources`) : `GET
|
|
||||||
/sources/:sourceKey/browse` (parcourir), `GET
|
|
||||||
/sources/:sourceKey/preview/:externalId` (prévisualiser sans sauvegarder —
|
|
||||||
ingrédients/unités/techniques déjà résolus contre les catalogues), `POST
|
|
||||||
/sources/:sourceKey/import/:externalId` (finaliser — même payload qu'une
|
|
||||||
création manuelle). Un item de source n'est sauvegardé qu'en conséquence de
|
|
||||||
son ajout au planning (import transparent si tout est résolu) ou d'une revue
|
|
||||||
manuelle (ingrédients ambigus à choisir à la main) — jamais un bouton
|
|
||||||
"importer" isolé. Une seule source concrète aujourd'hui : **TheMealDB**
|
|
||||||
(API officielle, catalogue anglais).
|
|
||||||
|
|
||||||
Détail complet (adaptateurs, algorithmes de matching ingrédients/techniques,
|
Les deux sont **publics** (pas de `requireAuth`) : ce sont des données de référence,
|
||||||
synchronisation de la table `sources`) :
|
pas des données de foyer, et le wizard d'inscription doit pouvoir les lire avant
|
||||||
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation).
|
qu'un compte (donc une session) n'existe.
|
||||||
|
|
||||||
## Données de référence — régimes, allergènes, ingrédients, unités, techniques (apps/api)
|
Données seedées via `apps/api/prisma/seed.ts` (`pnpm --filter api prisma:seed`, ou
|
||||||
|
automatiquement après `prisma migrate reset` — config `prisma.seed` dans
|
||||||
|
`package.json`). La logique réelle (listes + upsert idempotent) vit dans
|
||||||
|
`src/db/reference-seed-data.ts`, partagée avec `test-support/reset-db.ts` : chaque
|
||||||
|
test repart d'une base **avec** ces données de référence, pas de tables vides —
|
||||||
|
nécessaire pour tester `dietId`/`allergyIds` sur de vraies lignes.
|
||||||
|
|
||||||
- `GET /reference/diets`, `/allergies`, `/ingredients`, `/units`,
|
`Diet.name` et `Category.name` sont `@unique` — ajouté à ce schéma (pas dans le doc
|
||||||
`/tech-steps`, `/sources` — tous **publics** (pas de `requireAuth`) : ce sont
|
spec d'origine) précisément pour permettre cet upsert idempotent par nom.
|
||||||
des données de référence, pas des données de foyer, et le wizard
|
|
||||||
d'inscription doit pouvoir les lire avant qu'un compte n'existe.
|
|
||||||
|
|
||||||
Données seedées via `apps/api/src/db/reference-seed-data.ts` (`pnpm --filter
|
Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
|
||||||
api prisma:seed`, ou automatiquement après `prisma migrate reset`) — jamais
|
standard, pas inventée.
|
||||||
créées/éditées/supprimées via l'API applicative. `key`/`name` sont `@unique`
|
|
||||||
pour permettre un seed idempotent (`upsert`). Le catalogue d'ingrédients
|
|
||||||
(400+) est organisé en 7 rayons/sous-catégories façon supermarché français, et
|
|
||||||
chaque allergène est classé `ALLERGY` (immunitaire) ou `INTOLERANCE`
|
|
||||||
(Gluten/Sulfites). Détail complet du schéma :
|
|
||||||
[specs/batch-cooking-modele.md](specs/batch-cooking-modele.md).
|
|
||||||
|
|
||||||
## Foyer & profil — régime, allergènes, ingrédients détestés (apps/api)
|
**Allergies vs intolérances** (retour fonctionnel, pas dans le doc spec d'origine) :
|
||||||
|
`Category.kind` (`AllergenKind` — `ALLERGY` | `INTOLERANCE`) classe chaque allergène.
|
||||||
|
Seuls `Gluten` et `Sulfites` sont en `INTOLERANCE` (réaction non-immunitaire
|
||||||
|
documentée) ; les 12 autres en `ALLERGY` (réaction immunitaire classique). Classifié
|
||||||
|
par substance, pas par utilisateur — un même foyer ne peut pas déclarer "allergie au
|
||||||
|
lait" pour un membre et "intolérance au lait" pour un autre ; a suffi pour le besoin
|
||||||
|
exprimé, à revoir si ça devient un problème réel. `GET /reference/allergies` renvoie
|
||||||
|
`kind` dans chaque `AllergyView` ; `PATCH /profile/allergies` ne change pas (une
|
||||||
|
seule liste d'IDs, `kind` ne sert qu'à grouper l'affichage côté client).
|
||||||
|
|
||||||
Nécessitent tous une session (`requireAuth`) — données propres à
|
## Foyer & profil — nom, régime, allergènes (apps/api)
|
||||||
l'utilisateur/au foyer, pas des données de référence.
|
|
||||||
|
|
||||||
|
Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de
|
||||||
|
référence ci-dessus, ce sont des données propres à l'utilisateur/au foyer.
|
||||||
|
|
||||||
|
- `GET`/`PATCH /house/current` — foyer de l'utilisateur connecté. `GET` renvoie
|
||||||
|
`null` si le profil n'a pas encore de foyer (cas théorique : le signup en crée
|
||||||
|
toujours un) ; `PATCH { name }` le renomme (`404 HOUSE_NOT_FOUND` si le profil
|
||||||
|
n'a pas de foyer).
|
||||||
- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
|
- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
|
||||||
`null` efface le régime.
|
`null` efface le régime (étape "skippable" du parcours). `404 DIET_NOT_FOUND` si
|
||||||
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances (medical), liste
|
`dietId` ne correspond à aucun régime de référence.
|
||||||
d'IDs, remplace (pas de fusion).
|
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances du profil connecté,
|
||||||
- `GET`/`PATCH /profile/disliked-ingredients` — ingrédients personnellement
|
sous forme de liste d'IDs (`number[]`). `PATCH { allergyIds }` **remplace**
|
||||||
"pas aimés" (**goût, pas médical** — ne déclenche jamais un avertissement de
|
l'ensemble (pas une fusion — le client renvoie toujours la sélection complète,
|
||||||
sécurité, juste un rappel discret sur la fiche recette), même contrat de
|
cohérent avec un composant de multi-sélection). `404 ALLERGY_NOT_FOUND` si un ID
|
||||||
remplacement.
|
ne correspond à aucun allergène de référence.
|
||||||
- `GET`/`PATCH /preferences { theme: "LIGHT"|"DARK"|"SYSTEM" }` — préférence
|
|
||||||
d'affichage, upsert (pas de ligne tant que rien n'a été choisi, défaut
|
|
||||||
`SYSTEM`).
|
|
||||||
|
|
||||||
`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
|
`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
|
||||||
(`toSafeProfile`).
|
(`toSafeProfile`), auparavant dupliqué dans `auth.service.ts` et
|
||||||
|
`require-auth.ts` — `profile.service.ts` le réutilise aussi.
|
||||||
|
|
||||||
## Page de connexion / inscription (apps/web)
|
## Page de connexion / inscription (apps/web)
|
||||||
|
|
||||||
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée
|
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) :
|
||||||
`apiClient`) : enveloppe `fetch` vers l'API (`credentials: "include"`, requis
|
enveloppe `fetch` vers l'API (`credentials: "include"`, requis pour que le cookie
|
||||||
pour que le cookie de session httpOnly parte/revienne — l'API et le front
|
de session httpOnly parte/revienne — l'API et le front sont sur des origines
|
||||||
sont sur des origines différentes). URL configurable via `VITE_API_URL`
|
différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
|
||||||
(voir `.env.example`).
|
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET /auth/me` au
|
||||||
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET
|
chargement pour restaurer la session depuis le cookie.
|
||||||
/auth/me` au chargement pour restaurer la session depuis le cookie ;
|
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes de route
|
||||||
`deleteAccount()` pour la suppression de compte.
|
(react-router-dom) : `/` exige d'être connecté, `/login` et `/signup` redirigent vers
|
||||||
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes
|
`/` si on l'est déjà.
|
||||||
de route (react-router-dom) : l'espace connecté exige d'être connecté,
|
- `src/pages/{Login,Signup,Home}Page.tsx` — validation client instantanée via les
|
||||||
`/login` et `/signup` redirigent vers `/` si on l'est déjà.
|
|
||||||
- `src/pages/{Login,Signup}Page.tsx` — validation client instantanée via les
|
|
||||||
schémas zod partagés (`packages/shared`), erreurs API traduites via
|
schémas zod partagés (`packages/shared`), erreurs API traduites via
|
||||||
`ErrorMessageService` (voir ci-dessous).
|
`ErrorMessageService` (voir ci-dessous).
|
||||||
|
|
||||||
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
||||||
|
|
||||||
## Sidebar, planning, recettes & sections (apps/web)
|
## Accueil, sidebar & sections (apps/web)
|
||||||
|
|
||||||
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` —
|
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar
|
||||||
sidebar (nav Planning/Recettes/Liste de courses, sous-menu Paramètres
|
(nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et
|
||||||
repliable, menu compte en pied) et `<Outlet />` pour la route active — montée
|
`<Outlet />` pour la route active — montée une seule fois comme route parente de tout
|
||||||
une seule fois comme route parente de tout l'espace authentifié (`App.tsx`).
|
l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx`
|
||||||
|
(routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`,
|
||||||
|
voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes` et `Liste de
|
||||||
|
courses` n'ont pas encore de backend dédié et rendent pour l'instant le même
|
||||||
|
composant `ComingSoonPage` — `Foyer & profil` (`src/pages/HouseholdPage.tsx`), lui,
|
||||||
|
est une vraie page (voir section suivante). Détail complet (pourquoi une seule route
|
||||||
|
parente, pourquoi un composant stub partagé) :
|
||||||
|
[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
|
||||||
|
|
||||||
- **`/` — `PlanningPage`** : grille complète de la semaine (7 jours × 5
|
## Parcours profil — foyer, régime, allergènes (apps/web)
|
||||||
repas), navigation par semaine avec mini-calendrier, ajout via
|
|
||||||
`RecipePickerDialog` (parcourir le catalogue **et** les sources externes,
|
|
||||||
prévisualiser avant de confirmer, import transparent en un clic si la
|
|
||||||
recette d'une source n'est pas encore résolue automatiquement, sinon revue
|
|
||||||
intégrée dans le même dialogue).
|
|
||||||
- **`/recettes`** (+ `/recettes/:id`, `/recettes/sources/:sourceKey/:externalId`)
|
|
||||||
— `RecipesPage`, vue maître-détail : onglets favoris/perso/foyer/publique
|
|
||||||
**plus un onglet par source externe activée pour le foyer**, tableau +
|
|
||||||
panneau de détail (surlignage des techniques détectées avec infobulle, icônes
|
|
||||||
d'ingrédients génériques, badges régime/allergènes/reproductible).
|
|
||||||
`/recettes/nouvelle` et `/recettes/:id/modifier` (`RecipeFormPage`) pour la
|
|
||||||
création/édition manuelle.
|
|
||||||
- **`/liste-de-courses`** — toujours un stub (`ComingSoonPage`), le module
|
|
||||||
« Calcul batch-cooking » reste `TODO` (voir
|
|
||||||
[specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md)).
|
|
||||||
- **`/parametres/*`** — Compte (identité + suppression), Préférences
|
|
||||||
(régime/allergies/ingrédients détestés), Foyer (création/invitation,
|
|
||||||
membres, sources activées), Préférences utilisateur (thème
|
|
||||||
clair/sombre/système), Crédits (attribution des icônes CC BY 4.0).
|
|
||||||
|
|
||||||
Détail complet (pourquoi une seule route parente, le flux d'import détaillé,
|
- `src/features/profile/` — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
|
||||||
les composants UI partagés `Dialog`/`Checkbox`/`Radio`/`Tooltip`) :
|
contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une
|
||||||
|
grille de cases à cocher dans un `<fieldset>`/`<legend>` plutôt qu'un
|
||||||
|
`<select multiple>` — bien plus repérable/tapable, notamment sur mobile. Prend un
|
||||||
|
`legend` en prop (pas un libellé fixe interne) : le même composant est rendu
|
||||||
|
**deux fois** par chaque page consommatrice — une fois pour les allergies
|
||||||
|
(`AllergyView.kind === "ALLERGY"`), une fois pour les intolérances
|
||||||
|
(`"INTOLERANCE"`) — les deux listes filtrées côté client à partir d'un seul
|
||||||
|
`GET /reference/allergies`, mais la sélection (`allergyIds`) reste une seule
|
||||||
|
liste d'IDs partagée entre les deux groupes (une seule `PATCH /profile/allergies`).
|
||||||
|
- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
|
||||||
|
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage` →
|
||||||
|
`OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`).
|
||||||
|
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante (y
|
||||||
|
compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip
|
||||||
|
implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
|
||||||
|
wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`.
|
||||||
|
- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes réglages, modifiables
|
||||||
|
à tout moment. **Hot saving** (retour fonctionnel) : pas de bouton "Enregistrer",
|
||||||
|
chaque section sauvegarde automatiquement peu après la dernière modification —
|
||||||
|
nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms,
|
||||||
|
pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé
|
||||||
|
immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le
|
||||||
|
handler `onChange` de chaque champ, jamais depuis un `useEffect` générique qui
|
||||||
|
observerait la valeur — un tel effect se déclencherait aussi au chargement
|
||||||
|
initial (quand le `GET` peuple le même state), sans moyen propre de distinguer
|
||||||
|
"vient d'être chargé" de "vient d'être modifié par l'utilisateur".
|
||||||
|
|
||||||
## Parcours d'inscription — onboarding (apps/web)
|
**Piège trouvé en testant dans le navigateur** : `RedirectIfAuthenticated` (garde de
|
||||||
|
`/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la
|
||||||
|
vérification initiale — un `navigate()` explicite dans le gestionnaire de soumission
|
||||||
|
d'un formulaire qu'elle protège (ex. `SignupPage` après `signup()`, qui met `user` à
|
||||||
|
jour) entre alors en course avec le propre `<Navigate>` de la garde. Invisible tant
|
||||||
|
que les deux ciblaient "/", devenu un vrai bug dès que `SignupPage` a dû rediriger
|
||||||
|
ailleurs (`/onboarding/foyer`). Fix : la décision de redirection est verrouillée une
|
||||||
|
seule fois, au moment où `isLoading` passe à `false`, plus jamais réévaluée après.
|
||||||
|
|
||||||
Wizard de 4 écrans lancé une fois juste après l'inscription :
|
**Autre piège, même méthode** : `HouseholdPage` initialisait le régime affiché depuis
|
||||||
`/onboarding/regime` → `/onboarding/foyer` → `/onboarding/sources`
|
`useAuth().user.dietId` (un instantané jamais rafraîchi après une modification faite
|
||||||
(conditionnelle, sautée si aucun foyer n'a été créé/rejoint à l'étape
|
directement via `apiClient`, qui ne touche pas `AuthContext`) — revenait à l'ancienne
|
||||||
précédente) → `/onboarding/allergenes`. Chaque étape a un unique bouton
|
valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la
|
||||||
"Continuer" qui envoie la valeur courante (y compris "aucune" pour
|
page fetch son propre profil frais (`apiClient.me()`) au montage, et
|
||||||
régime/allergènes) — pas de bouton "Passer" séparé, skip implicite. Routes
|
`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
|
||||||
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein écran
|
régime pour que le reste de l'app reste cohérent aussi.
|
||||||
sans sidebar, même langage visuel que `/login`/`/signup`. Les mêmes réglages
|
|
||||||
restent modifiables à tout moment depuis `/parametres/*` (hot saving, pas de
|
|
||||||
bouton "Enregistrer" — chaque champ sauvegarde peu après la dernière
|
|
||||||
modification).
|
|
||||||
|
|
||||||
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts` et `*.feature` +
|
Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` +
|
||||||
`@badeball/cypress-cucumber-preprocessor`) : mockent l'API via `cy.intercept`
|
`home-planning.cy.ts` + `onboarding.cy.ts` + `household.cy.ts` mockent l'API via
|
||||||
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne
|
`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
|
||||||
pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de
|
provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
|
||||||
l'API est couvert par la suite Mocha d'`apps/api` (contre une vraie base).
|
réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une
|
||||||
Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
|
vraie base).
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#tests-cypress--cucumber).
|
|
||||||
|
|
||||||
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
|
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
|
||||||
> Chromium/Electron headless plante au lancement du process GPU
|
> Chromium/Electron headless plante au lancement du process GPU
|
||||||
|
|
@ -450,9 +383,8 @@ Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
|
||||||
## Gestion des erreurs (API ↔ web)
|
## Gestion des erreurs (API ↔ web)
|
||||||
|
|
||||||
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
|
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
|
||||||
**numérique** groupée par famille — `4000` validation, `401x` auth, `402x`
|
**numérique** groupée par famille — `4000` validation, `401x` auth, `404x` not
|
||||||
conflit/état invalide, `403x` autorisation, `404x` not found, `500x` interne
|
found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
|
||||||
— et `ApiErrorResponse`) : l'API renvoie toujours
|
|
||||||
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
|
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
|
||||||
quel), et le client traduit `code` en libellé français via **i18next**
|
quel), et le client traduit `code` en libellé français via **i18next**
|
||||||
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
|
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
|
||||||
|
|
@ -462,8 +394,8 @@ centralisent la transformation de toute erreur levée en réponse HTTP conforme
|
||||||
aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
|
aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
|
||||||
compris dans les mocks Cypress).
|
compris dans les mocks Cypress).
|
||||||
|
|
||||||
Détail complet (schéma, liste des ~19 codes actuels, exemples, comment ajouter
|
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
|
||||||
un nouveau code d'erreur) : [specs/error-handling.md](specs/error-handling.md).
|
[specs/error-handling.md](specs/error-handling.md).
|
||||||
|
|
||||||
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
|
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
|
||||||
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
|
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
|
||||||
|
|
@ -479,23 +411,16 @@ voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshare
|
||||||
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
|
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
|
||||||
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`),
|
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`),
|
||||||
jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
|
jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
|
||||||
en ajouter une est une question de fichier de locale, pas de code. Les
|
en ajouter une est une question de fichier de locale, pas de code. Détail :
|
||||||
libellés des tables de référence (régimes, allergènes, ingrédients, unités,
|
|
||||||
techniques) vivent aussi dans ce fichier (`catalog.*`, par `key` stable de
|
|
||||||
`schema.prisma`), jamais stockés en base. Détail :
|
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
|
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
|
||||||
|
|
||||||
## Thème clair / sombre / système (apps/web)
|
|
||||||
|
|
||||||
Préférence par utilisateur, persistée côté serveur (`GET`/`PATCH
|
|
||||||
/preferences`, pas `localStorage`). Tokens de design en custom properties CSS
|
|
||||||
(`apps/web/src/styles/_theme.scss`) redéfinies sous `[data-theme="dark"]`
|
|
||||||
(choix explicite) ou sous `prefers-color-scheme: dark` quand aucun
|
|
||||||
`data-theme` n'est posé (choix "système", le défaut). Détail :
|
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#thème-clairsombresystème).
|
|
||||||
|
|
||||||
## Données de test (faker.js)
|
## Données de test (faker.js)
|
||||||
|
|
||||||
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
|
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
|
||||||
de test dans `test/*.test.ts` (Mocha) — jamais de nom/email qui ressemble à une
|
de test dans `test/auth.test.ts` (Mocha) et le "bruit" (prénom/nom de remplissage)
|
||||||
vraie personne en dur dans un fixture.
|
des steps Cucumber — jamais de nom/email qui ressemble à une vraie personne en dur
|
||||||
|
dans un fixture. Les valeurs *littérales* des scénarios `.feature` eux-mêmes
|
||||||
|
(ex. `alice@example.com`) restent volontairement statiques : c'est le point des
|
||||||
|
scénarios Gherkin lisibles (exemples illustratifs conventionnels en BDD, pas des
|
||||||
|
données réelles) — seules les données de remplissage hors du texte lisible du
|
||||||
|
scénario sont générées.
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,3 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# JWT_EXPIRES_IN=7d
|
# JWT_EXPIRES_IN=7d
|
||||||
# AUTH_COOKIE_NAME=session
|
# AUTH_COOKIE_NAME=session
|
||||||
# CORS_ORIGIN=http://localhost:5173
|
# 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
NODE_ENV=test
|
|
||||||
PORT=3000
|
|
||||||
# Must point at a *different* database than your `.env`'s — `pnpm test`
|
|
||||||
# (test-support/reset-db.ts's `resetDatabase()`) TRUNCATEs almost every
|
|
||||||
# table before each test. Pointing this at the same database `pnpm dev`
|
|
||||||
# uses will wipe real local data on every test run. Easiest setup: same
|
|
||||||
# Postgres server/credentials as `.env`, just a different database name —
|
|
||||||
# create it once with e.g.:
|
|
||||||
# pnpm exec prisma db execute --url "postgresql://USER:PASSWORD@localhost:PORT/postgres?schema=public" --file - <<< "CREATE DATABASE batchcooking_test;"
|
|
||||||
# DATABASE_URL="postgresql://USER:PASSWORD@localhost:PORT/batchcooking_test?schema=public" pnpm exec prisma migrate deploy
|
|
||||||
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking_test?schema=public"
|
|
||||||
|
|
||||||
# Required, no default on purpose — generate your own, e.g.:
|
|
||||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
|
||||||
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Required — the Mocha suite exercises the real techStepClassifier, which
|
|
||||||
# now round-trips over HTTP to services/tech-step-intent-service (no mocks
|
|
||||||
# of internal services, per this repo's test conventions). Start that
|
|
||||||
# service locally first (see its own README) with a matching
|
|
||||||
# INTENT_SERVICE_SECRET, or every test touching tech-step-matcher.ts fails
|
|
||||||
# with a connection error rather than a useful assertion failure.
|
|
||||||
INTENT_SERVICE_BASE_URL=http://localhost:8000
|
|
||||||
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Optional — only needed to exercise tech-step-worker.routes.test.ts's
|
|
||||||
# success path (a request with a matching secret); every other test runs
|
|
||||||
# fine without it. Any value at least 32 chars works locally.
|
|
||||||
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
@ -2,6 +2,5 @@
|
||||||
"extension": ["ts"],
|
"extension": ["ts"],
|
||||||
"spec": "test/**/*.test.ts",
|
"spec": "test/**/*.test.ts",
|
||||||
"node-option": ["import=tsx"],
|
"node-option": ["import=tsx"],
|
||||||
"timeout": 10000,
|
"timeout": 10000
|
||||||
"require": ["test-support/mocha-root-hooks.ts"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,24 +14,6 @@ WORKDIR /repo
|
||||||
# process, one container, no separate nginx/static host. Builds both so the
|
# process, one container, no separate nginx/static host. Builds both so the
|
||||||
# runtime stage below can copy each app's build output independently.
|
# runtime stage below can copy each app's build output independently.
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
# `pnpm install` (below) installs every workspace's dependencies, including
|
|
||||||
# apps/web's `cypress` devDependency — this image never runs it, so skip its
|
|
||||||
# (large, Electron-bundled) binary download: saves build time/bandwidth and
|
|
||||||
# removes a network dependency on Cypress's CDN from every production build.
|
|
||||||
# The `cypress` npm *package* itself still ends up in `node_modules` (it's a
|
|
||||||
# real lockfile entry) and gets copied into `runtime` below — a deliberate
|
|
||||||
# tradeoff. Tried to prune it out too (`pnpm prune --prod`, then a full
|
|
||||||
# wipe-and-reinstall with `--prod`), but in this pnpm workspace both proved
|
|
||||||
# actively unsafe rather than just ineffective: `prune` doesn't cascade into
|
|
||||||
# sibling workspace projects' `node_modules` at all, and even a *scoped*
|
|
||||||
# `pnpm --filter web prune --prod` emptied out apps/api's entire
|
|
||||||
# `node_modules` as a side effect; the wipe-and-reinstall variant silently
|
|
||||||
# dropped argon2's compiled native binding (no postinstall re-run to rebuild
|
|
||||||
# it), which only surfaced as a crash *after* deploy
|
|
||||||
# (`Cannot find module '.../argon2.node'`) — not something to risk on the
|
|
||||||
# real production image for what's ~10MB of inert JS once the binary
|
|
||||||
# download above is already skipped.
|
|
||||||
ENV CYPRESS_INSTALL_BINARY=0
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
RUN pnpm --filter api build
|
RUN pnpm --filter api build
|
||||||
|
|
@ -60,8 +42,6 @@ COPY --from=build /repo/apps/web/dist ./apps/web/dist
|
||||||
|
|
||||||
WORKDIR /repo/apps/api
|
WORKDIR /repo/apps/api
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
# Applies pending migrations, then seeds the reference data (Diet/Category/
|
# Applies pending migrations before starting — keeps the review environment's
|
||||||
# Allergy — see src/scripts/seed-runtime.ts) before starting. Both steps are
|
# schema in sync automatically, no manual step needed.
|
||||||
# safe to repeat on every container start: migrate deploy only applies
|
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/server.js"]
|
||||||
# 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"]
|
|
||||||
|
|
|
||||||
11
apps/api/cucumber.cjs
Normal file
11
apps/api/cucumber.cjs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Explicit .cjs extension (not .js) so this loads as CommonJS regardless of
|
||||||
|
// the "type": "module" in package.json — avoids the same ESM/CJS config
|
||||||
|
// loader mismatch that broke apps/web/cypress.config.ts earlier.
|
||||||
|
module.exports = {
|
||||||
|
default: {
|
||||||
|
paths: ["features/**/*.feature"],
|
||||||
|
import: ["features/**/*.ts"],
|
||||||
|
format: ["progress-bar"],
|
||||||
|
formatOptions: { snippetInterface: "async-await" },
|
||||||
|
},
|
||||||
|
};
|
||||||
50
apps/api/features/auth.feature
Normal file
50
apps/api/features/auth.feature
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
Feature: Account creation and login
|
||||||
|
As a new user
|
||||||
|
I want to create a profile and log in
|
||||||
|
So that I can access my household's batch-cooking planning
|
||||||
|
|
||||||
|
Scenario: A visitor creates a new profile
|
||||||
|
When I sign up with the following details:
|
||||||
|
| firstName | Alice |
|
||||||
|
| lastName | Martin |
|
||||||
|
| email | alice@example.com |
|
||||||
|
| password | correct-horse-battery-staple |
|
||||||
|
Then the response status should be 201
|
||||||
|
And I am authenticated as "alice@example.com"
|
||||||
|
|
||||||
|
Scenario: A visitor cannot sign up twice with the same email
|
||||||
|
Given a profile already exists with email "alice@example.com"
|
||||||
|
When I sign up with the following details:
|
||||||
|
| firstName | Alice |
|
||||||
|
| lastName | Martin |
|
||||||
|
| email | alice@example.com |
|
||||||
|
| password | correct-horse-battery-staple |
|
||||||
|
Then the response status should be 409
|
||||||
|
And the response error code should be "EMAIL_ALREADY_IN_USE"
|
||||||
|
|
||||||
|
Scenario: A registered user logs in with correct credentials
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
Then the response status should be 200
|
||||||
|
And I am authenticated as "alice@example.com"
|
||||||
|
|
||||||
|
Scenario: A registered user cannot log in with the wrong password
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I log in with email "alice@example.com" and password "wrong-password"
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "INVALID_CREDENTIALS"
|
||||||
|
|
||||||
|
Scenario: A signed-in user cannot delete their account with the wrong password
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I delete my account with password "wrong-password"
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "INVALID_CREDENTIALS"
|
||||||
|
And I am authenticated as "alice@example.com"
|
||||||
|
|
||||||
|
Scenario: A signed-in user deletes their account
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I delete my account with password "correct-horse-battery-staple"
|
||||||
|
Then the response status should be 204
|
||||||
|
And I am no longer authenticated
|
||||||
12
apps/api/features/health.feature
Normal file
12
apps/api/features/health.feature
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
Feature: API health check
|
||||||
|
As a monitoring service
|
||||||
|
I want to query the API
|
||||||
|
So that I can verify it is up and responding correctly
|
||||||
|
|
||||||
|
Scenario: The API is available
|
||||||
|
When I send a GET request to "/health"
|
||||||
|
Then the response status should be 200
|
||||||
|
And the response body should be:
|
||||||
|
"""
|
||||||
|
{ "status": "ok" }
|
||||||
|
"""
|
||||||
67
apps/api/features/household.feature
Normal file
67
apps/api/features/household.feature
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
Feature: Household
|
||||||
|
As a signed-in user
|
||||||
|
I want to name my household, invite others to it, and manage its members
|
||||||
|
So that my whole household can share the same planning
|
||||||
|
|
||||||
|
Scenario: A visitor without a session cannot read the household
|
||||||
|
When I send a GET request to "/house/current"
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "NOT_AUTHENTICATED"
|
||||||
|
|
||||||
|
Scenario: A signed-in user creates a household
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I create a household named "Chez Alice"
|
||||||
|
Then the response status should be 201
|
||||||
|
And my household should be named "Chez Alice"
|
||||||
|
|
||||||
|
Scenario: A signed-in user renames their household
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I have a household named "Foyer de test"
|
||||||
|
When I rename my household to "Chez les Martin"
|
||||||
|
Then the response status should be 200
|
||||||
|
And my household should be named "Chez les Martin"
|
||||||
|
|
||||||
|
Scenario: A second user joins a household using its invite code
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I have a household named "Chez Alice"
|
||||||
|
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user joins my household using its invite code
|
||||||
|
Then the second user's response status should be 200
|
||||||
|
And the second user should be a member of my household
|
||||||
|
|
||||||
|
Scenario: A non-admin member cannot delete the household
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I have a household named "Chez Alice"
|
||||||
|
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user joins my household using its invite code
|
||||||
|
When the second user tries to delete the household
|
||||||
|
Then the second user's response status should be 403
|
||||||
|
And the second user's response error code should be "NOT_HOUSE_ADMIN"
|
||||||
|
|
||||||
|
Scenario: Adminship transfers to the remaining member when the admin leaves
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I have a household named "Chez Alice"
|
||||||
|
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user joins my household using its invite code
|
||||||
|
When I leave the household
|
||||||
|
Then the response status should be 204
|
||||||
|
And the second user should be the household's admin
|
||||||
|
|
||||||
|
Scenario: The admin removes a member
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I have a household named "Chez Alice"
|
||||||
|
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And the second user joins my household using its invite code
|
||||||
|
When I remove the second user from my household
|
||||||
|
Then the response status should be 200
|
||||||
|
And the second user should have no household
|
||||||
24
apps/api/features/planning.feature
Normal file
24
apps/api/features/planning.feature
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
Feature: Household weekly planning
|
||||||
|
As a signed-in user
|
||||||
|
I want to see my household's current planning
|
||||||
|
So that I know what meals are planned this week
|
||||||
|
|
||||||
|
Scenario: A visitor without a session cannot view the planning
|
||||||
|
When I request the current planning
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "NOT_AUTHENTICATED"
|
||||||
|
|
||||||
|
Scenario: A signed-in user with no planning yet sees an empty state
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I request the current planning
|
||||||
|
Then the response status should be 200
|
||||||
|
And the current planning response should be empty
|
||||||
|
|
||||||
|
Scenario: A signed-in user sees their household's current planning
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And my household has a planning covering today with recipe "Ratatouille" on "monday" for "dinner"
|
||||||
|
When I request the current planning
|
||||||
|
Then the response status should be 200
|
||||||
|
And the current planning response should include recipe "Ratatouille" on "monday" for "dinner"
|
||||||
23
apps/api/features/preferences.feature
Normal file
23
apps/api/features/preferences.feature
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
Feature: User preferences (theme)
|
||||||
|
As a signed-in user
|
||||||
|
I want to choose a light, dark, or system theme
|
||||||
|
So that the app matches how I like to read it
|
||||||
|
|
||||||
|
Scenario: A visitor without a session cannot read preferences
|
||||||
|
When I send a GET request to "/preferences"
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "NOT_AUTHENTICATED"
|
||||||
|
|
||||||
|
Scenario: A signed-in user with no preferences yet defaults to SYSTEM
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I request my preferences
|
||||||
|
Then the response status should be 200
|
||||||
|
And my theme preference should be "SYSTEM"
|
||||||
|
|
||||||
|
Scenario: A signed-in user sets their theme preference
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I set my theme preference to "DARK"
|
||||||
|
Then the response status should be 200
|
||||||
|
And my theme preference should be "DARK"
|
||||||
28
apps/api/features/profile.feature
Normal file
28
apps/api/features/profile.feature
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
Feature: Profile regime and allergens
|
||||||
|
As a signed-in user
|
||||||
|
I want to set my dietary regime and allergens/intolerances
|
||||||
|
So that the household's meal planning can account for them later
|
||||||
|
|
||||||
|
Scenario: A visitor without a session cannot set a regime
|
||||||
|
When I send a PATCH request to "/profile/diet" with body:
|
||||||
|
"""
|
||||||
|
{ "dietId": 1 }
|
||||||
|
"""
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "NOT_AUTHENTICATED"
|
||||||
|
|
||||||
|
Scenario: A signed-in user sets their regime to a valid, seeded diet
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I set my regime to "Végétarien"
|
||||||
|
Then the response status should be 200
|
||||||
|
And my profile's regime should be "Végétarien"
|
||||||
|
|
||||||
|
Scenario: A signed-in user selects allergens, then replaces the selection
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I set my allergens to "Arachides, Gluten"
|
||||||
|
Then the response status should be 200
|
||||||
|
And my selected allergens should be "Arachides, Gluten"
|
||||||
|
When I set my allergens to "Lait"
|
||||||
|
Then my selected allergens should be "Lait"
|
||||||
14
apps/api/features/reference.feature
Normal file
14
apps/api/features/reference.feature
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
Feature: Reference data (diets, allergens)
|
||||||
|
As a visitor filling in the signup wizard, or a signed-in user editing their profile
|
||||||
|
I want to read the list of dietary regimes and allergens
|
||||||
|
So that I can pick from them — before an account necessarily exists
|
||||||
|
|
||||||
|
Scenario: A visitor without a session can read the list of dietary regimes
|
||||||
|
When I send a GET request to "/reference/diets"
|
||||||
|
Then the response status should be 200
|
||||||
|
And the reference list response should include "Végétarien"
|
||||||
|
|
||||||
|
Scenario: A visitor without a session can read the list of allergens
|
||||||
|
When I send a GET request to "/reference/allergies"
|
||||||
|
Then the response status should be 200
|
||||||
|
And the reference list response should include "Arachides"
|
||||||
67
apps/api/features/step-definitions/auth.steps.ts
Normal file
67
apps/api/features/step-definitions/auth.steps.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { DataTable } from "@cucumber/cucumber";
|
||||||
|
import { Given, Then, When } from "@cucumber/cucumber";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { signup } from "../../src/modules/auth/auth.service.js";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
// firstName/lastName/password below are filler for background state the
|
||||||
|
// scenario doesn't actually read (only the emails in the .feature file are
|
||||||
|
// part of what's being tested) — faker-generated rather than hardcoded so
|
||||||
|
// no test fixture ever looks like a real person's data.
|
||||||
|
|
||||||
|
Given("a profile already exists with email {string}", async (email: string) => {
|
||||||
|
await signup({
|
||||||
|
firstName: faker.person.firstName(),
|
||||||
|
lastName: faker.person.lastName(),
|
||||||
|
email,
|
||||||
|
password: faker.internet.password({ length: 16 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given(
|
||||||
|
"a profile already exists with email {string} and password {string}",
|
||||||
|
async (email: string, password: string) => {
|
||||||
|
await signup({
|
||||||
|
firstName: faker.person.firstName(),
|
||||||
|
lastName: faker.person.lastName(),
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When("I sign up with the following details:", async function (this: CustomWorld, table: DataTable) {
|
||||||
|
const details = table.rowsHash();
|
||||||
|
this.response = await this.agent.post("/auth/signup").send({
|
||||||
|
firstName: details.firstName,
|
||||||
|
lastName: details.lastName,
|
||||||
|
email: details.email,
|
||||||
|
password: details.password,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
When(
|
||||||
|
"I log in with email {string} and password {string}",
|
||||||
|
async function (this: CustomWorld, email: string, password: string) {
|
||||||
|
this.response = await this.agent.post("/auth/login").send({ email, password });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When(
|
||||||
|
"I delete my account with password {string}",
|
||||||
|
async function (this: CustomWorld, password: string) {
|
||||||
|
this.response = await this.agent.delete("/auth/me").send({ password });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then("I am authenticated as {string}", async function (this: CustomWorld, email: string) {
|
||||||
|
const res = await this.agent.get("/auth/me");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.email, email);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("I am no longer authenticated", async function (this: CustomWorld) {
|
||||||
|
const res = await this.agent.get("/auth/me");
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
});
|
||||||
38
apps/api/features/step-definitions/health.steps.ts
Normal file
38
apps/api/features/step-definitions/health.steps.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import { Then, When } from "@cucumber/cucumber";
|
||||||
|
import request from "supertest";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
When("I send a GET request to {string}", async function (this: CustomWorld, path: string) {
|
||||||
|
this.response = await request(this.app).get(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
When(
|
||||||
|
"I send a PATCH request to {string} with body:",
|
||||||
|
async function (this: CustomWorld, path: string, body: string) {
|
||||||
|
this.response = await request(this.app).patch(path).send(JSON.parse(body));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then("the response status should be {int}", function (this: CustomWorld, status: number) {
|
||||||
|
assert.equal(this.response.status, status);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the response body should be:", function (this: CustomWorld, expectedJson: string) {
|
||||||
|
assert.deepEqual(this.response.body, JSON.parse(expectedJson));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic enough to be reused by any feature asserting on the shared
|
||||||
|
// ApiErrorResponse contract's `code` field — not health-specific, but this
|
||||||
|
// file is where the other generic response-assertion steps already live.
|
||||||
|
//
|
||||||
|
// `code` here is the enum *member name* (readable in the .feature file,
|
||||||
|
// e.g. "EMAIL_ALREADY_IN_USE") — ErrorCode[name] resolves it to the real
|
||||||
|
// numeric value via TypeScript's reverse enum lookup, so this never
|
||||||
|
// compares against a hardcoded number.
|
||||||
|
Then("the response error code should be {string}", function (this: CustomWorld, code: string) {
|
||||||
|
const expected = ErrorCode[code as keyof typeof ErrorCode];
|
||||||
|
assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`);
|
||||||
|
assert.equal(this.response.body.code, expected);
|
||||||
|
});
|
||||||
90
apps/api/features/step-definitions/household.steps.ts
Normal file
90
apps/api/features/step-definitions/household.steps.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import { Given, Then, When } from "@cucumber/cucumber";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
Given("I have a household named {string}", async function (this: CustomWorld, name: string) {
|
||||||
|
const res = await this.agent.post("/house").send({ name });
|
||||||
|
assert.equal(res.status, 201, JSON.stringify(res.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I create a household named {string}", async function (this: CustomWorld, name: string) {
|
||||||
|
this.response = await this.agent.post("/house").send({ name });
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I rename my household to {string}", async function (this: CustomWorld, name: string) {
|
||||||
|
this.response = await this.agent.patch("/house/current").send({ name });
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I leave the household", async function (this: CustomWorld) {
|
||||||
|
this.response = await this.agent.post("/house/leave");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I remove the second user from my household", async function (this: CustomWorld) {
|
||||||
|
const secondMe = await this.secondAgent.get("/auth/me");
|
||||||
|
this.response = await this.agent.delete(`/house/members/${secondMe.body.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("my household should be named {string}", async function (this: CustomWorld, name: string) {
|
||||||
|
const res = await this.agent.get("/house/current");
|
||||||
|
assert.equal(res.body.name, name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Steps involving a second, independently signed-in user ---------------
|
||||||
|
// The first ("a profile already exists with email ...") step is reused
|
||||||
|
// as-is for the second user too — it just inserts a row, agent-agnostic.
|
||||||
|
|
||||||
|
When(
|
||||||
|
"the second user logs in with email {string} and password {string}",
|
||||||
|
async function (this: CustomWorld, email: string, password: string) {
|
||||||
|
this.secondResponse = await this.secondAgent.post("/auth/login").send({ email, password });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When(
|
||||||
|
"the second user joins my household using its invite code",
|
||||||
|
async function (this: CustomWorld) {
|
||||||
|
const house = await this.agent.get("/house/current");
|
||||||
|
this.secondResponse = await this.secondAgent
|
||||||
|
.post("/house/join")
|
||||||
|
.send({ inviteCode: house.body.inviteCode });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When("the second user tries to delete the household", async function (this: CustomWorld) {
|
||||||
|
this.secondResponse = await this.secondAgent.delete("/house/current");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the second user's response status should be {int}",
|
||||||
|
function (this: CustomWorld, status: number) {
|
||||||
|
assert.equal(this.secondResponse.status, status);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the second user's response error code should be {string}",
|
||||||
|
function (this: CustomWorld, code: string) {
|
||||||
|
const expected = ErrorCode[code as keyof typeof ErrorCode];
|
||||||
|
assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`);
|
||||||
|
assert.equal(this.secondResponse.body.code, expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then("the second user should be a member of my household", async function (this: CustomWorld) {
|
||||||
|
const house = await this.agent.get("/house/current");
|
||||||
|
const secondMe = await this.secondAgent.get("/auth/me");
|
||||||
|
const memberIds = (house.body.members as Array<{ id: number }>).map((member) => member.id);
|
||||||
|
assert.ok(memberIds.includes(secondMe.body.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the second user should be the household's admin", async function (this: CustomWorld) {
|
||||||
|
const secondMe = await this.secondAgent.get("/auth/me");
|
||||||
|
const house = await this.secondAgent.get("/house/current");
|
||||||
|
assert.equal(house.body.adminId, secondMe.body.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the second user should have no household", async function (this: CustomWorld) {
|
||||||
|
const secondMe = await this.secondAgent.get("/auth/me");
|
||||||
|
assert.equal(secondMe.body.houseId, null);
|
||||||
|
});
|
||||||
58
apps/api/features/step-definitions/planning.steps.ts
Normal file
58
apps/api/features/step-definitions/planning.steps.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Given, Then, When } from "@cucumber/cucumber";
|
||||||
|
import { prisma } from "../../src/db/prisma.js";
|
||||||
|
import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
/** `GET /planning` takes `?date=` explicitly — this scenario wording ("the current planning") maps to the fixed test "today" (see `TEST_REFERENCE_DATE`). */
|
||||||
|
When("I request the current planning", async function (this: CustomWorld) {
|
||||||
|
this.response = await this.agent
|
||||||
|
.get("/planning")
|
||||||
|
.query({ date: TEST_REFERENCE_DATE.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 planning = await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
||||||
|
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.planningItem.create({
|
||||||
|
data: { planningId: planning.id, weekDay, meal, recipeId: recipe.id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the current planning response should include recipe {string} on {string} for {string}",
|
||||||
|
function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
|
||||||
|
const items = this.response.body.items as Array<{
|
||||||
|
weekDay: string;
|
||||||
|
meal: string;
|
||||||
|
recipe: { name: string };
|
||||||
|
}>;
|
||||||
|
const item = items.find((i) => i.recipe.name === recipeName);
|
||||||
|
assert.ok(item, `expected an item with recipe "${recipeName}", got ${JSON.stringify(items)}`);
|
||||||
|
assert.equal(item.weekDay, weekDay);
|
||||||
|
assert.equal(item.meal, meal);
|
||||||
|
},
|
||||||
|
);
|
||||||
15
apps/api/features/step-definitions/preferences.steps.ts
Normal file
15
apps/api/features/step-definitions/preferences.steps.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Then, When } from "@cucumber/cucumber";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
When("I request my preferences", async function (this: CustomWorld) {
|
||||||
|
this.response = await this.agent.get("/preferences");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I set my theme preference to {string}", async function (this: CustomWorld, theme: string) {
|
||||||
|
this.response = await this.agent.patch("/preferences").send({ theme });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("my theme preference should be {string}", function (this: CustomWorld, theme: string) {
|
||||||
|
assert.equal(this.response.body.theme, theme);
|
||||||
|
});
|
||||||
46
apps/api/features/step-definitions/profile.steps.ts
Normal file
46
apps/api/features/step-definitions/profile.steps.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Then, When } from "@cucumber/cucumber";
|
||||||
|
import { prisma } from "../../src/db/prisma.js";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
/** Splits a comma-separated list of names from a `.feature` string into trimmed, non-empty parts. */
|
||||||
|
function splitNames(names: string): string[] {
|
||||||
|
return names
|
||||||
|
.split(",")
|
||||||
|
.map((name) => name.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves allergen names (Category.name) to their Allergy id — see reference.service.ts for why the name lives on Category, not Allergy. */
|
||||||
|
async function allergyIdsFor(names: string[]): Promise<number[]> {
|
||||||
|
const allergies = await prisma.allergy.findMany({ include: { category: true } });
|
||||||
|
return names.map((name) => {
|
||||||
|
const match = allergies.find((allergy) => allergy.category.name === name);
|
||||||
|
if (!match) throw new Error(`No seeded allergen named "${name}"`);
|
||||||
|
return match.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
When("I set my regime to {string}", async function (this: CustomWorld, dietName: string) {
|
||||||
|
const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } });
|
||||||
|
this.response = await this.agent.patch("/profile/diet").send({ dietId: diet.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"my profile's regime should be {string}",
|
||||||
|
async function (this: CustomWorld, dietName: string) {
|
||||||
|
const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } });
|
||||||
|
assert.equal(this.response.body.dietId, diet.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When("I set my allergens to {string}", async function (this: CustomWorld, names: string) {
|
||||||
|
const allergyIds = await allergyIdsFor(splitNames(names));
|
||||||
|
this.response = await this.agent.patch("/profile/allergies").send({ allergyIds });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("my selected allergens should be {string}", async function (this: CustomWorld, names: string) {
|
||||||
|
const expected = (await allergyIdsFor(splitNames(names))).sort();
|
||||||
|
const actual = [...this.response.body].sort();
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
11
apps/api/features/step-definitions/reference.steps.ts
Normal file
11
apps/api/features/step-definitions/reference.steps.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Then } from "@cucumber/cucumber";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the reference list response should include {string}",
|
||||||
|
function (this: CustomWorld, name: string) {
|
||||||
|
const names = (this.response.body as Array<{ name: string }>).map((item) => item.name);
|
||||||
|
assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`);
|
||||||
|
},
|
||||||
|
);
|
||||||
10
apps/api/features/support/hooks.ts
Normal file
10
apps/api/features/support/hooks.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Before, setDefaultTimeout } from "@cucumber/cucumber";
|
||||||
|
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||||
|
|
||||||
|
// Integration tests hitting a real Postgres + argon2 need more than
|
||||||
|
// Cucumber's 5s default, especially on a cold Prisma connection.
|
||||||
|
setDefaultTimeout(10_000);
|
||||||
|
|
||||||
|
Before(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
26
apps/api/features/support/world.ts
Normal file
26
apps/api/features/support/world.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { type IWorldOptions, World, setWorldConstructor } from "@cucumber/cucumber";
|
||||||
|
import type { Express } from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../../src/app.js";
|
||||||
|
|
||||||
|
// Fresh Express app per scenario (in-process, via supertest — no server to
|
||||||
|
// spin up/tear down) plus the last HTTP response, available to every step.
|
||||||
|
// `agent` persists cookies across requests within a scenario (needed for
|
||||||
|
// "sign up then check I'm authenticated" style flows).
|
||||||
|
export class CustomWorld extends World {
|
||||||
|
app: Express;
|
||||||
|
agent: ReturnType<typeof request.agent>;
|
||||||
|
response!: request.Response;
|
||||||
|
/** A second, independent session (own cookie jar) — only used by scenarios needing two distinct signed-in users, e.g. household invites/admin transfer/member removal. */
|
||||||
|
secondAgent: ReturnType<typeof request.agent>;
|
||||||
|
secondResponse!: request.Response;
|
||||||
|
|
||||||
|
constructor(options: IWorldOptions) {
|
||||||
|
super(options);
|
||||||
|
this.app = createApp();
|
||||||
|
this.agent = request.agent(this.app);
|
||||||
|
this.secondAgent = request.agent(this.app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorldConstructor(CustomWorld);
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "cross-env NODE_ENV=test mocha",
|
"test": "cross-env NODE_ENV=test mocha",
|
||||||
|
"test:bdd": "cross-env NODE_ENV=test NODE_OPTIONS=--import=tsx cucumber-js",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:seed": "prisma db seed",
|
"prisma:seed": "prisma db seed",
|
||||||
|
|
@ -26,10 +27,10 @@
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"prisma": "^5.22.0",
|
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cucumber/cucumber": "^13.2.1",
|
||||||
"@faker-js/faker": "^10.6.0",
|
"@faker-js/faker": "^10.6.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
|
@ -38,6 +39,7 @@
|
||||||
"chai": "^5.1.2",
|
"chai": "^5.1.2",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"mocha": "^10.8.2",
|
"mocha": "^10.8.2",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "ingredient_allergy" (
|
|
||||||
"ingredient_id" INTEGER NOT NULL,
|
|
||||||
"allergy_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "ingredient_allergy_pkey" PRIMARY KEY ("ingredient_id","allergy_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "ingredients_name_key" ON "ingredients"("name");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ingredient_allergy" ADD CONSTRAINT "ingredient_allergy_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ingredient_allergy" ADD CONSTRAINT "ingredient_allergy_allergy_id_fkey" FOREIGN KEY ("allergy_id") REFERENCES "allergy"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "RecipeVisibility" AS ENUM ('PERSONAL', 'HOUSE', 'PUBLIC');
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "recipe" ADD COLUMN "author_house_id" INTEGER,
|
|
||||||
ADD COLUMN "author_id" INTEGER NOT NULL,
|
|
||||||
ADD COLUMN "visibility" "RecipeVisibility" NOT NULL DEFAULT 'PERSONAL';
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "user_profile_disliked_ingredient" (
|
|
||||||
"user_profile_id" INTEGER NOT NULL,
|
|
||||||
"ingredient_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "user_profile_disliked_ingredient_pkey" PRIMARY KEY ("user_profile_id","ingredient_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "recipe_favorite" (
|
|
||||||
"user_profile_id" INTEGER NOT NULL,
|
|
||||||
"recipe_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "recipe_favorite_pkey" PRIMARY KEY ("user_profile_id","recipe_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "recipe_diet" (
|
|
||||||
"recipe_id" INTEGER NOT NULL,
|
|
||||||
"diet_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "recipe_diet_pkey" PRIMARY KEY ("recipe_id","diet_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "user_profile_disliked_ingredient" ADD CONSTRAINT "user_profile_disliked_ingredient_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "user_profile_disliked_ingredient" ADD CONSTRAINT "user_profile_disliked_ingredient_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe" ADD CONSTRAINT "recipe_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "user_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe" ADD CONSTRAINT "recipe_author_house_id_fkey" FOREIGN KEY ("author_house_id") REFERENCES "house"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe_favorite" ADD CONSTRAINT "recipe_favorite_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe_favorite" ADD CONSTRAINT "recipe_favorite_recipe_id_fkey" FOREIGN KEY ("recipe_id") REFERENCES "recipe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe_diet" ADD CONSTRAINT "recipe_diet_recipe_id_fkey" FOREIGN KEY ("recipe_id") REFERENCES "recipe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe_diet" ADD CONSTRAINT "recipe_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "IngredientCategory" AS ENUM ('CEREALES_FECULENTS', 'LEGUMINEUSES', 'VIANDES_VOLAILLES', 'POISSONS_FRUITS_DE_MER', 'PRODUITS_LAITIERS_OEUFS', 'LEGUMES', 'FRUITS', 'FRUITS_SECS_OLEAGINEUX', 'CONDIMENTS_SAUCES', 'EPICES_HERBES', 'SUCRE_PATISSERIE', 'CUISINE_ITALIENNE', 'CUISINE_ASIATIQUE', 'CUISINE_MEXICAINE', 'MAGHREB_MOYEN_ORIENT', 'PAINS_SANDWICHS', 'EPICERIE_DIVERS', 'LIQUIDES_BOISSONS');
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "category" "IngredientCategory" NOT NULL DEFAULT 'EPICERIE_DIVERS';
|
|
||||||
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "ingredient_diet" (
|
|
||||||
"ingredient_id" INTEGER NOT NULL,
|
|
||||||
"diet_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "ingredient_diet_pkey" PRIMARY KEY ("ingredient_id","diet_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
-- Rewritten after this migration failed on a database that already had
|
|
||||||
-- seeded ingredient rows: the original version (auto-generated by `prisma
|
|
||||||
-- migrate diff`) cast every existing `category` value directly from the
|
|
||||||
-- old 18-value enum to the new 7-value one, which fails for every row
|
|
||||||
-- since none of the old values exist in the new enum. This version instead
|
|
||||||
-- adds the new columns with a safe default (never casting existing data),
|
|
||||||
-- then swaps them in — the same "add with a default, correct for real on
|
|
||||||
-- the next seed run" pattern already used for `IngredientCategory`'s and
|
|
||||||
-- `IngredientSubcategory`'s own `@default(...)` (see their doc comments in
|
|
||||||
-- schema.prisma). `seedReferenceData()` runs right after `migrate deploy`
|
|
||||||
-- on every container start (see apps/api/Dockerfile) and corrects every
|
|
||||||
-- row's real category/subcategory immediately.
|
|
||||||
--
|
|
||||||
-- `DROP TYPE IF EXISTS "IngredientSubcategory"` guards against a previous
|
|
||||||
-- failed attempt at this exact migration: that CREATE TYPE statement runs
|
|
||||||
-- outside the AlterEnum transaction below and so persists even though the
|
|
||||||
-- rest of that failed attempt rolled back — retrying without this guard
|
|
||||||
-- would hit "type already exists".
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
DROP TYPE IF EXISTS "IngredientSubcategory";
|
|
||||||
CREATE TYPE "IngredientSubcategory" AS ENUM ('LEGUMES', 'FRUITS', 'HERBES_FRAICHES', 'VIANDES', 'VOLAILLES', 'POISSONS', 'CRUSTACES_FRUITS_DE_MER', 'FECULENTS', 'LEGUMINEUSES', 'GRAINES_FRUITS_SECS', 'AUTRES', 'PAINS', 'PATES_A_CUIRE', 'PRODUITS_LAITIERS', 'OEUFS', 'ALTERNATIVES', 'EPICES', 'SAUCES', 'ASSAISONNEMENTS', 'BASES', 'EPAISSISSANTS', 'SUCRES');
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
DROP TYPE IF EXISTS "IngredientCategory_new";
|
|
||||||
CREATE TYPE "IngredientCategory_new" AS ENUM ('PRODUITS_FRAIS', 'BOUCHERIE_POISSONNERIE', 'EPICERIE_SECHE', 'BOULANGERIE', 'CREMERIE_FROMAGE', 'CONDIMENTS_EPICES', 'AIDES_CULINAIRES');
|
|
||||||
|
|
||||||
-- AlterTable: add the new columns at their defaults — no cast of existing
|
|
||||||
-- `category` values, so this succeeds regardless of what the table
|
|
||||||
-- currently holds.
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "category_new" "IngredientCategory_new" NOT NULL DEFAULT 'EPICERIE_SECHE';
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "subcategory" "IngredientSubcategory" NOT NULL DEFAULT 'AUTRES';
|
|
||||||
|
|
||||||
-- Swap the old `category` column (old 18-value enum) out for the new one.
|
|
||||||
ALTER TABLE "ingredients" DROP COLUMN "category";
|
|
||||||
ALTER TABLE "ingredients" RENAME COLUMN "category_new" TO "category";
|
|
||||||
DROP TYPE "IngredientCategory";
|
|
||||||
ALTER TYPE "IngredientCategory_new" RENAME TO "IngredientCategory";
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "IngredientIcon" AS ENUM ('VEGETABLE', 'FRUIT', 'HERB', 'MEAT', 'POULTRY', 'FISH', 'SHELLFISH', 'GRAIN', 'LEGUME', 'NUT_SEED', 'BREAD', 'DOUGH', 'MILK', 'CHEESE', 'EGG', 'SPROUT', 'SPICE', 'JAR', 'BOTTLE', 'DRINK', 'STOCK_POT', 'SUGAR');
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "ingredients" DROP COLUMN "icon",
|
|
||||||
ADD COLUMN "icon" "IngredientIcon" NOT NULL DEFAULT 'JAR';
|
|
||||||
|
|
@ -1,480 +0,0 @@
|
||||||
-- Renames the reference-data label column to a stable slug key on Diet,
|
|
||||||
-- Category (allergens), and Ingredient — see `utils/slugify.ts` and
|
|
||||||
-- `scripts/generate-catalog-i18n.ts`. The display label these columns used
|
|
||||||
-- to hold moves to `apps/web`'s `locales/fr/translation.json` (`catalog.*`
|
|
||||||
-- namespace) instead.
|
|
||||||
|
|
||||||
ALTER TABLE "diet" RENAME COLUMN "name" TO "key";
|
|
||||||
ALTER INDEX "diet_name_key" RENAME TO "diet_key_key";
|
|
||||||
|
|
||||||
ALTER TABLE "category" RENAME COLUMN "name" TO "key";
|
|
||||||
ALTER INDEX "category_name_key" RENAME TO "category_key_key";
|
|
||||||
|
|
||||||
ALTER TABLE "ingredients" RENAME COLUMN "name" TO "key";
|
|
||||||
ALTER INDEX "ingredients_name_key" RENAME TO "ingredients_key_key";
|
|
||||||
|
|
||||||
-- Auto-generated by scripts/generate-catalog-i18n.ts — do not hand-edit.
|
|
||||||
-- Backfills the "key" column (just renamed from "name" by this migration's
|
|
||||||
-- preceding statement, so it still holds the old French label) to its slug
|
|
||||||
-- value, for every row already seeded in a database this migration runs
|
|
||||||
-- against. A fresh database has none of these rows yet (the seed script
|
|
||||||
-- inserts by "key" from the start), so this is a no-op there.
|
|
||||||
|
|
||||||
UPDATE "diet" SET "key" = 'omnivore' WHERE "key" = 'Omnivore';
|
|
||||||
UPDATE "diet" SET "key" = 'vegetarien' WHERE "key" = 'Végétarien';
|
|
||||||
UPDATE "diet" SET "key" = 'vegan' WHERE "key" = 'Végan';
|
|
||||||
UPDATE "diet" SET "key" = 'pescetarien' WHERE "key" = 'Pescétarien';
|
|
||||||
UPDATE "diet" SET "key" = 'sans_gluten' WHERE "key" = 'Sans gluten';
|
|
||||||
|
|
||||||
UPDATE "category" SET "key" = 'gluten' WHERE "key" = 'Gluten';
|
|
||||||
UPDATE "category" SET "key" = 'crustaces' WHERE "key" = 'Crustacés';
|
|
||||||
UPDATE "category" SET "key" = 'oeufs' WHERE "key" = 'Œufs';
|
|
||||||
UPDATE "category" SET "key" = 'poissons' WHERE "key" = 'Poissons';
|
|
||||||
UPDATE "category" SET "key" = 'arachides' WHERE "key" = 'Arachides';
|
|
||||||
UPDATE "category" SET "key" = 'soja' WHERE "key" = 'Soja';
|
|
||||||
UPDATE "category" SET "key" = 'lait' WHERE "key" = 'Lait';
|
|
||||||
UPDATE "category" SET "key" = 'fruits_a_coque' WHERE "key" = 'Fruits à coque';
|
|
||||||
UPDATE "category" SET "key" = 'celeri' WHERE "key" = 'Céleri';
|
|
||||||
UPDATE "category" SET "key" = 'moutarde' WHERE "key" = 'Moutarde';
|
|
||||||
UPDATE "category" SET "key" = 'graines_de_sesame' WHERE "key" = 'Graines de sésame';
|
|
||||||
UPDATE "category" SET "key" = 'sulfites' WHERE "key" = 'Sulfites';
|
|
||||||
UPDATE "category" SET "key" = 'lupin' WHERE "key" = 'Lupin';
|
|
||||||
UPDATE "category" SET "key" = 'mollusques' WHERE "key" = 'Mollusques';
|
|
||||||
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomate' WHERE "key" = 'Tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oignon' WHERE "key" = 'Oignon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'echalote' WHERE "key" = 'Échalote';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ail' WHERE "key" = 'Ail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'carotte' WHERE "key" = 'Carotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'courgette' WHERE "key" = 'Courgette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'concombre' WHERE "key" = 'Concombre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cornichons' WHERE "key" = 'Cornichons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poivron' WHERE "key" = 'Poivron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'champignon' WHERE "key" = 'Champignon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cepes' WHERE "key" = 'Cèpes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'aubergine' WHERE "key" = 'Aubergine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brocoli' WHERE "key" = 'Brocoli';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chou_fleur' WHERE "key" = 'Chou-fleur';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chou_blanc' WHERE "key" = 'Chou blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chou_rouge' WHERE "key" = 'Chou rouge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chou_de_bruxelles' WHERE "key" = 'Chou de Bruxelles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'epinard' WHERE "key" = 'Épinard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blette' WHERE "key" = 'Blette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'salade' WHERE "key" = 'Salade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'roquette' WHERE "key" = 'Roquette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cresson' WHERE "key" = 'Cresson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poireau' WHERE "key" = 'Poireau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'celeri' WHERE "key" = 'Céleri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'radis' WHERE "key" = 'Radis';
|
|
||||||
UPDATE "ingredients" SET "key" = 'betterave' WHERE "key" = 'Betterave';
|
|
||||||
UPDATE "ingredients" SET "key" = 'navet' WHERE "key" = 'Navet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'panais' WHERE "key" = 'Panais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haricot_vert' WHERE "key" = 'Haricot vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'petit_pois' WHERE "key" = 'Petit pois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mais' WHERE "key" = 'Maïs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'artichaut' WHERE "key" = 'Artichaut';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fenouil' WHERE "key" = 'Fenouil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'endive' WHERE "key" = 'Endive';
|
|
||||||
UPDATE "ingredients" SET "key" = 'potiron' WHERE "key" = 'Potiron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'butternut' WHERE "key" = 'Butternut';
|
|
||||||
UPDATE "ingredients" SET "key" = 'asperge' WHERE "key" = 'Asperge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'avocat' WHERE "key" = 'Avocat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pomme_de_terre' WHERE "key" = 'Pomme de terre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'patate_douce' WHERE "key" = 'Patate douce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomates_cerises' WHERE "key" = 'Tomates cerises';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pak_choi' WHERE "key" = 'Pak-choï';
|
|
||||||
UPDATE "ingredients" SET "key" = 'germes_de_soja' WHERE "key" = 'Germes de soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shiitake' WHERE "key" = 'Shiitake';
|
|
||||||
UPDATE "ingredients" SET "key" = 'daikon' WHERE "key" = 'Daikon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_vert_frais' WHERE "key" = 'Piment vert frais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'citron' WHERE "key" = 'Citron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'citron_vert' WHERE "key" = 'Citron vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pomme' WHERE "key" = 'Pomme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poire' WHERE "key" = 'Poire';
|
|
||||||
UPDATE "ingredients" SET "key" = 'banane' WHERE "key" = 'Banane';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orange' WHERE "key" = 'Orange';
|
|
||||||
UPDATE "ingredients" SET "key" = 'clementine' WHERE "key" = 'Clémentine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pamplemousse' WHERE "key" = 'Pamplemousse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fraise' WHERE "key" = 'Fraise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'framboise' WHERE "key" = 'Framboise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'myrtille' WHERE "key" = 'Myrtille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mure' WHERE "key" = 'Mûre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cerise' WHERE "key" = 'Cerise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'abricot' WHERE "key" = 'Abricot';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peche' WHERE "key" = 'Pêche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'prune' WHERE "key" = 'Prune';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raisin' WHERE "key" = 'Raisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'melon' WHERE "key" = 'Melon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pasteque' WHERE "key" = 'Pastèque';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ananas' WHERE "key" = 'Ananas';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mangue' WHERE "key" = 'Mangue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kiwi' WHERE "key" = 'Kiwi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'figue' WHERE "key" = 'Figue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'datte' WHERE "key" = 'Datte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'litchi' WHERE "key" = 'Litchi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grenade' WHERE "key" = 'Grenade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rhubarbe' WHERE "key" = 'Rhubarbe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coing' WHERE "key" = 'Coing';
|
|
||||||
UPDATE "ingredients" SET "key" = 'basilic' WHERE "key" = 'Basilic';
|
|
||||||
UPDATE "ingredients" SET "key" = 'persil' WHERE "key" = 'Persil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'thym' WHERE "key" = 'Thym';
|
|
||||||
UPDATE "ingredients" SET "key" = 'romarin' WHERE "key" = 'Romarin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'laurier' WHERE "key" = 'Laurier';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ciboulette' WHERE "key" = 'Ciboulette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coriandre_fraiche' WHERE "key" = 'Coriandre fraîche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'menthe' WHERE "key" = 'Menthe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'origan' WHERE "key" = 'Origan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'aneth' WHERE "key" = 'Aneth';
|
|
||||||
UPDATE "ingredients" SET "key" = 'estragon' WHERE "key" = 'Estragon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sarriette' WHERE "key" = 'Sarriette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'marjolaine' WHERE "key" = 'Marjolaine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauge' WHERE "key" = 'Sauge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cerfeuil' WHERE "key" = 'Cerfeuil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gingembre' WHERE "key" = 'Gingembre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'citronnelle' WHERE "key" = 'Citronnelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'combava' WHERE "key" = 'Combava';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lapin' WHERE "key" = 'Lapin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'boeuf_hache' WHERE "key" = 'Bœuf haché';
|
|
||||||
UPDATE "ingredients" SET "key" = 'steak_de_boeuf' WHERE "key" = 'Steak de bœuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'roti_de_boeuf' WHERE "key" = 'Rôti de bœuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'escalope_de_veau' WHERE "key" = 'Escalope de veau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'filet_mignon_de_porc' WHERE "key" = 'Filet mignon de porc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cote_de_porc' WHERE "key" = 'Côte de porc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'agneau' WHERE "key" = 'Agneau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gigot_d_agneau' WHERE "key" = 'Gigot d''agneau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lardons' WHERE "key" = 'Lardons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bacon' WHERE "key" = 'Bacon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jambon_blanc' WHERE "key" = 'Jambon blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jambon_cru' WHERE "key" = 'Jambon cru';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saucisse' WHERE "key" = 'Saucisse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chorizo' WHERE "key" = 'Chorizo';
|
|
||||||
UPDATE "ingredients" SET "key" = 'merguez' WHERE "key" = 'Merguez';
|
|
||||||
UPDATE "ingredients" SET "key" = 'prosciutto' WHERE "key" = 'Prosciutto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pancetta' WHERE "key" = 'Pancetta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mortadelle' WHERE "key" = 'Mortadelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'salami' WHERE "key" = 'Salami';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poulet' WHERE "key" = 'Poulet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dinde' WHERE "key" = 'Dinde';
|
|
||||||
UPDATE "ingredients" SET "key" = 'canard' WHERE "key" = 'Canard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'magret_de_canard' WHERE "key" = 'Magret de canard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saumon' WHERE "key" = 'Saumon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'thon' WHERE "key" = 'Thon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cabillaud' WHERE "key" = 'Cabillaud';
|
|
||||||
UPDATE "ingredients" SET "key" = 'truite' WHERE "key" = 'Truite';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sardine' WHERE "key" = 'Sardine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'anchois' WHERE "key" = 'Anchois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'merlan' WHERE "key" = 'Merlan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'surimi' WHERE "key" = 'Surimi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bar_loup_de_mer' WHERE "key" = 'Bar (loup de mer)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dorade' WHERE "key" = 'Dorade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sole' WHERE "key" = 'Sole';
|
|
||||||
UPDATE "ingredients" SET "key" = 'turbot' WHERE "key" = 'Turbot';
|
|
||||||
UPDATE "ingredients" SET "key" = 'merlu' WHERE "key" = 'Merlu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'colin' WHERE "key" = 'Colin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lieu_noir' WHERE "key" = 'Lieu noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eglefin' WHERE "key" = 'Églefin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'maquereau' WHERE "key" = 'Maquereau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hareng' WHERE "key" = 'Hareng';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rouget' WHERE "key" = 'Rouget';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raie' WHERE "key" = 'Raie';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lotte' WHERE "key" = 'Lotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fletan' WHERE "key" = 'Flétan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'espadon' WHERE "key" = 'Espadon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'carpe' WHERE "key" = 'Carpe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brochet' WHERE "key" = 'Brochet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'perche' WHERE "key" = 'Perche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tilapia' WHERE "key" = 'Tilapia';
|
|
||||||
UPDATE "ingredients" SET "key" = 'panga' WHERE "key" = 'Panga';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saumon_fume' WHERE "key" = 'Saumon fumé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poisson_seche' WHERE "key" = 'Poisson séché';
|
|
||||||
UPDATE "ingredients" SET "key" = 'crevettes' WHERE "key" = 'Crevettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'langoustines' WHERE "key" = 'Langoustines';
|
|
||||||
UPDATE "ingredients" SET "key" = 'homard' WHERE "key" = 'Homard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'crabe' WHERE "key" = 'Crabe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'langouste' WHERE "key" = 'Langouste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'moules' WHERE "key" = 'Moules';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huitres' WHERE "key" = 'Huîtres';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saint_jacques' WHERE "key" = 'Saint-Jacques';
|
|
||||||
UPDATE "ingredients" SET "key" = 'calamar' WHERE "key" = 'Calamar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poulpe' WHERE "key" = 'Poulpe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'palourdes' WHERE "key" = 'Palourdes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bulots' WHERE "key" = 'Bulots';
|
|
||||||
UPDATE "ingredients" SET "key" = 'semoule' WHERE "key" = 'Semoule';
|
|
||||||
UPDATE "ingredients" SET "key" = 'couscous' WHERE "key" = 'Couscous';
|
|
||||||
UPDATE "ingredients" SET "key" = 'boulgour' WHERE "key" = 'Boulgour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'polenta' WHERE "key" = 'Polenta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'quinoa' WHERE "key" = 'Quinoa';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pates' WHERE "key" = 'Pâtes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pates_completes' WHERE "key" = 'Pâtes complètes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz' WHERE "key" = 'Riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_basmati' WHERE "key" = 'Riz basmati';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_complet' WHERE "key" = 'Riz complet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'flocons_d_avoine' WHERE "key" = 'Flocons d''avoine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spaghetti' WHERE "key" = 'Spaghetti';
|
|
||||||
UPDATE "ingredients" SET "key" = 'penne' WHERE "key" = 'Penne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tagliatelles' WHERE "key" = 'Tagliatelles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lasagnes_feuilles' WHERE "key" = 'Lasagnes (feuilles)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gnocchi' WHERE "key" = 'Gnocchi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_arborio' WHERE "key" = 'Riz arborio';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nouilles_de_riz' WHERE "key" = 'Nouilles de riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nouilles_udon' WHERE "key" = 'Nouilles udon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nouilles_soba' WHERE "key" = 'Nouilles soba';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nouilles_chinoises' WHERE "key" = 'Nouilles chinoises';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vermicelles_de_riz' WHERE "key" = 'Vermicelles de riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vermicelles_de_soja' WHERE "key" = 'Vermicelles de soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_gluant' WHERE "key" = 'Riz gluant';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_a_sushi' WHERE "key" = 'Riz à sushi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riz_jasmin' WHERE "key" = 'Riz jasmin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lentilles_vertes' WHERE "key" = 'Lentilles vertes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lentilles_corail' WHERE "key" = 'Lentilles corail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pois_chiches' WHERE "key" = 'Pois chiches';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haricots_blancs' WHERE "key" = 'Haricots blancs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haricots_rouges' WHERE "key" = 'Haricots rouges';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haricots_noirs' WHERE "key" = 'Haricots noirs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pois_casses' WHERE "key" = 'Pois cassés';
|
|
||||||
UPDATE "ingredients" SET "key" = 'feves' WHERE "key" = 'Fèves';
|
|
||||||
UPDATE "ingredients" SET "key" = 'edamame' WHERE "key" = 'Edamame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haricots_pinto' WHERE "key" = 'Haricots pinto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cacahuetes' WHERE "key" = 'Cacahuètes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'amandes' WHERE "key" = 'Amandes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noix' WHERE "key" = 'Noix';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noisettes' WHERE "key" = 'Noisettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noix_de_cajou' WHERE "key" = 'Noix de cajou';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pistaches' WHERE "key" = 'Pistaches';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noix_de_pecan' WHERE "key" = 'Noix de pécan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poudre_d_amande' WHERE "key" = 'Poudre d''amande';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pignons_de_pin' WHERE "key" = 'Pignons de pin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'graines_de_tournesol' WHERE "key" = 'Graines de tournesol';
|
|
||||||
UPDATE "ingredients" SET "key" = 'graines_de_courge' WHERE "key" = 'Graines de courge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noix_de_coco_rapee' WHERE "key" = 'Noix de coco râpée';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raisins_secs' WHERE "key" = 'Raisins secs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pruneaux' WHERE "key" = 'Pruneaux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'abricots_secs' WHERE "key" = 'Abricots secs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'graines_de_sesame' WHERE "key" = 'Graines de sésame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'champignons_noirs' WHERE "key" = 'Champignons noirs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'algue_nori' WHERE "key" = 'Algue nori';
|
|
||||||
UPDATE "ingredients" SET "key" = 'algue_wakame' WHERE "key" = 'Algue wakamé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'algue_kombu' WHERE "key" = 'Algue kombu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pousses_de_bambou' WHERE "key" = 'Pousses de bambou';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chataignes_d_eau' WHERE "key" = 'Châtaignes d''eau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain' WHERE "key" = 'Pain';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_de_mie' WHERE "key" = 'Pain de mie';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_complet' WHERE "key" = 'Pain complet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baguette' WHERE "key" = 'Baguette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_de_seigle' WHERE "key" = 'Pain de seigle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chapelure' WHERE "key" = 'Chapelure';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_a_burger' WHERE "key" = 'Pain à burger';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_brioche' WHERE "key" = 'Pain brioché';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_a_hot_dog' WHERE "key" = 'Pain à hot-dog';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_pita' WHERE "key" = 'Pain pita';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_bagel' WHERE "key" = 'Pain bagel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'naan' WHERE "key" = 'Naan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_wrap' WHERE "key" = 'Pain wrap';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_viennois' WHERE "key" = 'Pain viennois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_de_campagne' WHERE "key" = 'Pain de campagne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_aux_cereales' WHERE "key" = 'Pain aux céréales';
|
|
||||||
UPDATE "ingredients" SET "key" = 'petit_pain' WHERE "key" = 'Petit pain';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_suedois' WHERE "key" = 'Pain suédois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pain_sans_gluten' WHERE "key" = 'Pain sans gluten';
|
|
||||||
UPDATE "ingredients" SET "key" = 'biscotte' WHERE "key" = 'Biscotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'croutons' WHERE "key" = 'Croûtons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'focaccia' WHERE "key" = 'Focaccia';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ciabatta' WHERE "key" = 'Ciabatta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tortilla_de_mais' WHERE "key" = 'Tortilla de maïs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tortilla_de_ble' WHERE "key" = 'Tortilla de blé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_feuilletee' WHERE "key" = 'Pâte feuilletée';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_brisee' WHERE "key" = 'Pâte brisée';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_a_pizza' WHERE "key" = 'Pâte à pizza';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_a_tarte_sablee' WHERE "key" = 'Pâte à tarte sablée';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lait' WHERE "key" = 'Lait';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beurre' WHERE "key" = 'Beurre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'creme_fraiche' WHERE "key" = 'Crème fraîche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'creme_liquide' WHERE "key" = 'Crème liquide';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fromage' WHERE "key" = 'Fromage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'emmental' WHERE "key" = 'Emmental';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gruyere' WHERE "key" = 'Gruyère';
|
|
||||||
UPDATE "ingredients" SET "key" = 'parmesan' WHERE "key" = 'Parmesan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mozzarella' WHERE "key" = 'Mozzarella';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chevre_fromage' WHERE "key" = 'Chèvre (fromage)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'feta' WHERE "key" = 'Feta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'comte' WHERE "key" = 'Comté';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fromage_blanc' WHERE "key" = 'Fromage blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mascarpone' WHERE "key" = 'Mascarpone';
|
|
||||||
UPDATE "ingredients" SET "key" = 'yaourt' WHERE "key" = 'Yaourt';
|
|
||||||
UPDATE "ingredients" SET "key" = 'burrata' WHERE "key" = 'Burrata';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ricotta' WHERE "key" = 'Ricotta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pecorino' WHERE "key" = 'Pecorino';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gorgonzola' WHERE "key" = 'Gorgonzola';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cheddar' WHERE "key" = 'Cheddar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oeuf' WHERE "key" = 'Œuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lait_de_coco' WHERE "key" = 'Lait de coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'creme_de_coco' WHERE "key" = 'Crème de coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lait_d_amande' WHERE "key" = 'Lait d''amande';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lait_d_avoine' WHERE "key" = 'Lait d''avoine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tofu' WHERE "key" = 'Tofu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tofu_soyeux' WHERE "key" = 'Tofu soyeux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herbes_de_provence' WHERE "key" = 'Herbes de Provence';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poivre_noir' WHERE "key" = 'Poivre noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'paprika' WHERE "key" = 'Paprika';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_d_espelette' WHERE "key" = 'Piment d''Espelette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_de_cayenne' WHERE "key" = 'Piment de Cayenne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cumin' WHERE "key" = 'Cumin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curry_poudre' WHERE "key" = 'Curry (poudre)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curcuma' WHERE "key" = 'Curcuma';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cannelle' WHERE "key" = 'Cannelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'muscade' WHERE "key" = 'Muscade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'safran' WHERE "key" = 'Safran';
|
|
||||||
UPDATE "ingredients" SET "key" = 'clou_de_girofle' WHERE "key" = 'Clou de girofle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vanille_gousse' WHERE "key" = 'Vanille (gousse)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poivre_blanc' WHERE "key" = 'Poivre blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poivre_rose' WHERE "key" = 'Poivre rose';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poivre_du_sichuan' WHERE "key" = 'Poivre du Sichuan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'paprika_fume' WHERE "key" = 'Paprika fumé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_oiseau' WHERE "key" = 'Piment oiseau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baies_de_genievre' WHERE "key" = 'Baies de genièvre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'anis_etoile_badiane' WHERE "key" = 'Anis étoilé (badiane)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'anis_vert' WHERE "key" = 'Anis vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'graines_de_fenouil' WHERE "key" = 'Graines de fenouil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sumac' WHERE "key" = 'Sumac';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nigelle' WHERE "key" = 'Nigelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'quatre_epices' WHERE "key" = 'Quatre épices';
|
|
||||||
UPDATE "ingredients" SET "key" = 'colombo_poudre' WHERE "key" = 'Colombo (poudre)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baharat' WHERE "key" = 'Baharat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raifort' WHERE "key" = 'Raifort';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sel_aux_herbes' WHERE "key" = 'Sel aux herbes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sel_de_celeri' WHERE "key" = 'Sel de céleri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fleur_de_sel' WHERE "key" = 'Fleur de sel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sel' WHERE "key" = 'Sel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cinq_epices' WHERE "key" = 'Cinq épices';
|
|
||||||
UPDATE "ingredients" SET "key" = 'garam_masala' WHERE "key" = 'Garam masala';
|
|
||||||
UPDATE "ingredients" SET "key" = 'graines_de_coriandre' WHERE "key" = 'Graines de coriandre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cardamome' WHERE "key" = 'Cardamome';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fenugrec' WHERE "key" = 'Fenugrec';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_jalapeno' WHERE "key" = 'Piment jalapeño';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_chipotle' WHERE "key" = 'Piment chipotle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_poblano' WHERE "key" = 'Piment poblano';
|
|
||||||
UPDATE "ingredients" SET "key" = 'piment_habanero' WHERE "key" = 'Piment habanero';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ras_el_hanout' WHERE "key" = 'Ras el hanout';
|
|
||||||
UPDATE "ingredients" SET "key" = 'za_atar' WHERE "key" = 'Za''atar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_soja' WHERE "key" = 'Sauce soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'moutarde' WHERE "key" = 'Moutarde';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mayonnaise' WHERE "key" = 'Mayonnaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ketchup' WHERE "key" = 'Ketchup';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tabasco' WHERE "key" = 'Tabasco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_worcestershire' WHERE "key" = 'Sauce Worcestershire';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_nuoc_mam' WHERE "key" = 'Sauce nuoc-mâm';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wasabi' WHERE "key" = 'Wasabi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'harissa' WHERE "key" = 'Harissa';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_de_curry' WHERE "key" = 'Pâte de curry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beurre_de_cacahuete' WHERE "key" = 'Beurre de cacahuète';
|
|
||||||
UPDATE "ingredients" SET "key" = 'moutarde_de_dijon' WHERE "key" = 'Moutarde de Dijon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'moutarde_a_l_ancienne' WHERE "key" = 'Moutarde à l''ancienne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_barbecue' WHERE "key" = 'Sauce barbecue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_tartare' WHERE "key" = 'Sauce tartare';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_cocktail' WHERE "key" = 'Sauce cocktail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_bearnaise' WHERE "key" = 'Sauce béarnaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_hollandaise' WHERE "key" = 'Sauce hollandaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_bechamel' WHERE "key" = 'Sauce béchamel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_teriyaki' WHERE "key" = 'Sauce teriyaki';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_ponzu' WHERE "key" = 'Sauce ponzu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chimichurri' WHERE "key" = 'Chimichurri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pesto_rouge_tomates_sechees' WHERE "key" = 'Pesto rouge (tomates séchées)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pesto' WHERE "key" = 'Pesto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_huitre' WHERE "key" = 'Sauce huître';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_hoisin' WHERE "key" = 'Sauce hoisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_sriracha' WHERE "key" = 'Sauce sriracha';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sauce_sweet_chili' WHERE "key" = 'Sauce sweet chili';
|
|
||||||
UPDATE "ingredients" SET "key" = 'miso' WHERE "key" = 'Miso';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_de_crevettes' WHERE "key" = 'Pâte de crevettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_de_curry_rouge_thai' WHERE "key" = 'Pâte de curry rouge (thaï)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pate_de_curry_vert_thai' WHERE "key" = 'Pâte de curry vert (thaï)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tahini' WHERE "key" = 'Tahini';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_d_olive' WHERE "key" = 'Huile d''olive';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_tournesol' WHERE "key" = 'Huile de tournesol';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_colza' WHERE "key" = 'Huile de colza';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_coco' WHERE "key" = 'Huile de coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_sesame' WHERE "key" = 'Huile de sésame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_de_cidre' WHERE "key" = 'Vinaigre de cidre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_blanc' WHERE "key" = 'Vinaigre blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_balsamique' WHERE "key" = 'Vinaigre balsamique';
|
|
||||||
UPDATE "ingredients" SET "key" = 'capres' WHERE "key" = 'Câpres';
|
|
||||||
UPDATE "ingredients" SET "key" = 'olives' WHERE "key" = 'Olives';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vin_blanc_cuisine' WHERE "key" = 'Vin blanc (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vin_rouge_cuisine' WHERE "key" = 'Vin rouge (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_de_vin_rouge' WHERE "key" = 'Vinaigre de vin rouge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_de_vin_blanc' WHERE "key" = 'Vinaigre de vin blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_de_xeres' WHERE "key" = 'Vinaigre de xérès';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_noix' WHERE "key" = 'Huile de noix';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_de_noisette' WHERE "key" = 'Huile de noisette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_d_arachide' WHERE "key" = 'Huile d''arachide';
|
|
||||||
UPDATE "ingredients" SET "key" = 'huile_pimentee' WHERE "key" = 'Huile pimentée';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinaigre_de_riz' WHERE "key" = 'Vinaigre de riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mirin' WHERE "key" = 'Mirin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sake_cuisine' WHERE "key" = 'Saké (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_citron' WHERE "key" = 'Jus de citron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_citron_vert' WHERE "key" = 'Jus de citron vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_d_orange' WHERE "key" = 'Jus d''orange';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_pomme' WHERE "key" = 'Jus de pomme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_raisin' WHERE "key" = 'Jus de raisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_tomate' WHERE "key" = 'Jus de tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jus_de_cranberry' WHERE "key" = 'Jus de cranberry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cafe' WHERE "key" = 'Café';
|
|
||||||
UPDATE "ingredients" SET "key" = 'the' WHERE "key" = 'Thé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'biere_cuisine' WHERE "key" = 'Bière (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cidre_cuisine' WHERE "key" = 'Cidre (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'champagne_vin_petillant_cuisine' WHERE "key" = 'Champagne / vin pétillant (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'porto_cuisine' WHERE "key" = 'Porto (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vin_jaune_cuisine' WHERE "key" = 'Vin jaune (cuisine)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cognac' WHERE "key" = 'Cognac';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rhum' WHERE "key" = 'Rhum';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whisky' WHERE "key" = 'Whisky';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vodka' WHERE "key" = 'Vodka';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_ble' WHERE "key" = 'Farine de blé';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_complete' WHERE "key" = 'Farine complète';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_mais' WHERE "key" = 'Farine de maïs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_sarrasin' WHERE "key" = 'Farine de sarrasin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_riz' WHERE "key" = 'Farine de riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_cube_legumes' WHERE "key" = 'Bouillon cube légumes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_cube_volaille' WHERE "key" = 'Bouillon cube volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'concentre_de_tomate' WHERE "key" = 'Concentré de tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coulis_de_tomate' WHERE "key" = 'Coulis de tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomates_pelees_conserve' WHERE "key" = 'Tomates pelées (conserve)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomates_sechees' WHERE "key" = 'Tomates séchées';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fond_de_veau' WHERE "key" = 'Fond de veau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fond_de_volaille' WHERE "key" = 'Fond de volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_cube_boeuf' WHERE "key" = 'Bouillon cube bœuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_cube_poisson' WHERE "key" = 'Bouillon cube poisson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_de_legumes' WHERE "key" = 'Bouillon de légumes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_de_volaille' WHERE "key" = 'Bouillon de volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bouillon_de_boeuf' WHERE "key" = 'Bouillon de bœuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'court_bouillon' WHERE "key" = 'Court-bouillon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dashi_bouillon_japonais' WHERE "key" = 'Dashi (bouillon japonais)';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bisque_de_crustaces' WHERE "key" = 'Bisque de crustacés';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_tapioca' WHERE "key" = 'Farine de tapioca';
|
|
||||||
UPDATE "ingredients" SET "key" = 'masa_harina' WHERE "key" = 'Masa harina';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eau' WHERE "key" = 'Eau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eau_gazeuse' WHERE "key" = 'Eau gazeuse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eau_de_fleur_d_oranger' WHERE "key" = 'Eau de fleur d''oranger';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eau_de_rose' WHERE "key" = 'Eau de rose';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fumet_de_poisson' WHERE "key" = 'Fumet de poisson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'levure_boulangere' WHERE "key" = 'Levure boulangère';
|
|
||||||
UPDATE "ingredients" SET "key" = 'levure_chimique' WHERE "key" = 'Levure chimique';
|
|
||||||
UPDATE "ingredients" SET "key" = 'maizena' WHERE "key" = 'Maïzena';
|
|
||||||
UPDATE "ingredients" SET "key" = 'farine_de_lupin' WHERE "key" = 'Farine de lupin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gelatine' WHERE "key" = 'Gélatine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bicarbonate_de_soude' WHERE "key" = 'Bicarbonate de soude';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fecule_de_pomme_de_terre' WHERE "key" = 'Fécule de pomme de terre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sucre' WHERE "key" = 'Sucre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'miel' WHERE "key" = 'Miel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sirop_d_erable' WHERE "key" = 'Sirop d''érable';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sucre_roux' WHERE "key" = 'Sucre roux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sucre_glace' WHERE "key" = 'Sucre glace';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cassonade' WHERE "key" = 'Cassonade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chocolat_noir' WHERE "key" = 'Chocolat noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chocolat_au_lait' WHERE "key" = 'Chocolat au lait';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chocolat_blanc' WHERE "key" = 'Chocolat blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pepites_de_chocolat' WHERE "key" = 'Pépites de chocolat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cacao_en_poudre' WHERE "key" = 'Cacao en poudre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'extrait_de_vanille' WHERE "key" = 'Extrait de vanille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sucre_de_palme' WHERE "key" = 'Sucre de palme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sirop_de_sucre_de_canne' WHERE "key" = 'Sirop de sucre de canne';
|
|
||||||
|
|
@ -1,465 +0,0 @@
|
||||||
-- Auto-generated once by scripts/gen-english-remap-sql.ts — do not re-run,
|
|
||||||
-- do not hand-edit. Remaps every Diet/Category(allergen)/Ingredient row's
|
|
||||||
-- "key" from the French slug the previous migration
|
|
||||||
-- (20260818_catalog_labels_to_keys) produced to the hand-assigned English
|
|
||||||
-- key in catalog-en-keys.ts.
|
|
||||||
|
|
||||||
UPDATE "diet" SET "key" = 'omnivore' WHERE "key" = 'omnivore';
|
|
||||||
UPDATE "diet" SET "key" = 'vegetarian' WHERE "key" = 'vegetarien';
|
|
||||||
UPDATE "diet" SET "key" = 'vegan' WHERE "key" = 'vegan';
|
|
||||||
UPDATE "diet" SET "key" = 'pescatarian' WHERE "key" = 'pescetarien';
|
|
||||||
UPDATE "diet" SET "key" = 'gluten_free' WHERE "key" = 'sans_gluten';
|
|
||||||
|
|
||||||
UPDATE "category" SET "key" = 'gluten' WHERE "key" = 'gluten';
|
|
||||||
UPDATE "category" SET "key" = 'crustaceans' WHERE "key" = 'crustaces';
|
|
||||||
UPDATE "category" SET "key" = 'eggs' WHERE "key" = 'oeufs';
|
|
||||||
UPDATE "category" SET "key" = 'fish' WHERE "key" = 'poissons';
|
|
||||||
UPDATE "category" SET "key" = 'peanuts' WHERE "key" = 'arachides';
|
|
||||||
UPDATE "category" SET "key" = 'soy' WHERE "key" = 'soja';
|
|
||||||
UPDATE "category" SET "key" = 'milk' WHERE "key" = 'lait';
|
|
||||||
UPDATE "category" SET "key" = 'tree_nuts' WHERE "key" = 'fruits_a_coque';
|
|
||||||
UPDATE "category" SET "key" = 'celery' WHERE "key" = 'celeri';
|
|
||||||
UPDATE "category" SET "key" = 'mustard' WHERE "key" = 'moutarde';
|
|
||||||
UPDATE "category" SET "key" = 'sesame_seeds' WHERE "key" = 'graines_de_sesame';
|
|
||||||
UPDATE "category" SET "key" = 'sulfites' WHERE "key" = 'sulfites';
|
|
||||||
UPDATE "category" SET "key" = 'lupin' WHERE "key" = 'lupin';
|
|
||||||
UPDATE "category" SET "key" = 'molluscs' WHERE "key" = 'mollusques';
|
|
||||||
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomato' WHERE "key" = 'tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'onion' WHERE "key" = 'oignon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shallot' WHERE "key" = 'echalote';
|
|
||||||
UPDATE "ingredients" SET "key" = 'garlic' WHERE "key" = 'ail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'carrot' WHERE "key" = 'carotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'zucchini' WHERE "key" = 'courgette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cucumber' WHERE "key" = 'concombre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gherkins' WHERE "key" = 'cornichons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bell_pepper' WHERE "key" = 'poivron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mushroom' WHERE "key" = 'champignon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'porcini' WHERE "key" = 'cepes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'eggplant' WHERE "key" = 'aubergine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'broccoli' WHERE "key" = 'brocoli';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cauliflower' WHERE "key" = 'chou_fleur';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_cabbage' WHERE "key" = 'chou_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_cabbage' WHERE "key" = 'chou_rouge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brussels_sprouts' WHERE "key" = 'chou_de_bruxelles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spinach' WHERE "key" = 'epinard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'swiss_chard' WHERE "key" = 'blette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lettuce' WHERE "key" = 'salade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'arugula' WHERE "key" = 'roquette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'watercress' WHERE "key" = 'cresson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'leek' WHERE "key" = 'poireau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'celery' WHERE "key" = 'celeri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'radish' WHERE "key" = 'radis';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beetroot' WHERE "key" = 'betterave';
|
|
||||||
UPDATE "ingredients" SET "key" = 'turnip' WHERE "key" = 'navet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'parsnip' WHERE "key" = 'panais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'green_bean' WHERE "key" = 'haricot_vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pea' WHERE "key" = 'petit_pois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'corn' WHERE "key" = 'mais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'artichoke' WHERE "key" = 'artichaut';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fennel' WHERE "key" = 'fenouil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'endive' WHERE "key" = 'endive';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pumpkin' WHERE "key" = 'potiron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'butternut_squash' WHERE "key" = 'butternut';
|
|
||||||
UPDATE "ingredients" SET "key" = 'asparagus' WHERE "key" = 'asperge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'avocado' WHERE "key" = 'avocat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'potato' WHERE "key" = 'pomme_de_terre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweet_potato' WHERE "key" = 'patate_douce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cherry_tomato' WHERE "key" = 'tomates_cerises';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bok_choy' WHERE "key" = 'pak_choi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soybean_sprouts' WHERE "key" = 'germes_de_soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shiitake' WHERE "key" = 'shiitake';
|
|
||||||
UPDATE "ingredients" SET "key" = 'daikon' WHERE "key" = 'daikon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fresh_green_chili' WHERE "key" = 'piment_vert_frais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lemon' WHERE "key" = 'citron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lime' WHERE "key" = 'citron_vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'apple' WHERE "key" = 'pomme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pear' WHERE "key" = 'poire';
|
|
||||||
UPDATE "ingredients" SET "key" = 'banana' WHERE "key" = 'banane';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orange' WHERE "key" = 'orange';
|
|
||||||
UPDATE "ingredients" SET "key" = 'clementine' WHERE "key" = 'clementine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grapefruit' WHERE "key" = 'pamplemousse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'strawberry' WHERE "key" = 'fraise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raspberry' WHERE "key" = 'framboise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blueberry' WHERE "key" = 'myrtille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackberry' WHERE "key" = 'mure';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cherry' WHERE "key" = 'cerise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'apricot' WHERE "key" = 'abricot';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peach' WHERE "key" = 'peche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'plum' WHERE "key" = 'prune';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grape' WHERE "key" = 'raisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'melon' WHERE "key" = 'melon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'watermelon' WHERE "key" = 'pasteque';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pineapple' WHERE "key" = 'ananas';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mango' WHERE "key" = 'mangue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kiwi' WHERE "key" = 'kiwi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fig' WHERE "key" = 'figue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'date' WHERE "key" = 'datte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lychee' WHERE "key" = 'litchi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pomegranate' WHERE "key" = 'grenade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rhubarb' WHERE "key" = 'rhubarbe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'quince' WHERE "key" = 'coing';
|
|
||||||
UPDATE "ingredients" SET "key" = 'basil' WHERE "key" = 'basilic';
|
|
||||||
UPDATE "ingredients" SET "key" = 'parsley' WHERE "key" = 'persil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'thyme' WHERE "key" = 'thym';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rosemary' WHERE "key" = 'romarin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bay_leaf' WHERE "key" = 'laurier';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chives' WHERE "key" = 'ciboulette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fresh_cilantro' WHERE "key" = 'coriandre_fraiche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mint' WHERE "key" = 'menthe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oregano' WHERE "key" = 'origan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dill' WHERE "key" = 'aneth';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tarragon' WHERE "key" = 'estragon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'savory' WHERE "key" = 'sarriette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'marjoram' WHERE "key" = 'marjolaine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sage' WHERE "key" = 'sauge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chervil' WHERE "key" = 'cerfeuil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ginger' WHERE "key" = 'gingembre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lemongrass' WHERE "key" = 'citronnelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kaffir_lime' WHERE "key" = 'combava';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rabbit' WHERE "key" = 'lapin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ground_beef' WHERE "key" = 'boeuf_hache';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beef_steak' WHERE "key" = 'steak_de_boeuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beef_roast' WHERE "key" = 'roti_de_boeuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'veal_cutlet' WHERE "key" = 'escalope_de_veau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pork_tenderloin' WHERE "key" = 'filet_mignon_de_porc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pork_chop' WHERE "key" = 'cote_de_porc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lamb' WHERE "key" = 'agneau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'leg_of_lamb' WHERE "key" = 'gigot_d_agneau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bacon_lardons' WHERE "key" = 'lardons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bacon' WHERE "key" = 'bacon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ham' WHERE "key" = 'jambon_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cured_ham' WHERE "key" = 'jambon_cru';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sausage' WHERE "key" = 'saucisse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chorizo' WHERE "key" = 'chorizo';
|
|
||||||
UPDATE "ingredients" SET "key" = 'merguez' WHERE "key" = 'merguez';
|
|
||||||
UPDATE "ingredients" SET "key" = 'prosciutto' WHERE "key" = 'prosciutto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pancetta' WHERE "key" = 'pancetta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mortadella' WHERE "key" = 'mortadelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'salami' WHERE "key" = 'salami';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chicken' WHERE "key" = 'poulet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'turkey' WHERE "key" = 'dinde';
|
|
||||||
UPDATE "ingredients" SET "key" = 'duck' WHERE "key" = 'canard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'duck_breast' WHERE "key" = 'magret_de_canard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'salmon' WHERE "key" = 'saumon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tuna' WHERE "key" = 'thon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cod' WHERE "key" = 'cabillaud';
|
|
||||||
UPDATE "ingredients" SET "key" = 'trout' WHERE "key" = 'truite';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sardine' WHERE "key" = 'sardine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'anchovy' WHERE "key" = 'anchois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiting' WHERE "key" = 'merlan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'surimi' WHERE "key" = 'surimi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sea_bass' WHERE "key" = 'bar_loup_de_mer';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sea_bream' WHERE "key" = 'dorade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sole' WHERE "key" = 'sole';
|
|
||||||
UPDATE "ingredients" SET "key" = 'turbot' WHERE "key" = 'turbot';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hake' WHERE "key" = 'merlu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pollock' WHERE "key" = 'colin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saithe' WHERE "key" = 'lieu_noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'haddock' WHERE "key" = 'eglefin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mackerel' WHERE "key" = 'maquereau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herring' WHERE "key" = 'hareng';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_mullet' WHERE "key" = 'rouget';
|
|
||||||
UPDATE "ingredients" SET "key" = 'skate' WHERE "key" = 'raie';
|
|
||||||
UPDATE "ingredients" SET "key" = 'monkfish' WHERE "key" = 'lotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'halibut' WHERE "key" = 'fletan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'swordfish' WHERE "key" = 'espadon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'carp' WHERE "key" = 'carpe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pike' WHERE "key" = 'brochet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'perch' WHERE "key" = 'perche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tilapia' WHERE "key" = 'tilapia';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pangasius' WHERE "key" = 'panga';
|
|
||||||
UPDATE "ingredients" SET "key" = 'smoked_salmon' WHERE "key" = 'saumon_fume';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dried_fish' WHERE "key" = 'poisson_seche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shrimp' WHERE "key" = 'crevettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'langoustine' WHERE "key" = 'langoustines';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lobster' WHERE "key" = 'homard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'crab' WHERE "key" = 'crabe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spiny_lobster' WHERE "key" = 'langouste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mussels' WHERE "key" = 'moules';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oysters' WHERE "key" = 'huitres';
|
|
||||||
UPDATE "ingredients" SET "key" = 'scallops' WHERE "key" = 'saint_jacques';
|
|
||||||
UPDATE "ingredients" SET "key" = 'squid' WHERE "key" = 'calamar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'octopus' WHERE "key" = 'poulpe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'clams' WHERE "key" = 'palourdes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whelks' WHERE "key" = 'bulots';
|
|
||||||
UPDATE "ingredients" SET "key" = 'semolina' WHERE "key" = 'semoule';
|
|
||||||
UPDATE "ingredients" SET "key" = 'couscous' WHERE "key" = 'couscous';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bulgur' WHERE "key" = 'boulgour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'polenta' WHERE "key" = 'polenta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'quinoa' WHERE "key" = 'quinoa';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pasta' WHERE "key" = 'pates';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whole_wheat_pasta' WHERE "key" = 'pates_completes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rice' WHERE "key" = 'riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'basmati_rice' WHERE "key" = 'riz_basmati';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brown_rice' WHERE "key" = 'riz_complet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oats' WHERE "key" = 'flocons_d_avoine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spaghetti' WHERE "key" = 'spaghetti';
|
|
||||||
UPDATE "ingredients" SET "key" = 'penne' WHERE "key" = 'penne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tagliatelle' WHERE "key" = 'tagliatelles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lasagna_sheets' WHERE "key" = 'lasagnes_feuilles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gnocchi' WHERE "key" = 'gnocchi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'arborio_rice' WHERE "key" = 'riz_arborio';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rice_noodles' WHERE "key" = 'nouilles_de_riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'udon_noodles' WHERE "key" = 'nouilles_udon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soba_noodles' WHERE "key" = 'nouilles_soba';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chinese_noodles' WHERE "key" = 'nouilles_chinoises';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rice_vermicelli' WHERE "key" = 'vermicelles_de_riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soy_vermicelli' WHERE "key" = 'vermicelles_de_soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sticky_rice' WHERE "key" = 'riz_gluant';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sushi_rice' WHERE "key" = 'riz_a_sushi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jasmine_rice' WHERE "key" = 'riz_jasmin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'green_lentils' WHERE "key" = 'lentilles_vertes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_lentils' WHERE "key" = 'lentilles_corail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chickpeas' WHERE "key" = 'pois_chiches';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_beans' WHERE "key" = 'haricots_blancs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kidney_beans' WHERE "key" = 'haricots_rouges';
|
|
||||||
UPDATE "ingredients" SET "key" = 'black_beans' WHERE "key" = 'haricots_noirs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'split_peas' WHERE "key" = 'pois_casses';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fava_beans' WHERE "key" = 'feves';
|
|
||||||
UPDATE "ingredients" SET "key" = 'edamame' WHERE "key" = 'edamame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pinto_beans' WHERE "key" = 'haricots_pinto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanuts_shelled' WHERE "key" = 'cacahuetes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'almonds' WHERE "key" = 'amandes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'walnuts' WHERE "key" = 'noix';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hazelnuts' WHERE "key" = 'noisettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cashews' WHERE "key" = 'noix_de_cajou';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pistachios' WHERE "key" = 'pistaches';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pecans' WHERE "key" = 'noix_de_pecan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'almond_powder' WHERE "key" = 'poudre_d_amande';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pine_nuts' WHERE "key" = 'pignons_de_pin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sunflower_seeds' WHERE "key" = 'graines_de_tournesol';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pumpkin_seeds' WHERE "key" = 'graines_de_courge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shredded_coconut' WHERE "key" = 'noix_de_coco_rapee';
|
|
||||||
UPDATE "ingredients" SET "key" = 'raisins' WHERE "key" = 'raisins_secs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'prunes' WHERE "key" = 'pruneaux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dried_apricots' WHERE "key" = 'abricots_secs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sesame_seeds' WHERE "key" = 'graines_de_sesame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'black_mushrooms' WHERE "key" = 'champignons_noirs';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nori_seaweed' WHERE "key" = 'algue_nori';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wakame_seaweed' WHERE "key" = 'algue_wakame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kombu_seaweed' WHERE "key" = 'algue_kombu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bamboo_shoots' WHERE "key" = 'pousses_de_bambou';
|
|
||||||
UPDATE "ingredients" SET "key" = 'water_chestnuts' WHERE "key" = 'chataignes_d_eau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bread' WHERE "key" = 'pain';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sandwich_bread' WHERE "key" = 'pain_de_mie';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whole_wheat_bread' WHERE "key" = 'pain_complet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baguette' WHERE "key" = 'baguette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rye_bread' WHERE "key" = 'pain_de_seigle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'breadcrumbs' WHERE "key" = 'chapelure';
|
|
||||||
UPDATE "ingredients" SET "key" = 'burger_bun' WHERE "key" = 'pain_a_burger';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brioche_bun' WHERE "key" = 'pain_brioche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hot_dog_bun' WHERE "key" = 'pain_a_hot_dog';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pita_bread' WHERE "key" = 'pain_pita';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bagel' WHERE "key" = 'pain_bagel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'naan' WHERE "key" = 'naan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wrap_bread' WHERE "key" = 'pain_wrap';
|
|
||||||
UPDATE "ingredients" SET "key" = 'viennese_bread' WHERE "key" = 'pain_viennois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'country_bread' WHERE "key" = 'pain_de_campagne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'multigrain_bread' WHERE "key" = 'pain_aux_cereales';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bread_roll' WHERE "key" = 'petit_pain';
|
|
||||||
UPDATE "ingredients" SET "key" = 'swedish_bread' WHERE "key" = 'pain_suedois';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gluten_free_bread' WHERE "key" = 'pain_sans_gluten';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rusk' WHERE "key" = 'biscotte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'croutons' WHERE "key" = 'croutons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'focaccia' WHERE "key" = 'focaccia';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ciabatta' WHERE "key" = 'ciabatta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'corn_tortilla' WHERE "key" = 'tortilla_de_mais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wheat_tortilla' WHERE "key" = 'tortilla_de_ble';
|
|
||||||
UPDATE "ingredients" SET "key" = 'puff_pastry' WHERE "key" = 'pate_feuilletee';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shortcrust_pastry' WHERE "key" = 'pate_brisee';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pizza_dough' WHERE "key" = 'pate_a_pizza';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweet_shortcrust_pastry' WHERE "key" = 'pate_a_tarte_sablee';
|
|
||||||
UPDATE "ingredients" SET "key" = 'milk' WHERE "key" = 'lait';
|
|
||||||
UPDATE "ingredients" SET "key" = 'butter' WHERE "key" = 'beurre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'creme_fraiche' WHERE "key" = 'creme_fraiche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'liquid_cream' WHERE "key" = 'creme_liquide';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cheese' WHERE "key" = 'fromage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'emmental' WHERE "key" = 'emmental';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gruyere' WHERE "key" = 'gruyere';
|
|
||||||
UPDATE "ingredients" SET "key" = 'parmesan' WHERE "key" = 'parmesan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mozzarella' WHERE "key" = 'mozzarella';
|
|
||||||
UPDATE "ingredients" SET "key" = 'goat_cheese' WHERE "key" = 'chevre_fromage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'feta' WHERE "key" = 'feta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'comte' WHERE "key" = 'comte';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fromage_blanc' WHERE "key" = 'fromage_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mascarpone' WHERE "key" = 'mascarpone';
|
|
||||||
UPDATE "ingredients" SET "key" = 'yogurt' WHERE "key" = 'yaourt';
|
|
||||||
UPDATE "ingredients" SET "key" = 'burrata' WHERE "key" = 'burrata';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ricotta' WHERE "key" = 'ricotta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pecorino' WHERE "key" = 'pecorino';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gorgonzola' WHERE "key" = 'gorgonzola';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cheddar' WHERE "key" = 'cheddar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'egg' WHERE "key" = 'oeuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconut_milk' WHERE "key" = 'lait_de_coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconut_cream' WHERE "key" = 'creme_de_coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'almond_milk' WHERE "key" = 'lait_d_amande';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oat_milk' WHERE "key" = 'lait_d_avoine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tofu' WHERE "key" = 'tofu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'silken_tofu' WHERE "key" = 'tofu_soyeux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herbes_de_provence' WHERE "key" = 'herbes_de_provence';
|
|
||||||
UPDATE "ingredients" SET "key" = 'black_pepper' WHERE "key" = 'poivre_noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'paprika' WHERE "key" = 'paprika';
|
|
||||||
UPDATE "ingredients" SET "key" = 'espelette_pepper' WHERE "key" = 'piment_d_espelette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cayenne_pepper' WHERE "key" = 'piment_de_cayenne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cumin' WHERE "key" = 'cumin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curry_powder' WHERE "key" = 'curry_poudre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'turmeric' WHERE "key" = 'curcuma';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cinnamon' WHERE "key" = 'cannelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nutmeg' WHERE "key" = 'muscade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saffron' WHERE "key" = 'safran';
|
|
||||||
UPDATE "ingredients" SET "key" = 'clove' WHERE "key" = 'clou_de_girofle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vanilla_bean' WHERE "key" = 'vanille_gousse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_pepper' WHERE "key" = 'poivre_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pink_pepper' WHERE "key" = 'poivre_rose';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sichuan_pepper' WHERE "key" = 'poivre_du_sichuan';
|
|
||||||
UPDATE "ingredients" SET "key" = 'smoked_paprika' WHERE "key" = 'paprika_fume';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bird_eye_chili' WHERE "key" = 'piment_oiseau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'juniper_berries' WHERE "key" = 'baies_de_genievre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'star_anise' WHERE "key" = 'anis_etoile_badiane';
|
|
||||||
UPDATE "ingredients" SET "key" = 'green_anise' WHERE "key" = 'anis_vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fennel_seeds' WHERE "key" = 'graines_de_fenouil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sumac' WHERE "key" = 'sumac';
|
|
||||||
UPDATE "ingredients" SET "key" = 'nigella' WHERE "key" = 'nigelle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'allspice' WHERE "key" = 'quatre_epices';
|
|
||||||
UPDATE "ingredients" SET "key" = 'colombo_powder' WHERE "key" = 'colombo_poudre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baharat' WHERE "key" = 'baharat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'horseradish' WHERE "key" = 'raifort';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herb_salt' WHERE "key" = 'sel_aux_herbes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'celery_salt' WHERE "key" = 'sel_de_celeri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fleur_de_sel' WHERE "key" = 'fleur_de_sel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'salt' WHERE "key" = 'sel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'five_spice' WHERE "key" = 'cinq_epices';
|
|
||||||
UPDATE "ingredients" SET "key" = 'garam_masala' WHERE "key" = 'garam_masala';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coriander_seeds' WHERE "key" = 'graines_de_coriandre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cardamom' WHERE "key" = 'cardamome';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fenugreek' WHERE "key" = 'fenugrec';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jalapeno' WHERE "key" = 'piment_jalapeno';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chipotle' WHERE "key" = 'piment_chipotle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poblano_pepper' WHERE "key" = 'piment_poblano';
|
|
||||||
UPDATE "ingredients" SET "key" = 'habanero' WHERE "key" = 'piment_habanero';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ras_el_hanout' WHERE "key" = 'ras_el_hanout';
|
|
||||||
UPDATE "ingredients" SET "key" = 'zaatar' WHERE "key" = 'za_atar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soy_sauce' WHERE "key" = 'sauce_soja';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mustard' WHERE "key" = 'moutarde';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mayonnaise' WHERE "key" = 'mayonnaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ketchup' WHERE "key" = 'ketchup';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tabasco' WHERE "key" = 'tabasco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'worcestershire_sauce' WHERE "key" = 'sauce_worcestershire';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fish_sauce' WHERE "key" = 'sauce_nuoc_mam';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wasabi' WHERE "key" = 'wasabi';
|
|
||||||
UPDATE "ingredients" SET "key" = 'harissa' WHERE "key" = 'harissa';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curry_paste' WHERE "key" = 'pate_de_curry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanut_butter' WHERE "key" = 'beurre_de_cacahuete';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dijon_mustard' WHERE "key" = 'moutarde_de_dijon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wholegrain_mustard' WHERE "key" = 'moutarde_a_l_ancienne';
|
|
||||||
UPDATE "ingredients" SET "key" = 'barbecue_sauce' WHERE "key" = 'sauce_barbecue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tartar_sauce' WHERE "key" = 'sauce_tartare';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cocktail_sauce' WHERE "key" = 'sauce_cocktail';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bearnaise_sauce' WHERE "key" = 'sauce_bearnaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hollandaise_sauce' WHERE "key" = 'sauce_hollandaise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bechamel_sauce' WHERE "key" = 'sauce_bechamel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'teriyaki_sauce' WHERE "key" = 'sauce_teriyaki';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ponzu_sauce' WHERE "key" = 'sauce_ponzu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chimichurri' WHERE "key" = 'chimichurri';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_pesto' WHERE "key" = 'pesto_rouge_tomates_sechees';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pesto' WHERE "key" = 'pesto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oyster_sauce' WHERE "key" = 'sauce_huitre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hoisin_sauce' WHERE "key" = 'sauce_hoisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sriracha' WHERE "key" = 'sauce_sriracha';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweet_chili_sauce' WHERE "key" = 'sauce_sweet_chili';
|
|
||||||
UPDATE "ingredients" SET "key" = 'miso' WHERE "key" = 'miso';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shrimp_paste' WHERE "key" = 'pate_de_crevettes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_curry_paste' WHERE "key" = 'pate_de_curry_rouge_thai';
|
|
||||||
UPDATE "ingredients" SET "key" = 'green_curry_paste' WHERE "key" = 'pate_de_curry_vert_thai';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tahini' WHERE "key" = 'tahini';
|
|
||||||
UPDATE "ingredients" SET "key" = 'olive_oil' WHERE "key" = 'huile_d_olive';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sunflower_oil' WHERE "key" = 'huile_de_tournesol';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rapeseed_oil' WHERE "key" = 'huile_de_colza';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconut_oil' WHERE "key" = 'huile_de_coco';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sesame_oil' WHERE "key" = 'huile_de_sesame';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cider_vinegar' WHERE "key" = 'vinaigre_de_cidre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_vinegar' WHERE "key" = 'vinaigre_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'balsamic_vinegar' WHERE "key" = 'vinaigre_balsamique';
|
|
||||||
UPDATE "ingredients" SET "key" = 'capers' WHERE "key" = 'capres';
|
|
||||||
UPDATE "ingredients" SET "key" = 'olives' WHERE "key" = 'olives';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_wine' WHERE "key" = 'vin_blanc_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_wine' WHERE "key" = 'vin_rouge_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'red_wine_vinegar' WHERE "key" = 'vinaigre_de_vin_rouge';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_wine_vinegar' WHERE "key" = 'vinaigre_de_vin_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sherry_vinegar' WHERE "key" = 'vinaigre_de_xeres';
|
|
||||||
UPDATE "ingredients" SET "key" = 'walnut_oil' WHERE "key" = 'huile_de_noix';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hazelnut_oil' WHERE "key" = 'huile_de_noisette';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanut_oil' WHERE "key" = 'huile_d_arachide';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chili_oil' WHERE "key" = 'huile_pimentee';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rice_vinegar' WHERE "key" = 'vinaigre_de_riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mirin' WHERE "key" = 'mirin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sake' WHERE "key" = 'sake_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lemon_juice' WHERE "key" = 'jus_de_citron';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lime_juice' WHERE "key" = 'jus_de_citron_vert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orange_juice' WHERE "key" = 'jus_d_orange';
|
|
||||||
UPDATE "ingredients" SET "key" = 'apple_juice' WHERE "key" = 'jus_de_pomme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grape_juice' WHERE "key" = 'jus_de_raisin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomato_juice' WHERE "key" = 'jus_de_tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cranberry_juice' WHERE "key" = 'jus_de_cranberry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coffee' WHERE "key" = 'cafe';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tea' WHERE "key" = 'the';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beer' WHERE "key" = 'biere_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cider' WHERE "key" = 'cidre_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'champagne' WHERE "key" = 'champagne_vin_petillant_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'port_wine' WHERE "key" = 'porto_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vin_jaune' WHERE "key" = 'vin_jaune_cuisine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cognac' WHERE "key" = 'cognac';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rum' WHERE "key" = 'rhum';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whisky' WHERE "key" = 'whisky';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vodka' WHERE "key" = 'vodka';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wheat_flour' WHERE "key" = 'farine_de_ble';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whole_wheat_flour' WHERE "key" = 'farine_complete';
|
|
||||||
UPDATE "ingredients" SET "key" = 'corn_flour' WHERE "key" = 'farine_de_mais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'buckwheat_flour' WHERE "key" = 'farine_de_sarrasin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rice_flour' WHERE "key" = 'farine_de_riz';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vegetable_stock_cube' WHERE "key" = 'bouillon_cube_legumes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chicken_stock_cube' WHERE "key" = 'bouillon_cube_volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomato_paste' WHERE "key" = 'concentre_de_tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomato_coulis' WHERE "key" = 'coulis_de_tomate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'canned_peeled_tomatoes' WHERE "key" = 'tomates_pelees_conserve';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sun_dried_tomatoes' WHERE "key" = 'tomates_sechees';
|
|
||||||
UPDATE "ingredients" SET "key" = 'veal_stock' WHERE "key" = 'fond_de_veau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chicken_stock' WHERE "key" = 'fond_de_volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beef_stock_cube' WHERE "key" = 'bouillon_cube_boeuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fish_stock_cube' WHERE "key" = 'bouillon_cube_poisson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vegetable_broth' WHERE "key" = 'bouillon_de_legumes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chicken_broth' WHERE "key" = 'bouillon_de_volaille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beef_broth' WHERE "key" = 'bouillon_de_boeuf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'court_bouillon' WHERE "key" = 'court_bouillon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dashi' WHERE "key" = 'dashi_bouillon_japonais';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shellfish_bisque' WHERE "key" = 'bisque_de_crustaces';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tapioca_flour' WHERE "key" = 'farine_de_tapioca';
|
|
||||||
UPDATE "ingredients" SET "key" = 'masa_harina' WHERE "key" = 'masa_harina';
|
|
||||||
UPDATE "ingredients" SET "key" = 'water' WHERE "key" = 'eau';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sparkling_water' WHERE "key" = 'eau_gazeuse';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orange_blossom_water' WHERE "key" = 'eau_de_fleur_d_oranger';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rose_water' WHERE "key" = 'eau_de_rose';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fish_fumet' WHERE "key" = 'fumet_de_poisson';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bakers_yeast' WHERE "key" = 'levure_boulangere';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baking_powder' WHERE "key" = 'levure_chimique';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cornstarch' WHERE "key" = 'maizena';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lupin_flour' WHERE "key" = 'farine_de_lupin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'gelatin' WHERE "key" = 'gelatine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baking_soda' WHERE "key" = 'bicarbonate_de_soude';
|
|
||||||
UPDATE "ingredients" SET "key" = 'potato_starch' WHERE "key" = 'fecule_de_pomme_de_terre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sugar' WHERE "key" = 'sucre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'honey' WHERE "key" = 'miel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'maple_syrup' WHERE "key" = 'sirop_d_erable';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brown_sugar' WHERE "key" = 'sucre_roux';
|
|
||||||
UPDATE "ingredients" SET "key" = 'powdered_sugar' WHERE "key" = 'sucre_glace';
|
|
||||||
UPDATE "ingredients" SET "key" = 'demerara_sugar' WHERE "key" = 'cassonade';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dark_chocolate' WHERE "key" = 'chocolat_noir';
|
|
||||||
UPDATE "ingredients" SET "key" = 'milk_chocolate' WHERE "key" = 'chocolat_au_lait';
|
|
||||||
UPDATE "ingredients" SET "key" = 'white_chocolate' WHERE "key" = 'chocolat_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chocolate_chips' WHERE "key" = 'pepites_de_chocolat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cocoa_powder' WHERE "key" = 'cacao_en_poudre';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vanilla_extract' WHERE "key" = 'extrait_de_vanille';
|
|
||||||
UPDATE "ingredients" SET "key" = 'palm_sugar' WHERE "key" = 'sucre_de_palme';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cane_syrup' WHERE "key" = 'sirop_de_sucre_de_canne';
|
|
||||||
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- Adds the number of portions to prepare for a planning slot
|
|
||||||
-- (`PlanningItem.portions`), entered manually by whoever assigns the
|
|
||||||
-- recipe — no default exists anywhere to derive it from (no such concept
|
|
||||||
-- on `Recipe` either). Backfills any pre-existing row with 1 portion via a
|
|
||||||
-- transient DEFAULT, then drops that default so it isn't implicitly
|
|
||||||
-- reused for new inserts going forward (the app always sends an explicit
|
|
||||||
-- value, see `addPlanningItemSchema`).
|
|
||||||
ALTER TABLE "planning_item" ADD COLUMN "portions" INTEGER NOT NULL DEFAULT 1;
|
|
||||||
ALTER TABLE "planning_item" ALTER COLUMN "portions" DROP DEFAULT;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- Replaces the never-wired-up `Ingredient.alternateRecipeId` FK (zero
|
|
||||||
-- usage anywhere outside schema.prisma — confirmed by repo-wide grep)
|
|
||||||
-- with a plain boolean flag: whether this ingredient is reasonably
|
|
||||||
-- makeable at home. Product decision: no ingredient↔recipe linking in
|
|
||||||
-- the database — the recipe form only nudges the author toward the
|
|
||||||
-- recipe catalog's own search, pre-filled with the ingredient's name.
|
|
||||||
ALTER TABLE "ingredients" DROP CONSTRAINT "ingredients_alternate_recipe_fkey";
|
|
||||||
ALTER TABLE "ingredients" DROP COLUMN "alternate_recipe";
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "reproducible" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
@ -1,294 +0,0 @@
|
||||||
-- Replaces every Diet/Category(allergen)/Ingredient `key` with a
|
|
||||||
-- directly-authored English camelCase uid (no more French label +
|
|
||||||
-- separate catalog-en-keys.ts lookup table — see reference-seed-data.ts's
|
|
||||||
-- module doc comment). Auto-generated once by
|
|
||||||
-- scripts/gen-camel-uid-migration.ts — do not re-run, do not hand-edit.
|
|
||||||
-- Same shape as 20260818193000_catalog_keys_to_english/migration.sql.
|
|
||||||
|
|
||||||
UPDATE "diet" SET "key" = 'glutenFree' WHERE "key" = 'gluten_free';
|
|
||||||
UPDATE "category" SET "key" = 'treeNuts' WHERE "key" = 'tree_nuts';
|
|
||||||
UPDATE "category" SET "key" = 'sesameSeeds' WHERE "key" = 'sesame_seeds';
|
|
||||||
-- "sesame_seeds" is also an Ingredient key (the ingredient "Graines de
|
|
||||||
-- sésame" carries an allergen of the same name on itself) — hand-added,
|
|
||||||
-- the generator's first pass missed this dual-table case (see its
|
|
||||||
-- updated `if`/`if`/`if` — not `if`/`else if` — comment for why).
|
|
||||||
UPDATE "ingredients" SET "key" = 'sesameSeeds' WHERE "key" = 'sesame_seeds';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bellPepper' WHERE "key" = 'bell_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteCabbage' WHERE "key" = 'white_cabbage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redCabbage' WHERE "key" = 'red_cabbage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brusselsSprouts' WHERE "key" = 'brussels_sprouts';
|
|
||||||
UPDATE "ingredients" SET "key" = 'swissChard' WHERE "key" = 'swiss_chard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greenBean' WHERE "key" = 'green_bean';
|
|
||||||
UPDATE "ingredients" SET "key" = 'butternutSquash' WHERE "key" = 'butternut_squash';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweetPotato' WHERE "key" = 'sweet_potato';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cherryTomato' WHERE "key" = 'cherry_tomato';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bokChoy' WHERE "key" = 'bok_choy';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soybeanSprouts' WHERE "key" = 'soybean_sprouts';
|
|
||||||
UPDATE "ingredients" SET "key" = 'freshGreenChili' WHERE "key" = 'fresh_green_chili';
|
|
||||||
UPDATE "ingredients" SET "key" = 'napaCabbage' WHERE "key" = 'napa_cabbage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'springOnion' WHERE "key" = 'spring_onion';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redKuriSquash' WHERE "key" = 'red_kuri_squash';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lambsLettuce' WHERE "key" = 'lambs_lettuce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bayLeaf' WHERE "key" = 'bay_leaf';
|
|
||||||
UPDATE "ingredients" SET "key" = 'freshCilantro' WHERE "key" = 'fresh_cilantro';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kaffirLime' WHERE "key" = 'kaffir_lime';
|
|
||||||
UPDATE "ingredients" SET "key" = 'groundBeef' WHERE "key" = 'ground_beef';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefSteak' WHERE "key" = 'beef_steak';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefRoast' WHERE "key" = 'beef_roast';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealCutlet' WHERE "key" = 'veal_cutlet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'porkTenderloin' WHERE "key" = 'pork_tenderloin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'porkChop' WHERE "key" = 'pork_chop';
|
|
||||||
UPDATE "ingredients" SET "key" = 'legOfLamb' WHERE "key" = 'leg_of_lamb';
|
|
||||||
UPDATE "ingredients" SET "key" = 'baconLardons' WHERE "key" = 'bacon_lardons';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curedHam' WHERE "key" = 'cured_ham';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whitePudding' WHERE "key" = 'white_pudding';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackPudding' WHERE "key" = 'black_pudding';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dryCuredSausage' WHERE "key" = 'dry_cured_sausage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bayonneHam' WHERE "key" = 'bayonne_ham';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rosetteSausage' WHERE "key" = 'rosette_sausage';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealLiver' WHERE "key" = 'veal_liver';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealKidneys' WHERE "key" = 'veal_kidneys';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealBrain' WHERE "key" = 'veal_brain';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealSweetbread' WHERE "key" = 'veal_sweetbread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefTongue' WHERE "key" = 'beef_tongue';
|
|
||||||
UPDATE "ingredients" SET "key" = 'roeDeer' WHERE "key" = 'roe_deer';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wildBoar' WHERE "key" = 'wild_boar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'horseMeat' WHERE "key" = 'horse_meat';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefHeart' WHERE "key" = 'beef_heart';
|
|
||||||
UPDATE "ingredients" SET "key" = 'foieGras' WHERE "key" = 'foie_gras';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefMuzzle' WHERE "key" = 'beef_muzzle';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grisonsDriedBeef' WHERE "key" = 'grisons_dried_beef';
|
|
||||||
UPDATE "ingredients" SET "key" = 'duckBreast' WHERE "key" = 'duck_breast';
|
|
||||||
UPDATE "ingredients" SET "key" = 'guineaFowl' WHERE "key" = 'guinea_fowl';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poultryLiver' WHERE "key" = 'poultry_liver';
|
|
||||||
UPDATE "ingredients" SET "key" = 'seaBass' WHERE "key" = 'sea_bass';
|
|
||||||
UPDATE "ingredients" SET "key" = 'seaBream' WHERE "key" = 'sea_bream';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redMullet' WHERE "key" = 'red_mullet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'smokedSalmon' WHERE "key" = 'smoked_salmon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'driedFish' WHERE "key" = 'dried_fish';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saltCod' WHERE "key" = 'salt_cod';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lemonSole' WHERE "key" = 'lemon_sole';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spinyLobster' WHERE "key" = 'spiny_lobster';
|
|
||||||
UPDATE "ingredients" SET "key" = 'spiderCrab' WHERE "key" = 'spider_crab';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greyShrimp' WHERE "key" = 'grey_shrimp';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wholeWheatPasta' WHERE "key" = 'whole_wheat_pasta';
|
|
||||||
UPDATE "ingredients" SET "key" = 'basmatiRice' WHERE "key" = 'basmati_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brownRice' WHERE "key" = 'brown_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lasagnaSheets' WHERE "key" = 'lasagna_sheets';
|
|
||||||
UPDATE "ingredients" SET "key" = 'arborioRice' WHERE "key" = 'arborio_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riceNoodles' WHERE "key" = 'rice_noodles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'udonNoodles' WHERE "key" = 'udon_noodles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sobaNoodles' WHERE "key" = 'soba_noodles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chineseNoodles' WHERE "key" = 'chinese_noodles';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riceVermicelli' WHERE "key" = 'rice_vermicelli';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soyVermicelli' WHERE "key" = 'soy_vermicelli';
|
|
||||||
UPDATE "ingredients" SET "key" = 'stickyRice' WHERE "key" = 'sticky_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sushiRice' WHERE "key" = 'sushi_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'jasmineRice' WHERE "key" = 'jasmine_rice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greenLentils' WHERE "key" = 'green_lentils';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redLentils' WHERE "key" = 'red_lentils';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteBeans' WHERE "key" = 'white_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kidneyBeans' WHERE "key" = 'kidney_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackBeans' WHERE "key" = 'black_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'splitPeas' WHERE "key" = 'split_peas';
|
|
||||||
UPDATE "ingredients" SET "key" = 'favaBeans' WHERE "key" = 'fava_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pintoBeans' WHERE "key" = 'pinto_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'flageoletBeans' WHERE "key" = 'flageolet_beans';
|
|
||||||
UPDATE "ingredients" SET "key" = 'goldenLentils' WHERE "key" = 'golden_lentils';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanutsShelled' WHERE "key" = 'peanuts_shelled';
|
|
||||||
UPDATE "ingredients" SET "key" = 'almondPowder' WHERE "key" = 'almond_powder';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pineNuts' WHERE "key" = 'pine_nuts';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sunflowerSeeds' WHERE "key" = 'sunflower_seeds';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pumpkinSeeds' WHERE "key" = 'pumpkin_seeds';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shreddedCoconut' WHERE "key" = 'shredded_coconut';
|
|
||||||
UPDATE "ingredients" SET "key" = 'driedApricots' WHERE "key" = 'dried_apricots';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackMushrooms' WHERE "key" = 'black_mushrooms';
|
|
||||||
UPDATE "ingredients" SET "key" = 'noriSeaweed' WHERE "key" = 'nori_seaweed';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wakameSeaweed' WHERE "key" = 'wakame_seaweed';
|
|
||||||
UPDATE "ingredients" SET "key" = 'kombuSeaweed' WHERE "key" = 'kombu_seaweed';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bambooShoots' WHERE "key" = 'bamboo_shoots';
|
|
||||||
UPDATE "ingredients" SET "key" = 'waterChestnuts' WHERE "key" = 'water_chestnuts';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sandwichBread' WHERE "key" = 'sandwich_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wholeWheatBread' WHERE "key" = 'whole_wheat_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ryeBread' WHERE "key" = 'rye_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'burgerBun' WHERE "key" = 'burger_bun';
|
|
||||||
UPDATE "ingredients" SET "key" = 'briocheBun' WHERE "key" = 'brioche_bun';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hotDogBun' WHERE "key" = 'hot_dog_bun';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pitaBread' WHERE "key" = 'pita_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wrapBread' WHERE "key" = 'wrap_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vienneseBread' WHERE "key" = 'viennese_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'countryBread' WHERE "key" = 'country_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'multigrainBread' WHERE "key" = 'multigrain_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'breadRoll' WHERE "key" = 'bread_roll';
|
|
||||||
UPDATE "ingredients" SET "key" = 'swedishBread' WHERE "key" = 'swedish_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'glutenFreeBread' WHERE "key" = 'gluten_free_bread';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cornTortilla' WHERE "key" = 'corn_tortilla';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wheatTortilla' WHERE "key" = 'wheat_tortilla';
|
|
||||||
UPDATE "ingredients" SET "key" = 'puffPastry' WHERE "key" = 'puff_pastry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shortcrustPastry' WHERE "key" = 'shortcrust_pastry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pizzaDough' WHERE "key" = 'pizza_dough';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweetShortcrustPastry' WHERE "key" = 'sweet_shortcrust_pastry';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cremeFraiche' WHERE "key" = 'creme_fraiche';
|
|
||||||
UPDATE "ingredients" SET "key" = 'liquidCream' WHERE "key" = 'liquid_cream';
|
|
||||||
UPDATE "ingredients" SET "key" = 'goatCheese' WHERE "key" = 'goat_cheese';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fromageBlanc' WHERE "key" = 'fromage_blanc';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saintNectaire' WHERE "key" = 'saint_nectaire';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blueCheese' WHERE "key" = 'blue_cheese';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pontLeveque' WHERE "key" = 'pont_leveque';
|
|
||||||
UPDATE "ingredients" SET "key" = 'racletteCheese' WHERE "key" = 'raclette_cheese';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fourmeDAmbert' WHERE "key" = 'fourme_d_ambert';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ossauIraty' WHERE "key" = 'ossau_iraty';
|
|
||||||
UPDATE "ingredients" SET "key" = 'saintMarcellin' WHERE "key" = 'saint_marcellin';
|
|
||||||
UPDATE "ingredients" SET "key" = 'crottinDeChavignol' WHERE "key" = 'crottin_de_chavignol';
|
|
||||||
UPDATE "ingredients" SET "key" = 'abondanceCheese' WHERE "key" = 'abondance_cheese';
|
|
||||||
UPDATE "ingredients" SET "key" = 'carreDeLEst' WHERE "key" = 'carre_de_l_est';
|
|
||||||
UPDATE "ingredients" SET "key" = 'montDor' WHERE "key" = 'mont_dor';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greekYogurt' WHERE "key" = 'greek_yogurt';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconutMilk' WHERE "key" = 'coconut_milk';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconutCream' WHERE "key" = 'coconut_cream';
|
|
||||||
UPDATE "ingredients" SET "key" = 'almondMilk' WHERE "key" = 'almond_milk';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oatMilk' WHERE "key" = 'oat_milk';
|
|
||||||
UPDATE "ingredients" SET "key" = 'silkenTofu' WHERE "key" = 'silken_tofu';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herbesDeProvence' WHERE "key" = 'herbes_de_provence';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackPepper' WHERE "key" = 'black_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'espelettePepper' WHERE "key" = 'espelette_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cayennePepper' WHERE "key" = 'cayenne_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curryPowder' WHERE "key" = 'curry_powder';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vanillaBean' WHERE "key" = 'vanilla_bean';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whitePepper' WHERE "key" = 'white_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'pinkPepper' WHERE "key" = 'pink_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sichuanPepper' WHERE "key" = 'sichuan_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'smokedPaprika' WHERE "key" = 'smoked_paprika';
|
|
||||||
UPDATE "ingredients" SET "key" = 'birdEyeChili' WHERE "key" = 'bird_eye_chili';
|
|
||||||
UPDATE "ingredients" SET "key" = 'juniperBerries' WHERE "key" = 'juniper_berries';
|
|
||||||
UPDATE "ingredients" SET "key" = 'starAnise' WHERE "key" = 'star_anise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greenAnise' WHERE "key" = 'green_anise';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fennelSeeds' WHERE "key" = 'fennel_seeds';
|
|
||||||
UPDATE "ingredients" SET "key" = 'colomboPowder' WHERE "key" = 'colombo_powder';
|
|
||||||
UPDATE "ingredients" SET "key" = 'herbSalt' WHERE "key" = 'herb_salt';
|
|
||||||
UPDATE "ingredients" SET "key" = 'celerySalt' WHERE "key" = 'celery_salt';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fleurDeSel' WHERE "key" = 'fleur_de_sel';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fiveSpice' WHERE "key" = 'five_spice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'garamMasala' WHERE "key" = 'garam_masala';
|
|
||||||
UPDATE "ingredients" SET "key" = 'corianderSeeds' WHERE "key" = 'coriander_seeds';
|
|
||||||
UPDATE "ingredients" SET "key" = 'poblanoPepper' WHERE "key" = 'poblano_pepper';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rasElHanout' WHERE "key" = 'ras_el_hanout';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soySauce' WHERE "key" = 'soy_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'worcestershireSauce' WHERE "key" = 'worcestershire_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fishSauce' WHERE "key" = 'fish_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'curryPaste' WHERE "key" = 'curry_paste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanutButter' WHERE "key" = 'peanut_butter';
|
|
||||||
UPDATE "ingredients" SET "key" = 'dijonMustard' WHERE "key" = 'dijon_mustard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wholegrainMustard' WHERE "key" = 'wholegrain_mustard';
|
|
||||||
UPDATE "ingredients" SET "key" = 'barbecueSauce' WHERE "key" = 'barbecue_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tartarSauce' WHERE "key" = 'tartar_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cocktailSauce' WHERE "key" = 'cocktail_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bearnaiseSauce' WHERE "key" = 'bearnaise_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hollandaiseSauce' WHERE "key" = 'hollandaise_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bechamelSauce' WHERE "key" = 'bechamel_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'teriyakiSauce' WHERE "key" = 'teriyaki_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ponzuSauce' WHERE "key" = 'ponzu_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redPesto' WHERE "key" = 'red_pesto';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oysterSauce' WHERE "key" = 'oyster_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hoisinSauce' WHERE "key" = 'hoisin_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sweetChiliSauce' WHERE "key" = 'sweet_chili_sauce';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shrimpPaste' WHERE "key" = 'shrimp_paste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redCurryPaste' WHERE "key" = 'red_curry_paste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greenCurryPaste' WHERE "key" = 'green_curry_paste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'oliveOil' WHERE "key" = 'olive_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sunflowerOil' WHERE "key" = 'sunflower_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'rapeseedOil' WHERE "key" = 'rapeseed_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'coconutOil' WHERE "key" = 'coconut_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sesameOil' WHERE "key" = 'sesame_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'ciderVinegar' WHERE "key" = 'cider_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteVinegar' WHERE "key" = 'white_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'balsamicVinegar' WHERE "key" = 'balsamic_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'blackOlives' WHERE "key" = 'black_olives';
|
|
||||||
UPDATE "ingredients" SET "key" = 'greenOlives' WHERE "key" = 'green_olives';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteWine' WHERE "key" = 'white_wine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redWine' WHERE "key" = 'red_wine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'roseWine' WHERE "key" = 'rose_wine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'redWineVinegar' WHERE "key" = 'red_wine_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteWineVinegar' WHERE "key" = 'white_wine_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sherryVinegar' WHERE "key" = 'sherry_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'walnutOil' WHERE "key" = 'walnut_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'hazelnutOil' WHERE "key" = 'hazelnut_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'peanutOil' WHERE "key" = 'peanut_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chiliOil' WHERE "key" = 'chili_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riceVinegar' WHERE "key" = 'rice_vinegar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cornOil' WHERE "key" = 'corn_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grapeseedOil' WHERE "key" = 'grapeseed_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'soybeanOil' WHERE "key" = 'soybean_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'palmOil' WHERE "key" = 'palm_oil';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lemonJuice' WHERE "key" = 'lemon_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'limeJuice' WHERE "key" = 'lime_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orangeJuice' WHERE "key" = 'orange_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'appleJuice' WHERE "key" = 'apple_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'grapeJuice' WHERE "key" = 'grape_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomatoJuice' WHERE "key" = 'tomato_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cranberryJuice' WHERE "key" = 'cranberry_juice';
|
|
||||||
UPDATE "ingredients" SET "key" = 'portWine' WHERE "key" = 'port_wine';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vinJaune' WHERE "key" = 'vin_jaune';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wheatFlour' WHERE "key" = 'wheat_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'wholeWheatFlour' WHERE "key" = 'whole_wheat_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cornFlour' WHERE "key" = 'corn_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'buckwheatFlour' WHERE "key" = 'buckwheat_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'riceFlour' WHERE "key" = 'rice_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vegetableStockCube' WHERE "key" = 'vegetable_stock_cube';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chickenStockCube' WHERE "key" = 'chicken_stock_cube';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomatoPaste' WHERE "key" = 'tomato_paste';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tomatoCoulis' WHERE "key" = 'tomato_coulis';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cannedPeeledTomatoes' WHERE "key" = 'canned_peeled_tomatoes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sunDriedTomatoes' WHERE "key" = 'sun_dried_tomatoes';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vealStock' WHERE "key" = 'veal_stock';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chickenStock' WHERE "key" = 'chicken_stock';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefStockCube' WHERE "key" = 'beef_stock_cube';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fishStockCube' WHERE "key" = 'fish_stock_cube';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vegetableBroth' WHERE "key" = 'vegetable_broth';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chickenBroth' WHERE "key" = 'chicken_broth';
|
|
||||||
UPDATE "ingredients" SET "key" = 'beefBroth' WHERE "key" = 'beef_broth';
|
|
||||||
UPDATE "ingredients" SET "key" = 'courtBouillon' WHERE "key" = 'court_bouillon';
|
|
||||||
UPDATE "ingredients" SET "key" = 'shellfishBisque' WHERE "key" = 'shellfish_bisque';
|
|
||||||
UPDATE "ingredients" SET "key" = 'tapiocaFlour' WHERE "key" = 'tapioca_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'masaHarina' WHERE "key" = 'masa_harina';
|
|
||||||
UPDATE "ingredients" SET "key" = 'sparklingWater' WHERE "key" = 'sparkling_water';
|
|
||||||
UPDATE "ingredients" SET "key" = 'orangeBlossomWater' WHERE "key" = 'orange_blossom_water';
|
|
||||||
UPDATE "ingredients" SET "key" = 'roseWater' WHERE "key" = 'rose_water';
|
|
||||||
UPDATE "ingredients" SET "key" = 'fishFumet' WHERE "key" = 'fish_fumet';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bakersYeast' WHERE "key" = 'bakers_yeast';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bakingPowder' WHERE "key" = 'baking_powder';
|
|
||||||
UPDATE "ingredients" SET "key" = 'lupinFlour' WHERE "key" = 'lupin_flour';
|
|
||||||
UPDATE "ingredients" SET "key" = 'bakingSoda' WHERE "key" = 'baking_soda';
|
|
||||||
UPDATE "ingredients" SET "key" = 'potatoStarch' WHERE "key" = 'potato_starch';
|
|
||||||
UPDATE "ingredients" SET "key" = 'mapleSyrup' WHERE "key" = 'maple_syrup';
|
|
||||||
UPDATE "ingredients" SET "key" = 'brownSugar' WHERE "key" = 'brown_sugar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'powderedSugar' WHERE "key" = 'powdered_sugar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'demeraraSugar' WHERE "key" = 'demerara_sugar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'darkChocolate' WHERE "key" = 'dark_chocolate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'milkChocolate' WHERE "key" = 'milk_chocolate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'whiteChocolate' WHERE "key" = 'white_chocolate';
|
|
||||||
UPDATE "ingredients" SET "key" = 'chocolateChips' WHERE "key" = 'chocolate_chips';
|
|
||||||
UPDATE "ingredients" SET "key" = 'cocoaPowder' WHERE "key" = 'cocoa_powder';
|
|
||||||
UPDATE "ingredients" SET "key" = 'vanillaExtract' WHERE "key" = 'vanilla_extract';
|
|
||||||
UPDATE "ingredients" SET "key" = 'palmSugar' WHERE "key" = 'palm_sugar';
|
|
||||||
UPDATE "ingredients" SET "key" = 'caneSyrup' WHERE "key" = 'cane_syrup';
|
|
||||||
|
|
||||||
-- IngredientCategory/IngredientSubcategory enum rename — same
|
|
||||||
-- add-with-default-then-swap approach as
|
|
||||||
-- 20260818113250_ingredient_taxonomy_rework/migration.sql: the old and
|
|
||||||
-- new enums share no values, so a direct cast isn't possible. Existing
|
|
||||||
-- rows land on the placeholder default; seedReferenceData() (runs on
|
|
||||||
-- every container start, see apps/api/Dockerfile) corrects every row's
|
|
||||||
-- real category/subcategory immediately after.
|
|
||||||
CREATE TYPE "IngredientCategory_new" AS ENUM ('freshProduce', 'meatAndSeafood', 'dryGoods', 'bakery', 'dairyAndCheese', 'condimentsAndSpices', 'cookingEssentials');
|
|
||||||
CREATE TYPE "IngredientSubcategory_new" AS ENUM ('vegetables', 'fruits', 'freshHerbs', 'meats', 'poultry', 'fish', 'shellfish', 'starches', 'legumes', 'nutsAndSeeds', 'other', 'breads', 'rawDough', 'dairy', 'eggs', 'plantBasedAlternatives', 'spices', 'sauces', 'seasonings', 'bases', 'thickeners', 'sugars');
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "category_new" "IngredientCategory_new" NOT NULL DEFAULT 'dryGoods';
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "subcategory_new" "IngredientSubcategory_new" NOT NULL DEFAULT 'other';
|
|
||||||
ALTER TABLE "ingredients" DROP COLUMN "category";
|
|
||||||
ALTER TABLE "ingredients" DROP COLUMN "subcategory";
|
|
||||||
ALTER TABLE "ingredients" RENAME COLUMN "category_new" TO "category";
|
|
||||||
ALTER TABLE "ingredients" RENAME COLUMN "subcategory_new" TO "subcategory";
|
|
||||||
DROP TYPE "IngredientCategory";
|
|
||||||
DROP TYPE "IngredientSubcategory";
|
|
||||||
ALTER TYPE "IngredientCategory_new" RENAME TO "IngredientCategory";
|
|
||||||
ALTER TYPE "IngredientSubcategory_new" RENAME TO "IngredientSubcategory";
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- Adds how many portions a recipe yields as written (`Recipe.portions`) —
|
|
||||||
-- the planning recipe picker now pre-fills its own "how many portions?"
|
|
||||||
-- step from this (see `RecipePickerDialog`), closing the gap noted on
|
|
||||||
-- `PlanningItem.portions`'s own migration ("no such default exists ... on
|
|
||||||
-- `Recipe` either"). Backfills any pre-existing row with 4 portions (a
|
|
||||||
-- reasonable default recipe yield) via a transient DEFAULT, then drops it
|
|
||||||
-- so it isn't implicitly reused for new inserts going forward — same
|
|
||||||
-- pattern as `20260819064721_planning_item_portions`.
|
|
||||||
ALTER TABLE "recipe" ADD COLUMN "portions" INTEGER NOT NULL DEFAULT 4;
|
|
||||||
ALTER TABLE "recipe" ALTER COLUMN "portions" DROP DEFAULT;
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
-- Adds the `unit` reference catalog (key/type/to_base_factor) so recipe
|
|
||||||
-- ingredient units are a closed, normalized set instead of free text —
|
|
||||||
-- groundwork for a future unit-conversion feature (e.g. a shopping list
|
|
||||||
-- summing "500g" + "0.5kg" of the same ingredient), not that feature
|
|
||||||
-- itself. Seeded by reference-seed-data.ts's UNITS, same "SQL creates the
|
|
||||||
-- shape, application code seeds the rows" split as Diet/Allergy/Ingredient.
|
|
||||||
--
|
|
||||||
-- `recipe_ingredient.unit` (free text) is replaced by `unit_id` (FK), with
|
|
||||||
-- no backfill: a free-text value like "cas" or "grammes" can't be reliably
|
|
||||||
-- mapped to a catalog key without a human in the loop. Acceptable as a
|
|
||||||
-- straight breaking change here — the app has no real recipes yet
|
|
||||||
-- (pre-launch) — rather than staging `unit_id` as nullable across a
|
|
||||||
-- transition nothing will ever populate.
|
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `unit` on the `recipe_ingredient` table. All the data in the column will be lost.
|
|
||||||
- Added the required column `unit_id` to the `recipe_ingredient` table without a default value. This is not possible if the table is not empty.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "UnitType" AS ENUM ('MASS', 'VOLUME', 'COUNT');
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "recipe_ingredient" DROP COLUMN "unit",
|
|
||||||
ADD COLUMN "unit_id" INTEGER NOT NULL;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "unit" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
"type" "UnitType" NOT NULL,
|
|
||||||
"to_base_factor" DECIMAL(12,4) NOT NULL DEFAULT 1,
|
|
||||||
|
|
||||||
CONSTRAINT "unit_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "unit_key_key" ON "unit"("key");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "recipe_ingredient" ADD CONSTRAINT "recipe_ingredient_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "unit"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
-- Adds `TechStep.key` (`key String @unique`) and `TechStepMapping.locale` —
|
|
||||||
-- same "stable English camelCase uid, French label lives only in apps/web's
|
|
||||||
-- locales/fr/translation.json" convention as Diet/Category/Unit. `TechStep`
|
|
||||||
-- was created in the initial migration with no way to identify a row other
|
|
||||||
-- than its numeric id; this closes that gap so reference-seed-data.ts can
|
|
||||||
-- upsert it by key like every other reference catalog. `locale` lets the
|
|
||||||
-- same TechStep carry one matching rule set per language. Neither table has
|
|
||||||
-- ever been seeded (no rows exist pre-launch), so plain NOT NULL columns
|
|
||||||
-- with no backfill are safe.
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "key" TEXT NOT NULL;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "tech_step_key_key" ON "tech_step"("key");
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "tech_step_mapping" ADD COLUMN "locale" TEXT NOT NULL;
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
-- Adds `Source.key` (`key String @unique`) — the catalog of implemented
|
|
||||||
-- recipe sources is now kept in sync with the adapter registry
|
|
||||||
-- (recipe-source-registry.ts) by key, same "stable English camelCase uid"
|
|
||||||
-- convention as Diet/Unit/TechStep, rather than hand-maintained. `sources`
|
|
||||||
-- has never been seeded (no rows exist pre-launch), so a plain NOT NULL
|
|
||||||
-- column with no backfill is safe.
|
|
||||||
--
|
|
||||||
-- Adds `Recipe.external_id` — the item's identifier on `source`, `null`
|
|
||||||
-- for a manually-authored recipe. `@@unique([sourceId, externalId])`
|
|
||||||
-- prevents importing the same source recipe twice; Postgres treats each
|
|
||||||
-- NULL as distinct, so manually-authored recipes (both columns null) never
|
|
||||||
-- collide with each other or with one another here.
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "sources" ADD COLUMN "key" TEXT NOT NULL;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "sources_key_key" ON "sources"("key");
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "recipe" ADD COLUMN "external_id" TEXT;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "recipe_source_id_external_id_key" ON "recipe"("source_id", "external_id");
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
-- Replaces `Step.tech_step_id` (single nullable FK — at most one technique
|
|
||||||
-- per step) with `step_tech_step`, an ordered join table — a step can
|
|
||||||
-- genuinely involve more than one technique (e.g. "Dans une poêle chaude,
|
|
||||||
-- faire chauffer une noix de beurre" is both `preheat` and `melt`). Per PR
|
|
||||||
-- review feedback on the first version of this feature; `tech_step`/`step`
|
|
||||||
-- have never carried real recipe data yet (this feature isn't released),
|
|
||||||
-- so no backfill is needed.
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "step" DROP CONSTRAINT "step_tech_step_id_fkey";
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "step" DROP COLUMN "tech_step_id";
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step" (
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"tech_step_id" INTEGER NOT NULL,
|
|
||||||
"order" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_pkey" PRIMARY KEY ("step_id", "order")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step" ADD CONSTRAINT "step_tech_step_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step" ADD CONSTRAINT "step_tech_step_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- Adds `Source.official` (Boolean, no default — every source must state
|
|
||||||
-- it explicitly, mirrors `RecipeSourceAdapter.official`) and `house_source`,
|
|
||||||
-- an opt-in join table: a household sees recipes from a source only once a
|
|
||||||
-- row exists for it (no row = hidden). `sources` has never been seeded
|
|
||||||
-- (no adapter registered yet), so a plain NOT NULL column with no backfill
|
|
||||||
-- is safe.
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "sources" ADD COLUMN "official" BOOLEAN NOT NULL;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "house_source" (
|
|
||||||
"house_id" INTEGER NOT NULL,
|
|
||||||
"source_id" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "house_source_pkey" PRIMARY KEY ("house_id", "source_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "house_source" ADD CONSTRAINT "house_source_house_id_fkey" FOREIGN KEY ("house_id") REFERENCES "house"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "house_source" ADD CONSTRAINT "house_source_source_id_fkey" FOREIGN KEY ("source_id") REFERENCES "sources"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- Adds `Source.icon_url` — the source's own logo/favicon, shown next to
|
|
||||||
-- its name in the `SourceSelect` picker (apps/web). Nullable, no backfill
|
|
||||||
-- needed for existing rows (none had one to begin with).
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "sources" ADD COLUMN "icon_url" TEXT;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "step_tech_step" ADD COLUMN "end" INTEGER,
|
|
||||||
ADD COLUMN "start" INTEGER;
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "tech_step_mapping" DROP CONSTRAINT "tech_step_mapping_tech_step_id_fkey";
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "tech_step_mapping";
|
|
||||||
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER,
|
|
||||||
ADD COLUMN "context_start" INTEGER;
|
|
||||||
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step_correction" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"corrector_id" INTEGER NOT NULL,
|
|
||||||
"start" INTEGER NOT NULL,
|
|
||||||
"end" INTEGER NOT NULL,
|
|
||||||
"previous_tech_step_id" INTEGER,
|
|
||||||
"corrected_tech_step_id" INTEGER,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"consumed_at" TIMESTAMP(3),
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_correction_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "tech_step_training_suggestion" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"tech_step_id" INTEGER NOT NULL,
|
|
||||||
"locale" TEXT NOT NULL,
|
|
||||||
"suggested_synonyms" TEXT[],
|
|
||||||
"suggested_utterances" TEXT[],
|
|
||||||
"source_type" TEXT NOT NULL,
|
|
||||||
"source_correction_id" INTEGER,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "tech_step_training_suggestion_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrector_id_fkey" FOREIGN KEY ("corrector_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_previous_tech_step_id_fkey" FOREIGN KEY ("previous_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrected_tech_step_id_fkey" FOREIGN KEY ("corrected_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_source_correction_id_fkey" FOREIGN KEY ("source_correction_id") REFERENCES "step_tech_step_correction"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "utensil" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "utensil_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "utensil_key_key" ON "utensil"("key");
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step_ingredient" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"tech_step_order" INTEGER NOT NULL,
|
|
||||||
"ingredient_id" INTEGER NOT NULL,
|
|
||||||
"quantity" DECIMAL(10,2),
|
|
||||||
"unit_id" INTEGER,
|
|
||||||
"start" INTEGER NOT NULL,
|
|
||||||
"end" INTEGER NOT NULL,
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'auto',
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_ingredient_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step_utensil" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"tech_step_order" INTEGER NOT NULL,
|
|
||||||
"utensil_id" INTEGER NOT NULL,
|
|
||||||
"start" INTEGER NOT NULL,
|
|
||||||
"end" INTEGER NOT NULL,
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'auto',
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_utensil_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "unit"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_utensil_id_fkey" FOREIGN KEY ("utensil_id") REFERENCES "utensil"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -24,33 +24,22 @@ model House {
|
||||||
/// member — see `house.service.ts`'s generator for the charset/length.
|
/// member — see `house.service.ts`'s generator for the charset/length.
|
||||||
inviteCode String @unique @map("invite_code")
|
inviteCode String @unique @map("invite_code")
|
||||||
|
|
||||||
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
|
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
|
||||||
members UserProfile[] @relation("HouseMember")
|
members UserProfile[] @relation("HouseMember")
|
||||||
plannings Planning[]
|
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")
|
@@map("house")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
/// `name` 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
|
/// script (prisma/seed.ts) can `upsert` by name and stay idempotent/safe to
|
||||||
/// re-run, and so two reference rows can never silently duplicate the same
|
/// re-run, and so two reference rows can never silently duplicate the same
|
||||||
/// regime. A stable English camelCase uid (e.g. `"vegetarian"`), not the
|
/// regime.
|
||||||
/// 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 {
|
model Diet {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
name String @unique
|
||||||
|
|
||||||
users UserProfile[]
|
users UserProfile[]
|
||||||
recipes RecipeDiet[]
|
|
||||||
ingredients IngredientDiet[]
|
|
||||||
|
|
||||||
@@map("diet")
|
@@map("diet")
|
||||||
}
|
}
|
||||||
|
|
@ -65,13 +54,11 @@ enum AllergenKind {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enumeration-style table, meant to grow over time (e.g. allergy nuances).
|
/// Enumeration-style table, meant to grow over time (e.g. allergy nuances).
|
||||||
/// `key` is `@unique` for the same reason as `Diet.key` above — a stable
|
/// `name` is `@unique` for the same reason as `Diet.name` above. `kind` is
|
||||||
/// slug (`catalog.allergens.<key>` in `apps/web`'s locale file), not the
|
/// also not in the original spec doc — see {@link AllergenKind}.
|
||||||
/// display label. `kind` is also not in the original spec doc — see
|
|
||||||
/// {@link AllergenKind}.
|
|
||||||
model Category {
|
model Category {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
name String @unique
|
||||||
kind AllergenKind @default(ALLERGY)
|
kind AllergenKind @default(ALLERGY)
|
||||||
|
|
||||||
allergies Allergy[]
|
allergies Allergy[]
|
||||||
|
|
@ -83,9 +70,8 @@ model Allergy {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
categoryId Int @map("cat_id")
|
categoryId Int @map("cat_id")
|
||||||
|
|
||||||
category Category @relation(fields: [categoryId], references: [id])
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
users UserProfileAllergy[]
|
users UserProfileAllergy[]
|
||||||
ingredients IngredientAllergy[]
|
|
||||||
|
|
||||||
@@map("allergy")
|
@@map("allergy")
|
||||||
}
|
}
|
||||||
|
|
@ -104,47 +90,19 @@ model UserProfile {
|
||||||
houseId Int? @map("house_id")
|
houseId Int? @map("house_id")
|
||||||
dietId Int? @map("diet_id")
|
dietId Int? @map("diet_id")
|
||||||
|
|
||||||
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
|
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
|
||||||
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
||||||
allergies UserProfileAllergy[]
|
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
|
/// Households this profile administers. In practice at most one — a
|
||||||
/// profile can only ever belong to (and thus admin) a single household at
|
/// 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
|
/// a time — but Prisma models the admin side of a one-to-many FK as a
|
||||||
/// list regardless of that real-world cardinality.
|
/// list regardless of that real-world cardinality.
|
||||||
administeredHouses House[] @relation("HouseAdmin")
|
administeredHouses House[] @relation("HouseAdmin")
|
||||||
preferences UserPreference?
|
preferences UserPreference?
|
||||||
/// 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")
|
@@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,
|
/// Not in the original spec doc — personalization settings (theme for now,
|
||||||
/// meant to grow), one row per profile, created on demand (see
|
/// meant to grow), one row per profile, created on demand (see
|
||||||
/// `preferences.service.ts`) rather than at signup — same "absent means the
|
/// `preferences.service.ts`) rather than at signup — same "absent means the
|
||||||
|
|
@ -205,7 +163,6 @@ model PlanningItem {
|
||||||
weekDay String @map("week_day")
|
weekDay String @map("week_day")
|
||||||
meal String
|
meal String
|
||||||
recipeId Int @map("recipe_id")
|
recipeId Int @map("recipe_id")
|
||||||
portions Int
|
|
||||||
|
|
||||||
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id])
|
recipe Recipe @relation(fields: [recipeId], references: [id])
|
||||||
|
|
@ -217,399 +174,45 @@ model PlanningItem {
|
||||||
// Recipes
|
// 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 {
|
model Source {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
name String
|
||||||
name String
|
url String?
|
||||||
url String?
|
|
||||||
/// Whether this is an official API (the site/publisher provides
|
|
||||||
/// structured recipe data itself) or unofficial web scraping (we parse
|
|
||||||
/// HTML the site never committed to a stable shape for) — mirrors
|
|
||||||
/// `RecipeSourceAdapter.official` (recipe-source-adapter.ts), synced the
|
|
||||||
/// same way as `key`/`name`. Surfaced to households picking which
|
|
||||||
/// sources to enable (see `HouseSource`) so scraped content is never
|
|
||||||
/// mistaken for an official feed.
|
|
||||||
official Boolean
|
|
||||||
/// The source's own logo/favicon URL, shown next to its name in
|
|
||||||
/// `SourceSelect` (apps/web) — mirrors `RecipeSourceAdapter.iconUrl`,
|
|
||||||
/// synced the same way as `name`/`official`. `null` if the source has
|
|
||||||
/// none worth showing.
|
|
||||||
iconUrl String? @map("icon_url")
|
|
||||||
|
|
||||||
recipes Recipe[]
|
recipes Recipe[]
|
||||||
enabledHouses HouseSource[]
|
|
||||||
|
|
||||||
@@map("sources")
|
@@map("sources")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which sources a household has chosen to see recipes from — opt-in: no
|
|
||||||
/// row means disabled. A newly created household starts with nothing
|
|
||||||
/// enabled (see the household-creation step in the signup wizard, and the
|
|
||||||
/// household settings page for changing this later); every recipe catalog
|
|
||||||
/// tab (`recipe.service.ts`'s `listRecipes`) filters out recipes whose
|
|
||||||
/// `sourceId` isn't in this list for the viewer's household — a
|
|
||||||
/// manually-authored recipe (`sourceId` `null`) is never affected, this
|
|
||||||
/// only ever hides recipes that came from an external source.
|
|
||||||
model HouseSource {
|
|
||||||
houseId Int @map("house_id")
|
|
||||||
sourceId Int @map("source_id")
|
|
||||||
|
|
||||||
house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
|
|
||||||
source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([houseId, sourceId])
|
|
||||||
@@map("house_source")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Not in the original spec doc — who can *read* a recipe. Controls only
|
|
||||||
/// 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 {
|
model Recipe {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
sourceId Int? @map("source_id")
|
sourceId Int? @map("source_id")
|
||||||
/// The item's identifier on `source` (`RecipeSourceListItem.externalId`,
|
description String?
|
||||||
/// recipe-source-adapter.ts) — `null` for a manually-authored recipe,
|
picture String?
|
||||||
/// 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)
|
source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull)
|
||||||
ingredients RecipeIngredient[]
|
ingredients RecipeIngredient[]
|
||||||
steps Step[]
|
steps Step[]
|
||||||
planningItems PlanningItem[]
|
planningItems PlanningItem[]
|
||||||
favoritedBy RecipeFavorite[]
|
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
|
||||||
diets RecipeDiet[]
|
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
|
||||||
|
|
||||||
@@unique([sourceId, externalId])
|
|
||||||
@@map("recipe")
|
@@map("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")
|
|
||||||
|
|
||||||
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 {
|
model Ingredient {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
name String
|
||||||
icon IngredientIcon @default(JAR)
|
icon String?
|
||||||
category IngredientCategory @default(dryGoods)
|
alternateRecipeId Int? @map("alternate_recipe")
|
||||||
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[]
|
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
||||||
allergies IngredientAllergy[]
|
recipes RecipeIngredient[]
|
||||||
/// 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")
|
@@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
|
/// recipe <-> ingredients association. The spec documents this as a plain
|
||||||
/// many-to-many, but a shopping list / batch-cooking calculation needs a
|
/// many-to-many, but a shopping list / batch-cooking calculation needs a
|
||||||
/// quantity per recipe, so this join table carries quantity + unit
|
/// quantity per recipe, so this join table carries quantity + unit
|
||||||
|
|
@ -618,66 +221,35 @@ model RecipeIngredient {
|
||||||
recipeId Int @map("recipe_id")
|
recipeId Int @map("recipe_id")
|
||||||
ingredientId Int @map("ingredient_id")
|
ingredientId Int @map("ingredient_id")
|
||||||
quantity Decimal @db.Decimal(10, 2)
|
quantity Decimal @db.Decimal(10, 2)
|
||||||
unitId Int @map("unit_id")
|
unit String
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||||
unit Unit @relation(fields: [unitId], references: [id])
|
|
||||||
|
|
||||||
@@id([recipeId, ingredientId])
|
@@id([recipeId, ingredientId])
|
||||||
@@map("recipe_ingredient")
|
@@map("recipe_ingredient")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `key` is `@unique` — same convention as `Diet`/`Unit`: a stable English
|
|
||||||
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
|
|
||||||
/// label lives in `apps/web`'s `locales/fr/translation.json` under
|
|
||||||
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
|
|
||||||
///
|
|
||||||
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
|
|
||||||
/// `TechStepClassifierService`) used to go through a DB-backed
|
|
||||||
/// `TechStepMapping` table of per-locale regex expressions — replaced with
|
|
||||||
/// a spaCy-based model (`services/tech-step-intent-service`) trained from
|
|
||||||
/// in-code data (`tech-step-training-data.ts`) once regexes turned out
|
|
||||||
/// unable to generalize past their own literal vocabulary. Nothing
|
|
||||||
/// queries/edits that matching data at runtime anymore (it only ever feeds
|
|
||||||
/// that service's one-time training pass), so it no longer needs a table
|
|
||||||
/// of its own — this row now only exists to be a stable id/key other
|
|
||||||
/// tables (`StepTechStep`) reference.
|
|
||||||
model TechStep {
|
model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
|
||||||
|
|
||||||
steps StepTechStep[]
|
steps Step[]
|
||||||
/// Corrections where this technique was the *previous* (possibly wrong)
|
mappings TechStepMapping[]
|
||||||
/// 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")
|
@@map("tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `key` is `@unique`, same bare id+key shape as `TechStep` — no
|
/// Used by the recipe-import pipeline to auto-detect which technique a raw
|
||||||
/// categorization taxonomy like `Ingredient` needed yet, and no matching
|
/// instruction step corresponds to (expression = text pattern, weight = match score).
|
||||||
/// data of its own here either: unlike `TechStep` (whose matching synonyms
|
model TechStepMapping {
|
||||||
/// used to live in TS and were moved into
|
id Int @id @default(autoincrement())
|
||||||
/// `services/tech-step-intent-service`'s `training_data.py`), this catalog
|
techStepId Int @map("tech_step_id")
|
||||||
/// was *born* owned by that service (`utensil_vocabulary.py`) since nothing
|
expression String
|
||||||
/// pre-existing needed it — this row only exists to be a stable id/key
|
weight Int
|
||||||
/// `StepTechStepUtensil` references, and to carry a French label
|
|
||||||
/// (`apps/web`'s `catalog.utensils.<key>`, see `reference-seed-data.ts`'s
|
|
||||||
/// `UTENSILS`).
|
|
||||||
model Utensil {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
key String @unique
|
|
||||||
|
|
||||||
steps StepTechStepUtensil[]
|
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("utensil")
|
@@map("tech_step_mapping")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
||||||
|
|
@ -690,210 +262,10 @@ model Step {
|
||||||
description String
|
description String
|
||||||
picture String?
|
picture String?
|
||||||
order Int
|
order Int
|
||||||
|
techStepId Int? @map("tech_step_id")
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
techSteps StepTechStep[]
|
techStep TechStep? @relation(fields: [techStepId], references: [id], onDelete: SetNull)
|
||||||
/// User-submitted corrections to this step's detected techniques — see
|
|
||||||
/// `StepTechStepCorrection`.
|
|
||||||
corrections StepTechStepCorrection[]
|
|
||||||
|
|
||||||
@@map("step")
|
@@map("step")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single step's *ordered sequence* of detected techniques — one
|
|
||||||
/// instruction can genuinely involve more than one (e.g. "Dans une poêle
|
|
||||||
/// chaude, faire chauffer une noix de beurre" is both `preheat` and
|
|
||||||
/// `melt`), which is why this replaced the original single nullable
|
|
||||||
/// `Step.techStepId` FK (per PR review feedback on the first version of
|
|
||||||
/// this feature). `order` is the position within *this step* (0-based, in
|
|
||||||
/// the order `matchTechStepSpans` — `tech-step-matcher.ts` — detected the
|
|
||||||
/// techniques in the description), not a global ordering across different
|
|
||||||
/// steps of the recipe (that's `Step.order`).
|
|
||||||
///
|
|
||||||
/// `start`/`end` are the tight matched *keyword* span within
|
|
||||||
/// `Step.description` (see `TechStepMatch`, `tech-step-matcher.ts`) — what
|
|
||||||
/// the recipe detail view highlights strongly, with a tooltip.
|
|
||||||
/// `contextStart`/`contextEnd` are the wider *clause* the keyword was found
|
|
||||||
/// in (e.g. "Dans une poêle chaude" around a `preheat` keyword of "poêle
|
|
||||||
/// chaude") — always contains `start`/`end` — what the detail view
|
|
||||||
/// highlights more subtly around it, so both "the exact trigger word(s)"
|
|
||||||
/// and "how much of the sentence is about this technique" are visible.
|
|
||||||
/// Nullable, **not backfilled**: adding them `NOT NULL` without a default
|
|
||||||
/// would fail outright against any pre-existing row, the same mistake the
|
|
||||||
/// `ingredient_unit_catalog` migration made against real prod data. A row
|
|
||||||
/// from before a column existed just has no span for it (no highlight)
|
|
||||||
/// until its recipe is next saved, which recomputes every step's
|
|
||||||
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
|
|
||||||
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
|
|
||||||
/// graceful degradation, not a permanent gap.
|
|
||||||
///
|
|
||||||
/// `source` distinguishes a `"manual"` row — written immediately when a
|
|
||||||
/// user submits a `StepTechStepCorrection` that asserts a technique
|
|
||||||
/// (`recipe-tech-step-correction.service.ts`'s `applyManualCorrection`),
|
|
||||||
/// not just recorded as a pending suggestion — from an `"auto"` row the
|
|
||||||
/// classifier itself produced (`tech-step-matcher.ts`). Both kinds coexist
|
|
||||||
/// in the same ordered sequence; the detail view (`apps/web`) renders them
|
|
||||||
/// with a different highlight color so a viewer can tell which is which.
|
|
||||||
/// `backfillTechSteps` (`scripts/backfill-tech-steps.ts`) only ever
|
|
||||||
/// deletes/recreates `"auto"` rows — a `"manual"` row survives a
|
|
||||||
/// classifier/corpus change until a user (or a future moderation feature)
|
|
||||||
/// explicitly changes it again.
|
|
||||||
model StepTechStep {
|
|
||||||
stepId Int @map("step_id")
|
|
||||||
techStepId Int @map("tech_step_id")
|
|
||||||
order Int
|
|
||||||
start Int?
|
|
||||||
end Int?
|
|
||||||
contextStart Int? @map("context_start")
|
|
||||||
contextEnd Int? @map("context_end")
|
|
||||||
source String @default("auto")
|
|
||||||
|
|
||||||
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
|
||||||
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
|
||||||
/// Ingredients mentioned in the same clause as this technique occurrence
|
|
||||||
/// — see `StepTechStepIngredient`.
|
|
||||||
ingredients StepTechStepIngredient[]
|
|
||||||
/// Utensils mentioned in the same clause as this technique occurrence —
|
|
||||||
/// see `StepTechStepUtensil`.
|
|
||||||
utensils StepTechStepUtensil[]
|
|
||||||
|
|
||||||
@@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,7 +1,5 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
|
||||||
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||||
import { registerAllRecipeSources } from "../src/sources/index.js";
|
|
||||||
|
|
||||||
// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in
|
// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in
|
||||||
// the rest of the app's config/env plumbing this doesn't need), run via
|
// the rest of the app's config/env plumbing this doesn't need), run via
|
||||||
|
|
@ -10,10 +8,8 @@ import { registerAllRecipeSources } from "../src/sources/index.js";
|
||||||
// `prisma migrate reset`. The actual data/logic lives in
|
// `prisma migrate reset`. The actual data/logic lives in
|
||||||
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
|
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
registerAllRecipeSources();
|
|
||||||
|
|
||||||
seedReferenceData(prisma)
|
seedReferenceData(prisma)
|
||||||
.then(() => syncRecipeSources(prisma))
|
|
||||||
.then(() => prisma.$disconnect())
|
.then(() => prisma.$disconnect())
|
||||||
.catch(async (err) => {
|
.catch(async (err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
import { errorHandlerService } from "@batch-cooking/error-tools";
|
import { errorHandlerService } from "@batch-cooking/error-tools";
|
||||||
import { createErrorMiddleware, ExpressServer } from "@batch-cooking/express-tools";
|
import { ExpressServer, createErrorMiddleware } from "@batch-cooking/express-tools";
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { errorLogger } from "./middlewares/error-logger.js";
|
|
||||||
import { requestLogger } from "./middlewares/request-logger.js";
|
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
import { houseRouter } from "./modules/house/house.routes.js";
|
import { houseRouter } from "./modules/house/house.routes.js";
|
||||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
|
||||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||||
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
|
|
||||||
import { referenceRouter } from "./modules/reference/reference.routes.js";
|
import { referenceRouter } from "./modules/reference/reference.routes.js";
|
||||||
import { shoppingListRouter } from "./modules/shopping-list/shopping-list.routes.js";
|
|
||||||
import { sourcesRouter } from "./modules/sources/sources.routes.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the API's `ExpressServer`: standard middleware, routes, and the
|
* Builds the API's `ExpressServer`: standard middleware, routes, and the
|
||||||
|
|
@ -26,12 +20,6 @@ import { sourcesRouter } from "./modules/sources/sources.routes.js";
|
||||||
*/
|
*/
|
||||||
export function createServer(): ExpressServer {
|
export function createServer(): ExpressServer {
|
||||||
const server = new ExpressServer();
|
const server = new ExpressServer();
|
||||||
// First middleware registered, before even setupCore's own (CORS/JSON
|
|
||||||
// body parsing/cookies) — it only reads `req`/`res`, so it doesn't need
|
|
||||||
// to run after them, and mounting it first means it wraps the *whole*
|
|
||||||
// pipeline (its "finish" listener still fires for a request that never
|
|
||||||
// makes it past CORS/body-parsing, not just ones that reach a route).
|
|
||||||
server.addMiddleware(requestLogger);
|
|
||||||
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
||||||
|
|
||||||
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||||
|
|
@ -40,19 +28,10 @@ export function createServer(): ExpressServer {
|
||||||
|
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
server.mountRouter("/house", houseRouter);
|
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("/planning", planningRouter);
|
||||||
server.mountRouter("/preferences", preferencesRouter);
|
server.mountRouter("/preferences", preferencesRouter);
|
||||||
server.mountRouter("/profile", profileRouter);
|
server.mountRouter("/profile", profileRouter);
|
||||||
server.mountRouter("/recipes", recipeRouter);
|
|
||||||
server.mountRouter("/reference", referenceRouter);
|
server.mountRouter("/reference", referenceRouter);
|
||||||
server.mountRouter("/shopping-list", shoppingListRouter);
|
|
||||||
server.mountRouter("/sources", sourcesRouter);
|
|
||||||
|
|
||||||
// Serves the built frontend (production Docker image only — see
|
// Serves the built frontend (production Docker image only — see
|
||||||
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after
|
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after
|
||||||
|
|
@ -69,12 +48,10 @@ export function createServer(): ExpressServer {
|
||||||
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
|
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Two error-handling middlewares in a row (Express runs them in
|
// Final error-handling middleware: every thrown/`next(err)`-ed error in
|
||||||
// registration order, same as regular middleware) — errorLogger logs the
|
// the app ends up here. All the "what status/body does this error map
|
||||||
// error, then hands it on (`next(err)`) to the real one: all the "what
|
// to" logic lives in ErrorHandlerService, from @batch-cooking/error-tools
|
||||||
// status/body does this error map to" logic lives in ErrorHandlerService,
|
// — this stays a thin adapter.
|
||||||
// from @batch-cooking/error-tools — this stays a thin adapter.
|
|
||||||
server.setErrorHandler(errorLogger);
|
|
||||||
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
|
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,6 @@
|
||||||
import dotenv from "dotenv";
|
import "dotenv/config";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// Loads `.env.test` instead of `.env` when running the test suite
|
|
||||||
// (NODE_ENV=test, set by `cross-env` in package.json's `test` script —
|
|
||||||
// already present in `process.env` by the time this module runs, since
|
|
||||||
// `cross-env` sets it before invoking node/tsx at all). Keeps
|
|
||||||
// `resetDatabase()` (test-support/reset-db.ts, which TRUNCATEs almost
|
|
||||||
// every table before each test) pointed at a dedicated test database,
|
|
||||||
// never whatever `pnpm dev` actually uses — running the test suite once
|
|
||||||
// already wiped a real local dev database this way (`.env`/`.env.test`
|
|
||||||
// sharing one `DATABASE_URL`), see `.env.test.example` for how to set the
|
|
||||||
// separate test database this now requires.
|
|
||||||
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for every environment variable the API reads. Parsing (below)
|
* Schema for every environment variable the API reads. Parsing (below)
|
||||||
* fails fast at startup if something required is missing/invalid, instead
|
* fails fast at startup if something required is missing/invalid, instead
|
||||||
|
|
@ -44,52 +32,6 @@ const envSchema = z.object({
|
||||||
* the frontend instead.
|
* the frontend instead.
|
||||||
*/
|
*/
|
||||||
FRONTEND_DIST_DIR: z.string().optional(),
|
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. */
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,3 @@
|
||||||
// Imported for its side effect only (loading `.env`/`.env.test` via
|
|
||||||
// dotenv) — must run *before* `new PrismaClient()` below. The generated
|
|
||||||
// Prisma Client bakes in its own fallback `.env` path (always
|
|
||||||
// `apps/api/.env`, the dev one — resolved once at `prisma generate` time)
|
|
||||||
// and loads it internally the first time a `PrismaClient` is constructed,
|
|
||||||
// unless `DATABASE_URL` is already set in `process.env` by then — dotenv
|
|
||||||
// never overrides an already-set variable, so whichever of these two env
|
|
||||||
// loads runs first "wins" for the rest of the process. Without this
|
|
||||||
// import, that race depended entirely on which test file some *other*
|
|
||||||
// module happened to import first, which normally worked out only by
|
|
||||||
// coincidence (whatever file mocha's `test/**/*.test.ts` glob happens to
|
|
||||||
// resolve first) — running a single test file in isolation (e.g. `mocha
|
|
||||||
// test/some-file.test.ts` directly, bypassing that glob) could silently
|
|
||||||
// resolve `DATABASE_URL` to the real dev database instead of
|
|
||||||
// `.env.test`'s. `resetDatabase()`'s own `assertRunningAgainstTestDatabase`
|
|
||||||
// guard (test-support/reset-db.ts) is what actually caught this in
|
|
||||||
// practice — it throws rather than truncating the wrong database — but
|
|
||||||
// the fix belongs here, at the source, not just at that one call site.
|
|
||||||
import "../config/env.js";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import type { PrismaClient } from "@prisma/client";
|
|
||||||
import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upserts one `Source` row (schema.prisma) per adapter currently in
|
|
||||||
* `recipe-source-registry.ts`, keyed by `adapter.key` — keeps the
|
|
||||||
* `sources` catalog an exact mirror of "which sources are actually
|
|
||||||
* implemented in code", rather than a hand-maintained list that can drift
|
|
||||||
* out of sync the way `DIETS`/`UNITS` (`reference-seed-data.ts`) would if
|
|
||||||
* copy-pasted here. Call once at startup (`prisma/seed.ts`) and in test
|
|
||||||
* setup (`test-support/reset-db.ts`), the same place `seedReferenceData`
|
|
||||||
* runs — kept as its own function rather than folded into that one, since
|
|
||||||
* it reads from the adapter registry instead of a static array.
|
|
||||||
*
|
|
||||||
* Never deletes a `Source` row whose key fell out of the registry (e.g. an
|
|
||||||
* adapter temporarily removed from code) — a recipe already imported from
|
|
||||||
* it should keep citing it rather than having `sourceId` silently nulled
|
|
||||||
* out from under it (see `onDelete: SetNull` on `Recipe.source` in
|
|
||||||
* schema.prisma, which is what *would* happen on an actual delete).
|
|
||||||
*
|
|
||||||
* Safe to call with an empty registry (leaves the `sources` table
|
|
||||||
* untouched) — the case whenever nothing has called `registerRecipeSource`
|
|
||||||
* yet, e.g. most test files (see `apps/api/src/sources/index.ts` for where
|
|
||||||
* the app's own concrete adapters — currently just TheMealDB — register
|
|
||||||
* themselves at startup).
|
|
||||||
*/
|
|
||||||
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
|
|
||||||
try {
|
|
||||||
for (const adapter of listRecipeSources()) {
|
|
||||||
await prisma.source.upsert({
|
|
||||||
where: { key: adapter.key },
|
|
||||||
update: {
|
|
||||||
name: adapter.name,
|
|
||||||
official: adapter.official,
|
|
||||||
iconUrl: adapter.iconUrl,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
key: adapter.key,
|
|
||||||
name: adapter.name,
|
|
||||||
official: adapter.official,
|
|
||||||
iconUrl: adapter.iconUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — callers (app startup, `sources.service.ts` via test
|
|
||||||
// setup) already handle/log failures centrally; this function just
|
|
||||||
// isn't allowed a bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which of `externalIds` already have a `Recipe` imported from the source
|
|
||||||
* registered under `sourceKey`, mapped to that `Recipe`'s id — the
|
|
||||||
* DB-touching counterpart to `markAlreadyImported` (recipe-source-adapter.ts),
|
|
||||||
* which stays pure and takes a plain `ReadonlySet<string>` (this map's
|
|
||||||
* `.keys()`) rather than querying itself. The id (not just membership) is
|
|
||||||
* what `sources.service.ts`'s browse endpoint needs to link an
|
|
||||||
* already-imported item straight to its real `Recipe`, instead of a
|
|
||||||
* caller having to look it up again. Returns an empty map (not an error)
|
|
||||||
* for a `sourceKey` with no matching `Source` row — nothing can have been
|
|
||||||
* imported from a source we don't even have a catalog entry for.
|
|
||||||
*/
|
|
||||||
export async function findImportedRecipeIds(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
sourceKey: string,
|
|
||||||
externalIds: string[],
|
|
||||||
): Promise<Map<string, number>> {
|
|
||||||
try {
|
|
||||||
if (externalIds.length === 0) return new Map();
|
|
||||||
|
|
||||||
const source = await prisma.source.findUnique({
|
|
||||||
where: { key: sourceKey },
|
|
||||||
});
|
|
||||||
if (!source) return new Map();
|
|
||||||
|
|
||||||
const imported = await prisma.recipe.findMany({
|
|
||||||
where: { sourceId: source.id, externalId: { in: externalIds } },
|
|
||||||
select: { id: true, externalId: true },
|
|
||||||
});
|
|
||||||
return new Map(
|
|
||||||
imported.flatMap((recipe) =>
|
|
||||||
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see syncRecipeSources()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,102 +0,0 @@
|
||||||
import { env } from "../config/env.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side operational logging — the one place in this codebase allowed
|
|
||||||
* to call `console.*` directly (see `biome.json`'s `noConsole`, which bans
|
|
||||||
* bare `console.log` everywhere else: every log line here goes through a
|
|
||||||
* named level instead, `logger.info(...)`/`logger.error(...)`, never an
|
|
||||||
* unlabeled dump of text). Everything else (the request logger, `server.ts`'s
|
|
||||||
* startup line, the error middleware) goes through this instead of touching
|
|
||||||
* `console` itself, so there's exactly one place that decides the log
|
|
||||||
* *shape* (structured JSON lines — one object per line, trivially
|
|
||||||
* grep/parse-able by `docker logs`/Portainer or any log aggregator, unlike
|
|
||||||
* free-form `console.log` text).
|
|
||||||
*
|
|
||||||
* A class, not a plain object literal — same convention as
|
|
||||||
* `ErrorHandlerService` (`packages/error-tools`): `public` methods are the
|
|
||||||
* actual API, `private` ones are internals a caller never touches directly.
|
|
||||||
* `export const logger = new LoggerService()` below is the single shared
|
|
||||||
* instance every caller imports — there's exactly one log stream for the
|
|
||||||
* whole process, nothing to parameterize per call site, so nobody
|
|
||||||
* constructs their own.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Ascending severity — mirrors the standard `debug < info < warn < error` convention. */
|
|
||||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
||||||
|
|
||||||
/** Arbitrary structured context attached to a log line (a request id, a duration, an error's own fields, …) — merged into the emitted JSON object, never interpolated into the message string itself. */
|
|
||||||
export type LogMeta = Record<string, unknown>;
|
|
||||||
|
|
||||||
const LEVEL_SEVERITY: Record<LogLevel, number> = {
|
|
||||||
debug: 0,
|
|
||||||
info: 1,
|
|
||||||
warn: 2,
|
|
||||||
error: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lines below this level are dropped rather than emitted — keeps `debug`
|
|
||||||
* out of production (verbose, meant for local troubleshooting only) and
|
|
||||||
* out of the test suite's own output (Mocha's reporter is noisy enough
|
|
||||||
* already), while `pnpm dev:api` sees everything. A free function, not a
|
|
||||||
* method — exported (unlike the rest of this module's internals) purely so
|
|
||||||
* `logger.service.test.ts` can exercise the three `NODE_ENV` cases
|
|
||||||
* directly, without needing a `LoggerService` instance at all.
|
|
||||||
*/
|
|
||||||
export function minLevelFor(nodeEnv: typeof env.NODE_ENV): LogLevel {
|
|
||||||
if (nodeEnv === "production") return "info";
|
|
||||||
if (nodeEnv === "test") return "warn";
|
|
||||||
return "debug";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Console method each level writes through — `error`/`warn` go to stderr (their own native behavior), `debug`/`info` to stdout, the usual split log aggregators expect. */
|
|
||||||
const CONSOLE_METHOD: Record<LogLevel, "debug" | "info" | "warn" | "error"> = {
|
|
||||||
debug: "debug",
|
|
||||||
info: "info",
|
|
||||||
warn: "warn",
|
|
||||||
error: "error",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Server-side operational logger — see the module doc comment for why this exists and what it's for. */
|
|
||||||
export class LoggerService {
|
|
||||||
/** Computed once at construction from `env.NODE_ENV` — see {@link minLevelFor}. */
|
|
||||||
private readonly _minSeverity: number;
|
|
||||||
|
|
||||||
public constructor(nodeEnv: typeof env.NODE_ENV = env.NODE_ENV) {
|
|
||||||
this._minSeverity = LEVEL_SEVERITY[minLevelFor(nodeEnv)];
|
|
||||||
}
|
|
||||||
|
|
||||||
public debug(message: string, meta?: LogMeta): void {
|
|
||||||
this._emit("debug", message, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public info(message: string, meta?: LogMeta): void {
|
|
||||||
this._emit("info", message, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public warn(message: string, meta?: LogMeta): void {
|
|
||||||
this._emit("warn", message, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public error(message: string, meta?: LogMeta): void {
|
|
||||||
this._emit("error", message, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _emit(level: LogLevel, message: string, meta?: LogMeta): void {
|
|
||||||
if (LEVEL_SEVERITY[level] < this._minSeverity) return;
|
|
||||||
|
|
||||||
// `meta` spread first so a caller accidentally passing e.g. `{ message:
|
|
||||||
// ... }` in it can never shadow the line's own core fields.
|
|
||||||
const line = {
|
|
||||||
...meta,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
level,
|
|
||||||
message,
|
|
||||||
};
|
|
||||||
// biome-ignore lint/suspicious/noConsole: this is the one place allowed to — see the class doc comment above.
|
|
||||||
console[CONSOLE_METHOD[level]](JSON.stringify(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Single shared instance — this service has no per-call-site state to isolate, same reasoning as `errorHandlerService`. */
|
|
||||||
export const logger = new LoggerService();
|
|
||||||
|
|
@ -1,466 +0,0 @@
|
||||||
import {
|
|
||||||
INGREDIENT_LABEL_SYNONYMS_EN,
|
|
||||||
INGREDIENT_LABEL_SYNONYMS_FR,
|
|
||||||
INGREDIENT_LABELS_EN,
|
|
||||||
INGREDIENT_LABELS_FR,
|
|
||||||
UNIT_LABELS_EN,
|
|
||||||
UNIT_LABELS_FR,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { normalizeText } from "./tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter`
|
|
||||||
* lifts from a recipe source (`ParsedRecipeIngredient`) against our own
|
|
||||||
* `Ingredient`/`Unit` reference catalogs — the ingredient-side counterpart
|
|
||||||
* to `tech-step-matcher.ts`'s technique detection, built for the same
|
|
||||||
* reason: a source's raw text has no idea our catalogs even exist.
|
|
||||||
*
|
|
||||||
* Unlike tech steps (regex mappings hand-authored per technique),
|
|
||||||
* ingredient/unit labels are plain hand-written text, one table per
|
|
||||||
* supported `locale` (`INGREDIENT_LABELS_EN`/`INGREDIENT_LABELS_FR`/
|
|
||||||
* `UNIT_LABELS_EN`/`UNIT_LABELS_FR`, `packages/shared`) — matching them
|
|
||||||
* against arbitrary free text (extra adjectives, plurals, "large diced
|
|
||||||
* yellow onion" for a catalog entry that's just "Onion") needs its own,
|
|
||||||
* lighter algorithm: word-tokenize both sides, naively stem for plurals,
|
|
||||||
* then look for the catalog phrase's tokens as a contiguous run inside the
|
|
||||||
* ingredient text's tokens. The longest (most specific) matching catalog
|
|
||||||
* phrase wins, same "specificity resolves overlaps" idea as
|
|
||||||
* `matchTechSteps`, just without weights (there's no need to rank two
|
|
||||||
* *unrelated* ingredients — only a phrase against its own substrings, e.g.
|
|
||||||
* "chicken breast" beating bare "chicken").
|
|
||||||
*
|
|
||||||
* `matchIngredientName`/`matchUnit`/`extractQuantity` are pure, so they're
|
|
||||||
* unit-testable without a database (see `test/ingredient-matcher.test.ts`);
|
|
||||||
* `loadIngredientCatalog`/`loadUnitCatalog` are the only DB-touching pieces,
|
|
||||||
* meant to be fetched once per request and reused across every ingredient
|
|
||||||
* line, the same "don't requery per item" convention
|
|
||||||
* `tech-step-matcher.ts`'s `TechStepClassifierService` follows for its own
|
|
||||||
* one-time training pass.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its matching label in whichever locale it was loaded for. */
|
|
||||||
export interface IngredientMatchEntry {
|
|
||||||
ingredientId: number;
|
|
||||||
/** Matching label — English (`INGREDIENT_LABELS_EN`) or French (`INGREDIENT_LABELS_FR`) depending on which locale {@link loadIngredientCatalog} was called with, e.g. `"Chicken breast"`/`"Blanc de poulet"` — matched against free text, never displayed. */
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted spellings in whichever locale it was loaded for. */
|
|
||||||
export interface UnitMatchEntry {
|
|
||||||
unitId: number;
|
|
||||||
/** Accepted spellings — English (`UNIT_LABELS_EN`) or French (`UNIT_LABELS_FR`), e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]` or `["cuillère à soupe", "cas", ...]`. Unlike English, a French entry can be genuinely multi-word — see `matchUnit`'s own doc comment. */
|
|
||||||
synonyms: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Naive English stemmer — strips a plural suffix so "tomato"/"tomatoes",
|
|
||||||
* "onion"/"onions", "cherry"/"cherries" compare equal after stemming.
|
|
||||||
* Deliberately not a real linguistic stemmer: it only has to be
|
|
||||||
* *consistent* (the same word always stems the same way) since both sides
|
|
||||||
* of every comparison go through it, not linguistically correct on its
|
|
||||||
* own — see the module doc comment.
|
|
||||||
*/
|
|
||||||
function stemWordEn(word: string): string {
|
|
||||||
if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
|
|
||||||
if (word.endsWith("es") && word.length > 3) return word.slice(0, -2);
|
|
||||||
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
|
|
||||||
return word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Naive French stemmer — French regular plurals are overwhelmingly just
|
|
||||||
* "+s" on the singular (`"carotte"`/`"carottes"`, `"pomme"`/`"pommes"`),
|
|
||||||
* unlike English's several suffix patterns, so this only strips a single
|
|
||||||
* trailing "s". Deliberately *not* {@link stemWordEn}'s `"es"` rule reused
|
|
||||||
* here: applying it to French would silently corrupt any word whose
|
|
||||||
* singular itself ends in "e" plus a consonant before the final "s" — e.g.
|
|
||||||
* `"carottes"` would wrongly stem to `"carott"` (dropping the "e" that's
|
|
||||||
* actually part of the singular `"carotte"`) instead of `"carotte"`,
|
|
||||||
* exactly the class of near-miss that made ingredient matching
|
|
||||||
* French-locale silently broken before this stemmer existed (almost every
|
|
||||||
* regular French plural ends in "es" this way — it's not an edge case).
|
|
||||||
* Irregular plurals (`"cheval"`/`"chevaux"`, `"chou"`/`"choux"`) aren't
|
|
||||||
* handled — same "consistent, not linguistically perfect" trade-off as
|
|
||||||
* {@link stemWordEn}.
|
|
||||||
*/
|
|
||||||
function stemWordFr(word: string): string {
|
|
||||||
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
|
|
||||||
return word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dispatches to {@link stemWordEn}/{@link stemWordFr} by `locale` — any locale other than `"fr"` uses the English rules (the long-standing default, unchanged for every existing caller that doesn't pass a locale at all). */
|
|
||||||
function stemWord(word: string, locale: string): string {
|
|
||||||
return locale === "fr" ? stemWordFr(word) : stemWordEn(word);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lowercases, strips diacritics (via `normalizeText`) and splits `text` into stemmed word tokens — empty/non-letter runs are dropped, not kept as empty tokens. `locale` picks the stemming rules (see {@link stemWord}); defaults to `"en"`, the original behavior every pre-existing caller still gets without passing one. */
|
|
||||||
function tokenize(text: string, locale = "en"): string[] {
|
|
||||||
return normalizeText(text)
|
|
||||||
.split(/[^a-z]+/)
|
|
||||||
.filter((word) => word.length > 0)
|
|
||||||
.map((word) => stemWord(word, locale));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
|
|
||||||
function containsSubsequence(haystack: string[], needle: string[]): boolean {
|
|
||||||
if (needle.length === 0 || needle.length > haystack.length) return false;
|
|
||||||
for (let start = 0; start <= haystack.length - needle.length; start++) {
|
|
||||||
if (needle.every((word, i) => haystack[start + i] === word)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One stemmed word from {@link tokenizeWithOffsets}, alongside its `[start, end)` span in the *original* (un-normalized) text it came from. */
|
|
||||||
interface OffsetToken {
|
|
||||||
word: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Matches a run of letters (any script, diacritics included) — the same "word" unit {@link tokenize} splits `normalizeText`'d text on (`/[^a-z]+/`), applied here directly to the *original* text instead so each token keeps its real character offsets. Digits/punctuation are never part of a run, same separator role they play for `tokenize` (a leading quantity is `extractQuantity`'s job, not this module's word-tokenizer's). */
|
|
||||||
const LETTER_RUN_PATTERN = /\p{L}+/gu;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link tokenize}'s positional twin: same stemmed/normalized words, but
|
|
||||||
* each one keeps the `[start, end)` span it occupies in `text` — needed by
|
|
||||||
* {@link findIngredientMentions} to report *where* a mention is, not just
|
|
||||||
* that the catalog has a matching label somewhere. Splitting the original
|
|
||||||
* text into letter-runs first (rather than normalizing the whole string up
|
|
||||||
* front, the way `tokenize` does, then losing track of offsets) works
|
|
||||||
* safely here because `normalizeText` only ever rewrites a character's own
|
|
||||||
* form (case/diacritics) — see `_DiacriticsNormalizer`'s doc comment on the
|
|
||||||
* Python side, ported from the same guarantee — never merges or splits
|
|
||||||
* words, so normalizing one already-isolated run in place can't shift its
|
|
||||||
* boundaries relative to the un-normalized text.
|
|
||||||
*/
|
|
||||||
function tokenizeWithOffsets(text: string, locale = "en"): OffsetToken[] {
|
|
||||||
const tokens: OffsetToken[] = [];
|
|
||||||
for (const match of text.matchAll(LETTER_RUN_PATTERN)) {
|
|
||||||
const raw = match[0];
|
|
||||||
const start = match.index ?? 0;
|
|
||||||
const word = stemWord(normalizeText(raw), locale);
|
|
||||||
if (word.length === 0) continue;
|
|
||||||
tokens.push({ word, start, end: start + raw.length });
|
|
||||||
}
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches a quantity (integer/decimal/fraction/mixed number, same shapes as
|
|
||||||
* {@link extractQuantity}) immediately followed by an optional unit
|
|
||||||
* word/phrase (up to three words, e.g. "cuillères à soupe") and an optional
|
|
||||||
* connector ("de"/"d'"/"of"/"a"/"an"), anchored at the *end* of whatever
|
|
||||||
* string it's tested against (`$`) rather than the start. Anchoring at the
|
|
||||||
* end — not the start — is what lets {@link findQuantityBeforeIngredient}
|
|
||||||
* test the *whole* text preceding a mention without first having to guess
|
|
||||||
* where an unrelated preamble ("ajouter", "puis", an earlier sentence) ends
|
|
||||||
* and the quantity phrase begins: whatever doesn't fit the pattern
|
|
||||||
* immediately before the ingredient simply isn't part of the match, no
|
|
||||||
* separate boundary-finding step needed.
|
|
||||||
*/
|
|
||||||
const QUANTITY_BEFORE_INGREDIENT_PATTERN =
|
|
||||||
/(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:[.,]\d+)?)\s*((?:\p{L}+\s+){0,2}\p{L}*)\s*(?:de\s|d['’]|of\s|a\s|an\s)?$/u;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort quantity+unit lookup for an ingredient mention {@link findIngredientMentions}
|
|
||||||
* just found at `mentionStart` in `text` — looks *only* at what immediately
|
|
||||||
* precedes the mention (see {@link QUANTITY_BEFORE_INGREDIENT_PATTERN}), the
|
|
||||||
* dominant French/English recipe phrasing ("200g de beurre", "2 cuillères à
|
|
||||||
* soupe d'huile", "3 œufs"). Both `null` when nothing recognizable precedes
|
|
||||||
* it (no leading digit at all) — same "no match, not an error" posture as
|
|
||||||
* {@link extractQuantity}. Doesn't detect a quantity that *follows* its
|
|
||||||
* ingredient ("du beurre, 50g") — an accepted gap, same trade-off
|
|
||||||
* {@link extractQuantity} already documents for the leading-only case it
|
|
||||||
* was built for.
|
|
||||||
*/
|
|
||||||
function findQuantityBeforeIngredient(
|
|
||||||
text: string,
|
|
||||||
mentionStart: number,
|
|
||||||
unitCatalog: UnitMatchEntry[],
|
|
||||||
locale: string,
|
|
||||||
): { quantity: number | null; unitId: number | null } {
|
|
||||||
const match = QUANTITY_BEFORE_INGREDIENT_PATTERN.exec(text.slice(0, mentionStart));
|
|
||||||
if (!match) return { quantity: null, unitId: null };
|
|
||||||
const { quantity } = extractQuantity(match[1] ?? "");
|
|
||||||
const unitId = matchUnit(match[2] ?? "", unitCatalog, locale);
|
|
||||||
return { quantity, unitId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One ingredient mention {@link findIngredientMentions} found in a free-text clause, alongside its `[start, end)` span (same convention as `TechStepMatch`, `tech-step-matcher.ts`) and any quantity+unit resolved immediately before it (see {@link findQuantityBeforeIngredient}) — both `null` when the clause names the ingredient with no quantity ("ajouter le sel"). */
|
|
||||||
export interface IngredientMention {
|
|
||||||
ingredientId: number;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
quantity: number | null;
|
|
||||||
unitId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scans `text` (typically one technique's clause, see `tech-step-matcher.ts`'s
|
|
||||||
* `splitIntoClauses`) for every mention of a catalog ingredient, left to
|
|
||||||
* right, non-overlapping — the free-text-*scanning* counterpart to
|
|
||||||
* {@link matchIngredientName} (which resolves one *already-isolated*
|
|
||||||
* ingredient-line string to a single winner, not several mentions spread
|
|
||||||
* across a longer text). Same "longest catalog label wins" rule as
|
|
||||||
* {@link matchIngredientName}, applied at every token position in turn: once
|
|
||||||
* a mention is found, scanning resumes right after it rather than
|
|
||||||
* considering a shorter label starting inside an already-matched longer one.
|
|
||||||
*
|
|
||||||
* `locale` must match whatever `ingredientCatalog`/`unitCatalog` were loaded
|
|
||||||
* in (see {@link loadIngredientCatalog}/{@link loadUnitCatalog}) — defaults
|
|
||||||
* to `"en"`, same as every other function in this module.
|
|
||||||
*/
|
|
||||||
export function findIngredientMentions(
|
|
||||||
text: string,
|
|
||||||
ingredientCatalog: IngredientMatchEntry[],
|
|
||||||
unitCatalog: UnitMatchEntry[],
|
|
||||||
locale = "en",
|
|
||||||
): IngredientMention[] {
|
|
||||||
const tokens = tokenizeWithOffsets(text, locale);
|
|
||||||
if (tokens.length === 0) return [];
|
|
||||||
|
|
||||||
const candidates = ingredientCatalog
|
|
||||||
.map((entry) => ({
|
|
||||||
ingredientId: entry.ingredientId,
|
|
||||||
labelTokens: tokenize(entry.label, locale),
|
|
||||||
}))
|
|
||||||
.filter((entry) => entry.labelTokens.length > 0);
|
|
||||||
|
|
||||||
const mentions: IngredientMention[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < tokens.length) {
|
|
||||||
let best: { ingredientId: number; tokenCount: number } | null = null;
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const { labelTokens } = candidate;
|
|
||||||
if (i + labelTokens.length > tokens.length) continue;
|
|
||||||
const matches = labelTokens.every((word, offset) => tokens[i + offset]?.word === word);
|
|
||||||
if (!matches) continue;
|
|
||||||
if (
|
|
||||||
best === null ||
|
|
||||||
labelTokens.length > best.tokenCount ||
|
|
||||||
(labelTokens.length === best.tokenCount && candidate.ingredientId < best.ingredientId)
|
|
||||||
) {
|
|
||||||
best = { ingredientId: candidate.ingredientId, tokenCount: labelTokens.length };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (best === null) {
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const startToken = tokens[i];
|
|
||||||
const endToken = tokens[i + best.tokenCount - 1];
|
|
||||||
if (startToken === undefined || endToken === undefined) {
|
|
||||||
// Unreachable — `best` was only ever set above after confirming
|
|
||||||
// `i + labelTokens.length <= tokens.length`, so both tokens exist.
|
|
||||||
// Satisfies `noUncheckedIndexedAccess`.
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const { quantity, unitId } = findQuantityBeforeIngredient(
|
|
||||||
text,
|
|
||||||
startToken.start,
|
|
||||||
unitCatalog,
|
|
||||||
locale,
|
|
||||||
);
|
|
||||||
mentions.push({
|
|
||||||
ingredientId: best.ingredientId,
|
|
||||||
start: startToken.start,
|
|
||||||
end: endToken.end,
|
|
||||||
quantity,
|
|
||||||
unitId,
|
|
||||||
});
|
|
||||||
i += best.tokenCount;
|
|
||||||
}
|
|
||||||
return mentions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves free-text `name` (a `ParsedRecipeIngredient.name`, e.g. `"large
|
|
||||||
* diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or
|
|
||||||
* `null` if nothing matches. Among every catalog entry whose label's words
|
|
||||||
* all appear as a contiguous run in `name`, **in the same order**, the one
|
|
||||||
* with the most words wins (most specific — "chicken breast" over bare
|
|
||||||
* "chicken"); ties break on the lowest `ingredientId`, for a deterministic
|
|
||||||
* result independent of catalog order. `locale` must match whatever
|
|
||||||
* `catalog`'s labels were loaded in (see {@link loadIngredientCatalog}) —
|
|
||||||
* defaults to `"en"`.
|
|
||||||
*/
|
|
||||||
export function matchIngredientName(
|
|
||||||
name: string,
|
|
||||||
catalog: IngredientMatchEntry[],
|
|
||||||
locale = "en",
|
|
||||||
): number | null {
|
|
||||||
const nameTokens = tokenize(name, locale);
|
|
||||||
if (nameTokens.length === 0) return null;
|
|
||||||
|
|
||||||
let best: { ingredientId: number; tokenCount: number } | null = null;
|
|
||||||
for (const entry of catalog) {
|
|
||||||
const labelTokens = tokenize(entry.label, locale);
|
|
||||||
if (!containsSubsequence(nameTokens, labelTokens)) continue;
|
|
||||||
if (
|
|
||||||
best === null ||
|
|
||||||
labelTokens.length > best.tokenCount ||
|
|
||||||
(labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId)
|
|
||||||
) {
|
|
||||||
best = {
|
|
||||||
ingredientId: entry.ingredientId,
|
|
||||||
tokenCount: labelTokens.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best?.ingredientId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`, `"cuillères à
|
|
||||||
* soupe de farine"`) to the best-matching `Unit` in `catalog`, or `null` if
|
|
||||||
* nothing matches. Same ordered-contiguous-run search as
|
|
||||||
* {@link matchIngredientName}, longest match wins — **not** the
|
|
||||||
* single-first-word equality check this function used before French
|
|
||||||
* support existed: every English unit synonym happens to be one word, so
|
|
||||||
* comparing only `unitText`'s first token against each *whole* synonym
|
|
||||||
* string used to be enough, but a French unit can be genuinely multi-word
|
|
||||||
* (`"cuillère à soupe"`, see `UNIT_LABELS_FR`) — a whole multi-word phrase
|
|
||||||
* (spaces and all) can never equal a single extracted token, so that
|
|
||||||
* approach would have silently matched nothing for any French unit
|
|
||||||
* requiring more than one word. `locale` must match whatever `catalog`'s
|
|
||||||
* synonyms were loaded in (see {@link loadUnitCatalog}) — defaults to
|
|
||||||
* `"en"`.
|
|
||||||
*/
|
|
||||||
export function matchUnit(
|
|
||||||
unitText: string,
|
|
||||||
catalog: UnitMatchEntry[],
|
|
||||||
locale = "en",
|
|
||||||
): number | null {
|
|
||||||
const textTokens = tokenize(unitText, locale);
|
|
||||||
if (textTokens.length === 0) return null;
|
|
||||||
|
|
||||||
let best: { unitId: number; tokenCount: number } | null = null;
|
|
||||||
for (const entry of catalog) {
|
|
||||||
for (const synonym of entry.synonyms) {
|
|
||||||
const synonymTokens = tokenize(synonym, locale);
|
|
||||||
if (!containsSubsequence(textTokens, synonymTokens)) continue;
|
|
||||||
if (
|
|
||||||
best === null ||
|
|
||||||
synonymTokens.length > best.tokenCount ||
|
|
||||||
(synonymTokens.length === best.tokenCount && entry.unitId < best.unitId)
|
|
||||||
) {
|
|
||||||
best = { unitId: entry.unitId, tokenCount: synonymTokens.length };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best?.unitId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
|
|
||||||
export interface ExtractedQuantity {
|
|
||||||
quantity: number | null;
|
|
||||||
/** `rawText` with the leading quantity (and any separating whitespace) removed — `rawText` unchanged if none was found. */
|
|
||||||
remainder: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leading "1 1/2", "1/2", "1.5", "1,5" or "2" (optionally followed by a
|
|
||||||
// hyphenated range like "2-3", in which case only the first number counts —
|
|
||||||
// good enough for a best-effort quantity, not meant to model ranges. The
|
|
||||||
// `[.,]` decimal separator already covers French recipe text ("1,5") as-is,
|
|
||||||
// same pattern used for English ("1.5") — no locale-specific handling
|
|
||||||
// needed here, unlike tokenize/stemWord above.
|
|
||||||
const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls a leading quantity off `rawText` (e.g. `"1 1/2 cups flour"` ->
|
|
||||||
* `{ quantity: 1.5, remainder: "cups flour" }`), supporting a plain
|
|
||||||
* integer/decimal, a simple fraction (`"1/2"`), or a mixed number
|
|
||||||
* (`"1 1/2"`). `quantity: null` (remainder === rawText, trimmed) when
|
|
||||||
* `rawText` doesn't start with a recognizable number — e.g. `"salt to
|
|
||||||
* taste"`, which has none.
|
|
||||||
*/
|
|
||||||
export function extractQuantity(rawText: string): ExtractedQuantity {
|
|
||||||
const trimmed = rawText.trim();
|
|
||||||
const match = LEADING_QUANTITY_PATTERN.exec(trimmed);
|
|
||||||
if (!match) return { quantity: null, remainder: trimmed };
|
|
||||||
|
|
||||||
let quantity: number;
|
|
||||||
if (match[1] !== undefined) {
|
|
||||||
// Mixed number: "1 1/2".
|
|
||||||
quantity = Number(match[1]) + Number(match[2]) / Number(match[3]);
|
|
||||||
} else if (match[4] !== undefined) {
|
|
||||||
// Simple fraction: "1/2".
|
|
||||||
quantity = Number(match[4]) / Number(match[5]);
|
|
||||||
} else {
|
|
||||||
// Plain integer/decimal: "2" or "1.5"/"1,5" — group 6 is guaranteed
|
|
||||||
// defined here (the only remaining alternative in the pattern), `?? ""`
|
|
||||||
// is just satisfying the indexed-access type, not a real fallback.
|
|
||||||
quantity = Number((match[6] ?? "").replace(",", "."));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { quantity, remainder: trimmed.slice(match[0].length).trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-locale matching label tables {@link loadIngredientCatalog}/
|
|
||||||
* {@link loadUnitCatalog} pick from — the only two locales with any
|
|
||||||
* matching data authored yet (see `packages/shared/src/data/`). A locale
|
|
||||||
* with no entry here (anything but `"en"`/`"fr"`) falls back to empty
|
|
||||||
* tables in both loaders below — the same "no matching-language data,
|
|
||||||
* degrade to doing nothing rather than guess" behavior `tech-step-matcher.ts`
|
|
||||||
* already has for a locale with no trained mappings, not a thrown error.
|
|
||||||
*/
|
|
||||||
const INGREDIENT_LABELS_BY_LOCALE: Record<string, Record<string, string>> = {
|
|
||||||
en: INGREDIENT_LABELS_EN,
|
|
||||||
fr: INGREDIENT_LABELS_FR,
|
|
||||||
};
|
|
||||||
const INGREDIENT_LABEL_SYNONYMS_BY_LOCALE: Record<string, Record<string, string[]>> = {
|
|
||||||
en: INGREDIENT_LABEL_SYNONYMS_EN,
|
|
||||||
fr: INGREDIENT_LABEL_SYNONYMS_FR,
|
|
||||||
};
|
|
||||||
const UNIT_LABELS_BY_LOCALE: Record<string, Record<string, string[]>> = {
|
|
||||||
en: UNIT_LABELS_EN,
|
|
||||||
fr: UNIT_LABELS_FR,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s for `locale` (default `"en"`) — one entry per key with an authored label in that locale, plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`/`_FR`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no label in `locale` yet is silently skipped, never a matching target — same for every ingredient when `locale` itself has no label table at all (see {@link INGREDIENT_LABELS_BY_LOCALE}). Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
|
|
||||||
export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMatchEntry[]> {
|
|
||||||
try {
|
|
||||||
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
|
|
||||||
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
|
|
||||||
const ingredients = await prisma.ingredient.findMany({
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
const catalog: IngredientMatchEntry[] = [];
|
|
||||||
for (const ingredient of ingredients) {
|
|
||||||
const label = labels[ingredient.key];
|
|
||||||
if (label === undefined) continue;
|
|
||||||
catalog.push({ ingredientId: ingredient.id, label });
|
|
||||||
for (const synonym of synonyms[ingredient.key] ?? []) {
|
|
||||||
catalog.push({ ingredientId: ingredient.id, label: synonym });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return catalog;
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — the caller (`sources.service.ts`/`recipe-translation.ts`)
|
|
||||||
// already handles/logs failures centrally; this function just isn't
|
|
||||||
// allowed a bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s for `locale` (default `"en"`) — one entry per key with authored synonyms in that locale (see {@link UNIT_LABELS_BY_LOCALE}); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */
|
|
||||||
export async function loadUnitCatalog(locale = "en"): Promise<UnitMatchEntry[]> {
|
|
||||||
try {
|
|
||||||
const labels = UNIT_LABELS_BY_LOCALE[locale] ?? {};
|
|
||||||
const units = await prisma.unit.findMany({
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
const catalog: UnitMatchEntry[] = [];
|
|
||||||
for (const unit of units) {
|
|
||||||
const synonyms = labels[unit.key];
|
|
||||||
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
|
||||||
}
|
|
||||||
return catalog;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadIngredientCatalog()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { env } from "../../config/env.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin fetch wrapper around `services/tech-step-intent-service`'s HTTP
|
|
||||||
* contract (`POST /v1/process`) — the microservice
|
|
||||||
* {@link TechStepClassifierService} (`tech-step-matcher.ts`) delegates NER +
|
|
||||||
* intent classification to, in place of the `node-nlp` `NlpManager` it used
|
|
||||||
* to own directly. See that service's own README for the full contract and
|
|
||||||
* why it never touches Postgres itself — it also owns its own training
|
|
||||||
* corpus now (`training_data.py`), trained once at its own startup, so
|
|
||||||
* `apps/api` never pushes anything to it; `process()` below is this
|
|
||||||
* client's only method.
|
|
||||||
*
|
|
||||||
* Authenticated with `INTENT_SERVICE_SECRET` — the inverse direction of
|
|
||||||
* `requireInternalWorker`'s `INTERNAL_WORKER_SECRET` (this time `apps/api`
|
|
||||||
* is the caller, not the callee), but the same "one flat shared secret"
|
|
||||||
* shape.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** One candidate mention one of the service's two `PhraseMatcher`s found — offsets `[start, end)`, same convention as `String.prototype.slice`. Mirrors `EntityPayload` (Python `schemas.py`). `kind` distinguishes a technique mention (`self._matcher`, the corpus-trained one) from a utensil mention (`self._utensil_matcher`, static — see `utensil_vocabulary.py`) — `tech-step-matcher.ts` resolves each against a different catalog (`TechStep`/`Utensil`). */
|
|
||||||
export interface IntentServiceEntity {
|
|
||||||
uid: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
kind: "technique" | "utensil";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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();
|
|
||||||
|
|
@ -1,331 +0,0 @@
|
||||||
import type { UnitType } from "@batch-cooking/shared";
|
|
||||||
import type {
|
|
||||||
ParsedRecipe,
|
|
||||||
ParsedRecipeIngredient,
|
|
||||||
ParsedRecipeStep,
|
|
||||||
} from "../recipe-sources/recipe-source-adapter.js";
|
|
||||||
import {
|
|
||||||
extractQuantity,
|
|
||||||
type IngredientMatchEntry,
|
|
||||||
loadIngredientCatalog,
|
|
||||||
loadUnitCatalog,
|
|
||||||
matchIngredientName,
|
|
||||||
matchUnit,
|
|
||||||
type UnitMatchEntry,
|
|
||||||
} from "./ingredient-matcher.js";
|
|
||||||
import { techStepClassifier } from "./tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "Traduction en étapes" stage of the import pipeline described in
|
|
||||||
* specs/batch-cooking-architecture.md (Import depuis source → **Traduction
|
|
||||||
* en étapes** → Sauvegarde) — takes a source-agnostic {@link ParsedRecipe}
|
|
||||||
* (recipe-source-adapter.ts's `parse()` output) and declares each step's
|
|
||||||
* technique sequence, the same `techStepIds: number[]` shape
|
|
||||||
* `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
|
|
||||||
*
|
|
||||||
* Also resolves ingredients — matching each free-text `ParsedRecipeIngredient`
|
|
||||||
* line against our `Ingredient`/`Unit` catalogs (`ingredient-matcher.ts`),
|
|
||||||
* the same "free source text -> our catalog id" idea as tech-step
|
|
||||||
* detection, just for ingredients/units/quantities instead of technique
|
|
||||||
* verbs. Like tech-step matching, this doesn't turn the result into a
|
|
||||||
* saveable `Recipe` either (no `dietIds`/`visibility`/author, a source
|
|
||||||
* can't know those) — this is one step of the pipeline, not the whole
|
|
||||||
* thing.
|
|
||||||
*
|
|
||||||
* `translateRecipeIngredients` stays pure (takes its matching data as plain
|
|
||||||
* arguments, same convention `matchIngredientName` itself has) so it's
|
|
||||||
* unit-testable without a database. `translateRecipeSteps` no longer is —
|
|
||||||
* technique detection now goes through `techStepClassifier`'s trained
|
|
||||||
* model (`tech-step-matcher.ts`), which needs an async call — but is still
|
|
||||||
* exported separately from `translateRecipe` for callers/tests that only
|
|
||||||
* care about step translation, not ingredients too.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */
|
|
||||||
export interface TranslatedRecipeStep extends ParsedRecipeStep {
|
|
||||||
/** Ordered sequence of detected `TechStep` ids (see `TechStepClassifierService.matchTechSteps`) — empty if this step doesn't mention any known technique. */
|
|
||||||
techStepIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A {@link ParsedRecipeIngredient}, after ingredient/unit matching — `quantity` is filled in from `rawText` when the source itself left it `null` (see `extractQuantity`); `ingredientId`/`unitId` are `null` when nothing in the catalog matched. */
|
|
||||||
export interface TranslatedRecipeIngredient extends ParsedRecipeIngredient {
|
|
||||||
ingredientId: number | null;
|
|
||||||
unitId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A {@link ParsedRecipe} whose `steps`/`ingredients` have been translated — everything else (name, portions, …) passes through unchanged. */
|
|
||||||
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps" | "ingredients"> {
|
|
||||||
steps: TranslatedRecipeStep[];
|
|
||||||
ingredients: TranslatedRecipeIngredient[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Declares each of `recipe`'s steps' technique sequence for `locale`,
|
|
||||||
* leaving everything else about the recipe untouched — including
|
|
||||||
* ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
|
|
||||||
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
|
|
||||||
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
|
|
||||||
* separate the same way tech-step and ingredient matching are two
|
|
||||||
* independent concerns everywhere else in this module. Async — technique
|
|
||||||
* detection now runs against `techStepClassifier`'s trained model rather
|
|
||||||
* than a caller-supplied mapping list (see `tech-step-matcher.ts`), so this
|
|
||||||
* can no longer stay a plain synchronous function the way it used to.
|
|
||||||
*/
|
|
||||||
export async function translateRecipeSteps(
|
|
||||||
recipe: ParsedRecipe,
|
|
||||||
locale: string,
|
|
||||||
): Promise<TranslatedRecipe> {
|
|
||||||
try {
|
|
||||||
const steps = await Promise.all(
|
|
||||||
recipe.steps.map(async (step) => ({
|
|
||||||
...step,
|
|
||||||
techStepIds: await techStepClassifier.matchTechSteps(step.description, locale),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...recipe,
|
|
||||||
ingredients: recipe.ingredients.map((ingredient) => ({
|
|
||||||
...ingredient,
|
|
||||||
ingredientId: null,
|
|
||||||
unitId: null,
|
|
||||||
})),
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — the caller (`sources.service.ts`) already
|
|
||||||
// handles/logs failures centrally; this function just isn't allowed a
|
|
||||||
// bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locale-specific label {@link matchUnit} is fed when a quantity was found
|
|
||||||
* but no unit word was — see the `unitId` fallback below. Each is the
|
|
||||||
* catalog's generic "counted, no further unit" entry (`Unit.key` `"piece"`)
|
|
||||||
* in that locale's own label table (`UNIT_LABELS_EN`/`UNIT_LABELS_FR`,
|
|
||||||
* `packages/shared`). A locale with neither entry (anything but
|
|
||||||
* `"en"`/`"fr"`) falls back to the English spelling — harmless, since
|
|
||||||
* `unitCatalog` itself is already empty for an unsupported locale (see
|
|
||||||
* `loadUnitCatalog`), so this fallback lookup finds nothing either way.
|
|
||||||
*/
|
|
||||||
const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
|
|
||||||
en: "piece",
|
|
||||||
fr: "unité",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
|
|
||||||
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
|
|
||||||
* Pure — testable with hand-built catalogs, no database involved (see
|
|
||||||
* `translateRecipe` for the DB-backed loader). `quantity` falls back to
|
|
||||||
* `extractQuantity(rawText)` only when the source itself left it `null`;
|
|
||||||
* same for `unit` falling back to `extractQuantity`'s `remainder` before
|
|
||||||
* being matched against `unitCatalog` — a source that already states a
|
|
||||||
* clean unit/quantity is trusted over re-deriving it from `rawText`.
|
|
||||||
*
|
|
||||||
* When a quantity was found but nothing in the remaining text matched a
|
|
||||||
* unit (e.g. `"4 Egg Yolks"` — quantity `4`, remainder `"Egg Yolks"`, no
|
|
||||||
* unit word anywhere in it), `unitId` falls back to the catalog's generic
|
|
||||||
* `piece` unit rather than staying `null`: a bare count with no explicit
|
|
||||||
* unit word is overwhelmingly "N of them" (eggs, onions, cloves not
|
|
||||||
* spelled out as "clove") in practice, not a genuinely missing unit — see
|
|
||||||
* issue #53, where this previously left the import review form's submit
|
|
||||||
* button disabled with no indication why on almost any recipe with a
|
|
||||||
* whole-item ingredient. No fallback when `quantity` itself is `null`
|
|
||||||
* (e.g. `"To taste"`) — there's nothing to count, so nothing to default.
|
|
||||||
*
|
|
||||||
* `locale` (default `"en"`, matching every pre-existing caller) must agree
|
|
||||||
* with whichever locale `ingredientCatalog`/`unitCatalog` were loaded in
|
|
||||||
* (see `loadIngredientCatalog`/`loadUnitCatalog`) — it's threaded through to
|
|
||||||
* `matchIngredientName`/`matchUnit` for stemming, and picks the right
|
|
||||||
* spelling of the "piece" fallback below.
|
|
||||||
*/
|
|
||||||
export function translateRecipeIngredients(
|
|
||||||
ingredients: ParsedRecipeIngredient[],
|
|
||||||
ingredientCatalog: IngredientMatchEntry[],
|
|
||||||
unitCatalog: UnitMatchEntry[],
|
|
||||||
locale = "en",
|
|
||||||
): TranslatedRecipeIngredient[] {
|
|
||||||
const fallbackCountUnitLabel = FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE[locale] ?? "piece";
|
|
||||||
return ingredients.map((ingredient) => {
|
|
||||||
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog, locale);
|
|
||||||
const extracted = extractQuantity(ingredient.rawText);
|
|
||||||
const quantity = ingredient.quantity ?? extracted.quantity;
|
|
||||||
const unitText = ingredient.unit ?? extracted.remainder;
|
|
||||||
const unitId =
|
|
||||||
matchUnit(unitText, unitCatalog, locale) ??
|
|
||||||
(quantity !== null ? matchUnit(fallbackCountUnitLabel, unitCatalog, locale) : null);
|
|
||||||
return { ...ingredient, quantity, ingredientId, unitId };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What {@link mergeDuplicateIngredients} needs to know about a `Unit` to combine two of them — the `type`/`toBaseFactor` slice of `UnitView` (`packages/shared`). */
|
|
||||||
export interface UnitConversionEntry {
|
|
||||||
id: number;
|
|
||||||
type: UnitType;
|
|
||||||
toBaseFactor: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combines `a`/`b` — two lines already confirmed to resolve to the same
|
|
||||||
* ingredient — into one, summing their quantities, or returns `null` when
|
|
||||||
* that can't be done safely. `null` (quantity or unit missing on either
|
|
||||||
* side, unit not found in `unitById`, mismatched `UnitType`, or either side
|
|
||||||
* a `COUNT` unit) means "don't merge", not "error" — see
|
|
||||||
* {@link mergeDuplicateIngredients}.
|
|
||||||
*
|
|
||||||
* Same unit on both sides sums directly. Different units of the same
|
|
||||||
* measurable *type* (`MASS`/`VOLUME`) convert `b`'s quantity into `a`'s
|
|
||||||
* unit via `toBaseFactor` first — the groundwork that field's own doc
|
|
||||||
* comment (`UnitView`, `packages/shared`) already anticipated ("a future
|
|
||||||
* conversion feature ... summing '500g' + '0.5kg'"). `COUNT` units are
|
|
||||||
* never converted against each other even when `toBaseFactor` matches — a
|
|
||||||
* "pincée" isn't a fixed fraction of a "gousse" (same doc comment) — so two
|
|
||||||
* different `COUNT` units for the same ingredient are left unmerged.
|
|
||||||
* Rounded to 2 decimal places (`RecipeIngredient.quantity` is
|
|
||||||
* `Decimal(10, 2)`, schema.prisma) to avoid floating-point noise from the
|
|
||||||
* conversion.
|
|
||||||
*/
|
|
||||||
function combineIngredientLines(
|
|
||||||
a: TranslatedRecipeIngredient,
|
|
||||||
b: TranslatedRecipeIngredient,
|
|
||||||
unitById: Map<number, UnitConversionEntry>,
|
|
||||||
): TranslatedRecipeIngredient | null {
|
|
||||||
if (a.quantity === null || b.quantity === null || a.unitId === null || b.unitId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (a.unitId === b.unitId) {
|
|
||||||
return {
|
|
||||||
...a,
|
|
||||||
quantity: a.quantity + b.quantity,
|
|
||||||
rawText: `${a.rawText} + ${b.rawText}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const unitA = unitById.get(a.unitId);
|
|
||||||
const unitB = unitById.get(b.unitId);
|
|
||||||
if (!unitA || !unitB) return null;
|
|
||||||
if (unitA.type !== unitB.type || unitA.type === "COUNT") return null;
|
|
||||||
|
|
||||||
const combinedInBaseUnit = a.quantity * unitA.toBaseFactor + b.quantity * unitB.toBaseFactor;
|
|
||||||
const quantity = Math.round((combinedInBaseUnit / unitA.toBaseFactor) * 100) / 100;
|
|
||||||
return { ...a, quantity, rawText: `${a.rawText} + ${b.rawText}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Folds `ingredients` down to one line per resolved `ingredientId`,
|
|
||||||
* concatenating (summing the quantity of) every duplicate into the first
|
|
||||||
* line it matches — see issue #53's follow-up: two raw source lines (e.g.
|
|
||||||
* TheMealDB's "Egg Yolks"/"Eggs", or "100g Sugar" used in two different
|
|
||||||
* steps) can independently resolve to the same catalog `Ingredient`, and
|
|
||||||
* `RecipeIngredient`'s primary key (`recipeId`, `ingredientId`) only
|
|
||||||
* allows one row per ingredient per recipe — the review form used to
|
|
||||||
* either crash on submit (before `createRecipeSchema` rejected it) or
|
|
||||||
* require the person to manually delete every extra line by hand.
|
|
||||||
*
|
|
||||||
* Unresolved lines (`ingredientId: null`) are never merged with one
|
|
||||||
* another or with anything else — nothing reliable to key them on. Two
|
|
||||||
* lines that resolve to the same ingredient but can't be combined safely
|
|
||||||
* (see {@link combineIngredientLines} — mismatched quantity/unit, or
|
|
||||||
* genuinely incompatible units) are left as separate, still-duplicate
|
|
||||||
* lines: `createRecipeSchema` still rejects the result, and
|
|
||||||
* `RecipeImportForm` still highlights them, same safety net as before this
|
|
||||||
* merge step existed — merging never *invents* a number it isn't confident
|
|
||||||
* in.
|
|
||||||
*
|
|
||||||
* Pure — testable with a hand-built `unitCatalog`, no database involved.
|
|
||||||
* Order-preserving: a merged line keeps its first occurrence's position.
|
|
||||||
*/
|
|
||||||
export function mergeDuplicateIngredients(
|
|
||||||
ingredients: TranslatedRecipeIngredient[],
|
|
||||||
unitCatalog: UnitConversionEntry[],
|
|
||||||
): TranslatedRecipeIngredient[] {
|
|
||||||
const unitById = new Map(unitCatalog.map((unit) => [unit.id, unit]));
|
|
||||||
const merged: TranslatedRecipeIngredient[] = [];
|
|
||||||
const mergedIndexByIngredientId = new Map<number, number>();
|
|
||||||
|
|
||||||
for (const line of ingredients) {
|
|
||||||
const existingIndex =
|
|
||||||
line.ingredientId !== null ? mergedIndexByIngredientId.get(line.ingredientId) : undefined;
|
|
||||||
|
|
||||||
const existingLine = existingIndex !== undefined ? merged[existingIndex] : undefined;
|
|
||||||
if (existingIndex === undefined || existingLine === undefined) {
|
|
||||||
if (line.ingredientId !== null) {
|
|
||||||
mergedIndexByIngredientId.set(line.ingredientId, merged.length);
|
|
||||||
}
|
|
||||||
merged.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const combined = combineIngredientLines(existingLine, line, unitById);
|
|
||||||
if (combined === null) {
|
|
||||||
merged.push(line);
|
|
||||||
} else {
|
|
||||||
merged[existingIndex] = combined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience wrapper around {@link translateRecipeSteps}/
|
|
||||||
* {@link translateRecipeIngredients} that loads every catalog itself —
|
|
||||||
* what a caller reaches for when translating a single recipe on its own
|
|
||||||
* (e.g. the eventual "import this one recipe" endpoint). A caller
|
|
||||||
* translating many recipes at once should load the catalogs once and reuse
|
|
||||||
* them across calls instead, the same "don't requery per item" reasoning
|
|
||||||
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` already follow for
|
|
||||||
* manually-authored recipes.
|
|
||||||
*
|
|
||||||
* No user- or recipe-level language preference exists anywhere in the app
|
|
||||||
* yet (see `tech-step-matcher.ts`'s `TechStepClassifierService`) — callers
|
|
||||||
* pass a locale explicitly rather than this module guessing one. Note that
|
|
||||||
* an English-language source (e.g. TheMealDB) translated against `"fr"`
|
|
||||||
* will currently get an empty (or nonsensical) `techStepIds` sequence on
|
|
||||||
* every step — the classifier is trained per-locale, so calling it with a
|
|
||||||
* locale that doesn't match the actual text's language doesn't degrade
|
|
||||||
* gracefully, it just gets things wrong.
|
|
||||||
*
|
|
||||||
* Ingredient/unit matching has data for `"en"` and `"fr"` today
|
|
||||||
* (`INGREDIENT_LABELS_EN`/`_FR`, `UNIT_LABELS_EN`/`_FR`, `packages/shared`)
|
|
||||||
* — `loadIngredientCatalog(locale)`/`loadUnitCatalog(locale)` are always
|
|
||||||
* called, never specially skipped for a particular locale: a locale with no
|
|
||||||
* label table of its own (anything but `"en"`/`"fr"`) just gets back empty
|
|
||||||
* catalogs from those two loaders, and `translateRecipeIngredients` over an
|
|
||||||
* empty catalog naturally leaves every ingredient's `ingredientId`/`unitId`
|
|
||||||
* at the neutral `null` `translateRecipeSteps` already stubs in — the same
|
|
||||||
* "no matching-language data" degradation tech-step matching already has
|
|
||||||
* for a locale with no mappings, just arrived at by *not* special-casing
|
|
||||||
* which locales are "supported" here at all (that's `INGREDIENT_LABELS_BY_LOCALE`/
|
|
||||||
* `UNIT_LABELS_BY_LOCALE`'s job, in `ingredient-matcher.ts` — this function
|
|
||||||
* doesn't need its own copy of that list to stay in sync with).
|
|
||||||
*/
|
|
||||||
export async function translateRecipe(
|
|
||||||
recipe: ParsedRecipe,
|
|
||||||
locale: string,
|
|
||||||
): Promise<TranslatedRecipe> {
|
|
||||||
try {
|
|
||||||
const translated = await translateRecipeSteps(recipe, locale);
|
|
||||||
|
|
||||||
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
|
||||||
loadIngredientCatalog(locale),
|
|
||||||
loadUnitCatalog(locale),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
...translated,
|
|
||||||
ingredients: translateRecipeIngredients(
|
|
||||||
recipe.ingredients,
|
|
||||||
ingredientCatalog,
|
|
||||||
unitCatalog,
|
|
||||||
locale,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — the caller (`sources.service.ts`) already
|
|
||||||
// handles/logs failures centrally; this function just isn't allowed a
|
|
||||||
// bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
/**
|
|
||||||
* Hand-labeled evaluation set for {@link techStepClassifier} — what
|
|
||||||
* `tech-step-eval.test.ts` runs the real classifier against to compute
|
|
||||||
* precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any
|
|
||||||
* future change to `services/tech-step-intent-service`'s `training_data.py`
|
|
||||||
* must clear (see that module's own doc comment).
|
|
||||||
*
|
|
||||||
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
|
|
||||||
* verbatim — scoring the classifier against the exact sentences it was
|
|
||||||
* trained on would measure memorization, not generalization. Every
|
|
||||||
* description below is original phrasing; where a case still needs to name
|
|
||||||
* a technique's own verb to be labeled with confidence (most of them, see
|
|
||||||
* this file's own limits below), it's at least a different sentence shape
|
|
||||||
* than anything in the training corpus.
|
|
||||||
*
|
|
||||||
* `expectedKeys` is a multiset in reading order (see
|
|
||||||
* `TechStepEvalOutcome`'s doc comment in `tech-step-evaluator.ts` for why
|
|
||||||
* order isn't scored but repetition is) of `TechStep.key`s — resolved to
|
|
||||||
* real DB ids and back by `tech-step-eval-runner.ts`, this file only ever
|
|
||||||
* deals in stable keys so it doesn't need DB access to author or read.
|
|
||||||
*
|
|
||||||
* Known limit of this dataset, confirmed against a real run (see
|
|
||||||
* `MIN_OVERALL_F1`'s own doc comment, `tech-step-eval-runner.ts`): most
|
|
||||||
* cases anchor on a technique's own registered synonym, but `_classifyClause`
|
|
||||||
* only falls back to that anchor when the intent classifier's own score is
|
|
||||||
* *below* `CONFIDENCE_THRESHOLD` — a confidently *wrong* whole-clause
|
|
||||||
* classification (e.g. "Blanchissez les haricots verts..." scoring
|
|
||||||
* confidently as `peel` despite the correct `blanch` anchor) overrides the
|
|
||||||
* anchor just as readily as a confidently *right* one does, so this
|
|
||||||
* dataset genuinely does measure real classifier failures, not just a
|
|
||||||
* synthetic floor. A handful of such real mismatches are expected and
|
|
||||||
* intentionally left uncorrected here (see `MIN_OVERALL_F1`'s doc comment)
|
|
||||||
* — fixing the classifier's actual behavior on them is corpus work for a
|
|
||||||
* future change, not something to hide by loosening this dataset's own
|
|
||||||
* expectations to match whatever it currently outputs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TechStepEvalCase {
|
|
||||||
description: string;
|
|
||||||
locale: "fr" | "en";
|
|
||||||
expectedKeys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
|
|
||||||
// --- One straightforward case per technique (fr), covering all 26 ---
|
|
||||||
{
|
|
||||||
description: "Faites cuire les pâtes al dente dans une grande casserole d'eau bien salée.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["cook"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites bouillir l'eau dans une grande casserole avant d'y plonger les pâtes.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["boil"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Plongez les beignets dans l'huile très chaude pour les faire frire.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["fry"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites fondre le chocolat noir au bain-marie en remuant.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["melt"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Déglacez la casserole avec un trait de vinaigre balsamique.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["deglaze"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Laissez frémir la sauce tomate vingt minutes à couvert.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["simmer"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites rôtir la volaille entière sur la broche du four.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["roast"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites griller les brochettes de poulet quelques minutes de chaque côté.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["grill"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites sauter les champignons à feu vif dans une poêle très chaude.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["panFry"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Blanchissez les haricots verts trois minutes avant de les refroidir.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["blanch"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Laissez mariner les brochettes de poulet deux heures au frais.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["marinate"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Hachez grossièrement le persil frais avant de le parsemer.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["chop"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Épluchez les carottes avant de les couper en rondelles.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["peel"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Émincez finement l'échalote pour la vinaigrette.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["mince"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Mélangez la farine, le sucre et les œufs dans un grand saladier.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["mix"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Fouettez énergiquement la crème jusqu'à ce qu'elle épaississe.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["whisk"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Incorporez délicatement la farine tamisée à la préparation.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["foldIn"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Réservez la pâte au réfrigérateur pendant que vous préparez la garniture.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["setAside"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Assaisonnez le poisson avec du sel, du poivre et un filet de citron.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["season"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Égouttez soigneusement le riz dans une passoire fine.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["drain"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Faites dorer les morceaux de veau sur toutes leurs faces avant de mouiller avec le bouillon.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["brown"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Laissez reposer la viande dix minutes avant de la trancher.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["rest"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Préchauffez le four à 200 degrés avant d'y glisser le gratin.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["preheat"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Enfournez la tarte pendant trente-cinq minutes jusqu'à ce qu'elle soit dorée.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["bake"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Dressez harmonieusement les légumes autour de la pièce de viande.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["plate"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Nappez le fond du moule d'une fine couche de caramel.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["coat"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- English coverage (same technique verbs, distinct sentences) ---
|
|
||||||
{
|
|
||||||
description: "Simmer the stock gently for forty minutes, skimming occasionally.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["simmer"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Peel the potatoes and rinse them under cold water.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["peel"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Whisk the eggs with a pinch of salt until frothy.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["whisk"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Season the soup generously with black pepper before serving.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["season"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Make sure the chicken is cooked through before serving.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["cook"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Multi-technique sentences, in reading order ---
|
|
||||||
{
|
|
||||||
description: "Préchauffez le four, puis faites rôtir le poulet pendant une heure.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["preheat", "roast"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites revenir les oignons, puis déglacez la poêle avec du vin blanc.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["brown", "deglaze"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Faites cuire les légumes à la vapeur, puis assaisonnez-les avec des herbes fraîches.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["cook", "season"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Émincez l'oignon, faites-le suer, puis mouillez avec le bouillon et laissez mijoter.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["mince", "simmer"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- No technique mentioned at all ---
|
|
||||||
{
|
|
||||||
description: "Répartissez les convives autour de la table avant de commencer le repas.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Rangez les couverts propres dans le tiroir de la cuisine.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Take the dishes and glasses out of the cupboard.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Documented false-positive traps, re-verified with fresh wording ---
|
|
||||||
// `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not
|
|
||||||
// bare "brown" — precisely so this doesn't false-positive (see that
|
|
||||||
// entry's own comment in training_data.py).
|
|
||||||
{
|
|
||||||
description: "This recipe calls for two tablespoons of brown sugar.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
// `rest`'s EN synonyms are anchored phrases ("let it rest"/"resting
|
|
||||||
// for"...), not bare "rest" — so a sentence using the word in its
|
|
||||||
// "remainder" sense must not anchor `rest` at all.
|
|
||||||
{
|
|
||||||
description: "There is no time to rest before the guests arrive.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { TECH_STEP_EVAL_DATASET } from "./tech-step-eval-dataset.js";
|
|
||||||
import {
|
|
||||||
computeTechStepMetrics,
|
|
||||||
type TechStepEvalOutcome,
|
|
||||||
type TechStepEvalResult,
|
|
||||||
} from "./tech-step-evaluator.js";
|
|
||||||
import { techStepClassifier } from "./tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression floor (not a target) both `runTechStepEvalSuite`'s consumers
|
|
||||||
* gate on — exported from here (not defined separately in each consumer)
|
|
||||||
* so the CI regression gate and `scripts/retrain-tech-steps.ts`'s
|
|
||||||
* pre-backfill gate can never silently drift to different thresholds.
|
|
||||||
*
|
|
||||||
* Calibrated against a real run: the classifier trained on the corpus as
|
|
||||||
* of this constant's introduction scored **0.815** aggregate F1
|
|
||||||
* (33 TP / 9 FP / 6 FN) against `TECH_STEP_EVAL_DATASET` — `0.8` leaves a
|
|
||||||
* small margin below that for run-to-run noise while still catching a
|
|
||||||
* real regression (not a floor picked blind before ever running this
|
|
||||||
* suite — see this feature's plan document for that earlier state). The
|
|
||||||
* mismatches this run surfaced (e.g. "Blanchissez les haricots verts..."
|
|
||||||
* misclassified as `peel`, a handful of anchor-less sentences expected to
|
|
||||||
* match nothing instead scoring confidently as some technique) are real,
|
|
||||||
* known classifier weaknesses — evidence this harness is doing its job,
|
|
||||||
* not something to quietly paper over by loosening the dataset's own
|
|
||||||
* expectations. Improving them is corpus work for a future change, gated
|
|
||||||
* by this same suite.
|
|
||||||
*/
|
|
||||||
export const MIN_OVERALL_F1 = 0.8;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs {@link TECH_STEP_EVAL_DATASET} against the real, currently-trained
|
|
||||||
* `techStepClassifier` and returns the aggregate/per-technique metrics
|
|
||||||
* (`computeTechStepMetrics`, `tech-step-evaluator.ts`) — the one place this
|
|
||||||
* DB-touching "resolve ids to keys, then score" logic lives, shared by
|
|
||||||
* `test/recipe-matching/tech-step-eval.test.ts` (this feature's CI
|
|
||||||
* regression gate) and `scripts/retrain-tech-steps.ts` (the same gate, run
|
|
||||||
* by a maintainer before applying a corpus change). Kept out of
|
|
||||||
* `tech-step-evaluator.ts` itself, which is deliberately pure/DB-free (see
|
|
||||||
* that module's own doc comment) so its scoring logic stays unit-testable
|
|
||||||
* without a database.
|
|
||||||
*/
|
|
||||||
export async function runTechStepEvalSuite(): Promise<TechStepEvalResult> {
|
|
||||||
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
|
||||||
const keyById = new Map(techSteps.map((techStep) => [techStep.id, techStep.key]));
|
|
||||||
|
|
||||||
const outcomes: TechStepEvalOutcome[] = [];
|
|
||||||
for (const evalCase of TECH_STEP_EVAL_DATASET) {
|
|
||||||
const techStepIds = await techStepClassifier.matchTechSteps(
|
|
||||||
evalCase.description,
|
|
||||||
evalCase.locale,
|
|
||||||
);
|
|
||||||
const actualKeys = techStepIds.map((id) => {
|
|
||||||
const key = keyById.get(id);
|
|
||||||
// A `techStepId` the classifier resolved that isn't in the seeded
|
|
||||||
// catalog would be a bug in the classifier or the seed data, not
|
|
||||||
// this dataset — fail loudly rather than silently dropping it (see
|
|
||||||
// `_train`'s own comment in `tech-step-matcher.ts` on the
|
|
||||||
// equivalent, deliberately silent `undefined` case it has to
|
|
||||||
// tolerate for a different reason).
|
|
||||||
if (key === undefined) {
|
|
||||||
throw new Error(`Unknown TechStep id ${id} returned for "${evalCase.description}"`);
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
});
|
|
||||||
outcomes.push({ expectedKeys: evalCase.expectedKeys, actualKeys });
|
|
||||||
}
|
|
||||||
|
|
||||||
return computeTechStepMetrics(outcomes);
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
/**
|
|
||||||
* Precision/recall/F1 for {@link techStepClassifier}'s output against a
|
|
||||||
* hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective
|
|
||||||
* counterpart to the "inspected by eye" verdict every corpus change used to
|
|
||||||
* get before this module existed. Every future edit to
|
|
||||||
* `services/tech-step-intent-service`'s `training_data.py` (including the
|
|
||||||
* LLM-assisted suggestions the worker in `services/tech-step-llm-worker`
|
|
||||||
* proposes) is expected to run
|
|
||||||
* through `tech-step-eval.test.ts`'s regression gate, which calls
|
|
||||||
* {@link computeTechStepMetrics} — a corpus change that raises recall on one
|
|
||||||
* technique but silently tanks another's precision should fail loudly here,
|
|
||||||
* not get merged on the strength of a few manually-checked examples.
|
|
||||||
*
|
|
||||||
* Pure (no DB/model access) so it's unit-testable on its own — same
|
|
||||||
* convention as `tech-step-matcher.ts`'s own pure helpers (`normalizeText`,
|
|
||||||
* `splitIntoClauses`): this module only ever receives already-resolved
|
|
||||||
* `TechStep.key` strings, never DB ids or a live classifier instance, so it
|
|
||||||
* has nothing to mock to test.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** True/false-positive/negative counts for one technique (or the aggregate across all of them), plus the precision/recall/F1 derived from them. */
|
|
||||||
export interface TechStepMetrics {
|
|
||||||
truePositives: number;
|
|
||||||
falsePositives: number;
|
|
||||||
falseNegatives: number;
|
|
||||||
precision: number;
|
|
||||||
recall: number;
|
|
||||||
f1: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One evaluation case's outcome — what {@link TechStepEvalCase.expectedKeys}
|
|
||||||
* said should be found, against what the classifier actually returned for
|
|
||||||
* that case (already mapped from `TechStepMatch.techStepId` back to
|
|
||||||
* `TechStep.key`, see `tech-step-eval.test.ts`).
|
|
||||||
*
|
|
||||||
* Both lists are *multisets*, not sets — a description that names the same
|
|
||||||
* technique twice (rare, but not impossible: "faire cuire, puis... remettre
|
|
||||||
* à cuire") is expected to produce two matches, and comparing as plain sets
|
|
||||||
* would silently treat a classifier that only found one of them as a
|
|
||||||
* perfect match.
|
|
||||||
*/
|
|
||||||
export interface TechStepEvalOutcome {
|
|
||||||
expectedKeys: string[];
|
|
||||||
actualKeys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@link computeTechStepMetrics}'s result — the aggregate across every case, plus a breakdown per technique so a regression hiding behind a healthy overall F1 (one technique's recall collapsing, offset by another's improving) is still visible. */
|
|
||||||
export interface TechStepEvalResult {
|
|
||||||
overall: TechStepMetrics;
|
|
||||||
byKey: Record<string, TechStepMetrics>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawCounts {
|
|
||||||
tp: number;
|
|
||||||
fp: number;
|
|
||||||
fn: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyCounts(): RawCounts {
|
|
||||||
return { tp: 0, fp: 0, fn: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Counts occurrences of each key in a multiset, e.g. `["cook", "cook", "bake"]` -> `{cook: 2, bake: 1}`. */
|
|
||||||
function countByKey(keys: string[]): Map<string, number> {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const key of keys) {
|
|
||||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard vacuous-truth convention for the `0/0` cases: precision defaults
|
|
||||||
* to `1` when nothing was predicted for a key (`tp + fp === 0` — no false
|
|
||||||
* accusation to be precise about), recall defaults to `1` when nothing was
|
|
||||||
* expected (`tp + fn === 0` — nothing to have missed). Neither inflates F1
|
|
||||||
* on its own: a technique the classifier fully misses still has `recall =
|
|
||||||
* 0` (there *were* expected occurrences, just none matched), which is what
|
|
||||||
* pulls F1 down to `0` for that case regardless of precision's vacuous `1`.
|
|
||||||
*/
|
|
||||||
function toMetrics(counts: RawCounts): TechStepMetrics {
|
|
||||||
const { tp, fp, fn } = counts;
|
|
||||||
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
|
|
||||||
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
|
|
||||||
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
|
|
||||||
return { truePositives: tp, falsePositives: fp, falseNegatives: fn, precision, recall, f1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregates every {@link TechStepEvalOutcome} into one overall
|
|
||||||
* precision/recall/F1 plus a per-technique breakdown.
|
|
||||||
*
|
|
||||||
* Counted key by key, multiset-style, per outcome: for a given technique,
|
|
||||||
* `min(expectedCount, actualCount)` true positives, any actual occurrences
|
|
||||||
* beyond that are false positives, any expected occurrences short of that
|
|
||||||
* are false negatives — generalizes the usual set-based TP/FP/FN definition
|
|
||||||
* to handle a technique mentioned (or matched) more than once in the same
|
|
||||||
* step without over- or under-counting it.
|
|
||||||
*/
|
|
||||||
export function computeTechStepMetrics(outcomes: TechStepEvalOutcome[]): TechStepEvalResult {
|
|
||||||
const overallCounts = emptyCounts();
|
|
||||||
const countsByKey = new Map<string, RawCounts>();
|
|
||||||
|
|
||||||
for (const outcome of outcomes) {
|
|
||||||
const expectedCounts = countByKey(outcome.expectedKeys);
|
|
||||||
const actualCounts = countByKey(outcome.actualKeys);
|
|
||||||
const allKeys = new Set([...expectedCounts.keys(), ...actualCounts.keys()]);
|
|
||||||
|
|
||||||
for (const key of allKeys) {
|
|
||||||
const expected = expectedCounts.get(key) ?? 0;
|
|
||||||
const actual = actualCounts.get(key) ?? 0;
|
|
||||||
const tp = Math.min(expected, actual);
|
|
||||||
const fp = Math.max(0, actual - expected);
|
|
||||||
const fn = Math.max(0, expected - actual);
|
|
||||||
|
|
||||||
overallCounts.tp += tp;
|
|
||||||
overallCounts.fp += fp;
|
|
||||||
overallCounts.fn += fn;
|
|
||||||
|
|
||||||
const keyCounts = countsByKey.get(key) ?? emptyCounts();
|
|
||||||
keyCounts.tp += tp;
|
|
||||||
keyCounts.fp += fp;
|
|
||||||
keyCounts.fn += fn;
|
|
||||||
countsByKey.set(key, keyCounts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const byKey: Record<string, TechStepMetrics> = {};
|
|
||||||
for (const [key, counts] of countsByKey) {
|
|
||||||
byKey[key] = toMetrics(counts);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { overall: toMetrics(overallCounts), byKey };
|
|
||||||
}
|
|
||||||
|
|
@ -1,618 +0,0 @@
|
||||||
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();
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
/**
|
|
||||||
* The generic contract every recipe source (a specific website, an API, …)
|
|
||||||
* implements — groundwork for the "Import d'une recette" pipeline described
|
|
||||||
* in specs/batch-cooking-architecture.md (import depuis source → traduction
|
|
||||||
* en étapes → sauvegarde). This file only defines the shapes; no concrete
|
|
||||||
* source exists yet (see `recipe-source-registry.ts` for where one would be
|
|
||||||
* registered) and nothing here talks to the database or an HTTP route —
|
|
||||||
* that wiring (persisting an imported recipe, resolving `sourceId`) is
|
|
||||||
* deliberately out of scope until a real source needs it.
|
|
||||||
*
|
|
||||||
* The flow a caller drives against one adapter:
|
|
||||||
* 1. `list()` — browse what's available from the source (paginated,
|
|
||||||
* optionally filtered by `query`), like flipping through a catalog.
|
|
||||||
* 2. {@link markAlreadyImported} — flag which of those items we've
|
|
||||||
* already imported, so browsing a source doesn't dangle recipes the
|
|
||||||
* user has already brought in as if they were new. A separate, pure
|
|
||||||
* step rather than something `list()` itself does: an adapter only
|
|
||||||
* knows its source, never our database — same reasoning as
|
|
||||||
* `tech-step-matcher.ts`'s split between pure `splitIntoClauses` and
|
|
||||||
* its DB/model-touching `TechStepClassifierService`. Whichever future
|
|
||||||
* layer queries "which externalIds from this source do we already have"
|
|
||||||
* (not yet decided — it needs a place to persist that link,
|
|
||||||
* see {@link RecipeSourceListItem.externalId}) calls this to annotate
|
|
||||||
* the page before returning it.
|
|
||||||
* 3. `fetchDetail(externalId)` — once the user picks one item from that
|
|
||||||
* list, fetch its full raw content.
|
|
||||||
* 4. `parse(raw)` — turn that raw content into a {@link ParsedRecipe},
|
|
||||||
* pure and synchronous so it's unit-testable without any network
|
|
||||||
* access (same split as step 2 above).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Search/pagination input for {@link RecipeSourceAdapter.list}. */
|
|
||||||
export interface RecipeSourceListParams {
|
|
||||||
/** Free-text search, if the source supports it. Omitted means "browse everything". */
|
|
||||||
query?: string;
|
|
||||||
/**
|
|
||||||
* Opaque continuation token from a previous {@link RecipeSourceListResult.nextCursor}
|
|
||||||
* — omitted (or `null`) means "start from the first page". Deliberately
|
|
||||||
* opaque (not a page number) so an adapter can back it with whatever its
|
|
||||||
* source actually supports (page number, offset, an API-provided token).
|
|
||||||
*/
|
|
||||||
cursor?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One entry in a {@link RecipeSourceAdapter.list} result — enough to show in a browsing UI and to fetch the full recipe once selected. */
|
|
||||||
export interface RecipeSourceListItem {
|
|
||||||
/**
|
|
||||||
* Source-specific identifier, opaque to callers — passed back verbatim
|
|
||||||
* to {@link RecipeSourceAdapter.fetchDetail}, and the key
|
|
||||||
* {@link markAlreadyImported} matches against to tell an already-imported
|
|
||||||
* item apart from a new one.
|
|
||||||
*/
|
|
||||||
externalId: string;
|
|
||||||
title: string;
|
|
||||||
picture: string | null;
|
|
||||||
/** Canonical URL of the recipe on the source, kept for attribution even before it's imported. */
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeSourceListResult {
|
|
||||||
items: RecipeSourceListItem[];
|
|
||||||
/** Pass back as `cursor` to fetch the next page — `null` means this was the last page. */
|
|
||||||
nextCursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A browsed {@link RecipeSourceListItem}, after {@link markAlreadyImported} has flagged whether we already imported it. What a browsing UI actually renders — e.g. to grey it out or offer "already added" instead of "import". */
|
|
||||||
export interface BrowsableRecipeItem extends RecipeSourceListItem {
|
|
||||||
alreadyImported: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits a page of {@link RecipeSourceListItem}s into already-imported vs.
|
|
||||||
* new, purely by checking each item's `externalId` against
|
|
||||||
* `importedExternalIds` — no I/O here, the caller is responsible for
|
|
||||||
* gathering that set (from wherever we end up persisting the link between
|
|
||||||
* an imported `Recipe` and the source item it came from) before calling
|
|
||||||
* this. Kept as a tiny, dedicated, easily-testable step rather than folded
|
|
||||||
* into `list()` itself, so an adapter never needs to know our database
|
|
||||||
* exists.
|
|
||||||
*/
|
|
||||||
export function markAlreadyImported(
|
|
||||||
items: RecipeSourceListItem[],
|
|
||||||
importedExternalIds: ReadonlySet<string>,
|
|
||||||
): BrowsableRecipeItem[] {
|
|
||||||
return items.map((item) => ({
|
|
||||||
...item,
|
|
||||||
alreadyImported: importedExternalIds.has(item.externalId),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One ingredient line as lifted from a source, before it's resolved against
|
|
||||||
* our own `Ingredient`/`Unit` reference catalogs (that resolution —
|
|
||||||
* matching free text to a `key`, the way `tech-step-matcher.ts` matches
|
|
||||||
* step text to a `TechStep` — is a separate, not-yet-built concern; this
|
|
||||||
* type only carries what a source's raw text actually says). `rawText` is
|
|
||||||
* kept alongside the (best-effort) parsed fields so a failed/partial parse
|
|
||||||
* is still traceable back to what the source originally wrote.
|
|
||||||
*/
|
|
||||||
export interface ParsedRecipeIngredient {
|
|
||||||
rawText: string;
|
|
||||||
quantity: number | null;
|
|
||||||
/** Free-text unit exactly as written by the source (e.g. `"cuillère à soupe"`, `"g"`) — not yet resolved to a `Unit.key`. */
|
|
||||||
unit: string | null;
|
|
||||||
/** Free-text ingredient name exactly as written by the source — not yet resolved to an `Ingredient.key`. */
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedRecipeStep {
|
|
||||||
description: string;
|
|
||||||
picture: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The normalized shape every adapter's {@link RecipeSourceAdapter.parse}
|
|
||||||
* produces, regardless of the source. Intentionally *not*
|
|
||||||
* `CreateRecipeInput` (packages/shared/src/schemas/recipe.ts): ingredients
|
|
||||||
* are still free text (no `ingredientId`/`unitId` — that catalog-matching
|
|
||||||
* step doesn't exist yet), and there's no `dietIds`/`visibility` since a
|
|
||||||
* source can't know those. Turning a `ParsedRecipe` into a saved `Recipe`
|
|
||||||
* is future work for whichever module ends up driving this pipeline.
|
|
||||||
*/
|
|
||||||
export interface ParsedRecipe {
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
picture: string | null;
|
|
||||||
/** `null` when the source doesn't state a serving size. */
|
|
||||||
portions: number | null;
|
|
||||||
/** Canonical URL of the recipe on the source — the eventual `Source`/`Recipe.sourceId` link (schema.prisma) is populated from this once the import pipeline saves the recipe. */
|
|
||||||
sourceUrl: string;
|
|
||||||
ingredients: ParsedRecipeIngredient[];
|
|
||||||
steps: ParsedRecipeStep[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single recipe source — a specific website or API, plus the two pieces
|
|
||||||
* of source-specific logic needed to pull a recipe out of it. `TRawDetail`
|
|
||||||
* is whatever shape `fetchDetail` naturally returns for this source (an
|
|
||||||
* HTML string, a parsed JSON body, …); `parse` is the only thing that needs
|
|
||||||
* to understand it.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const myAdapter: RecipeSourceAdapter<{ html: string }> = {
|
|
||||||
* key: "someRecipeSite",
|
|
||||||
* name: "Some Recipe Site",
|
|
||||||
* official: false,
|
|
||||||
* iconUrl: "https://somerecipesite.example/favicon.svg",
|
|
||||||
* locale: "fr",
|
|
||||||
* async list(params) { ... },
|
|
||||||
* async fetchDetail(externalId) { ... },
|
|
||||||
* parse(raw) { ... },
|
|
||||||
* };
|
|
||||||
* registerRecipeSource(myAdapter);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export interface RecipeSourceAdapter<TRawDetail = unknown> {
|
|
||||||
/** Stable identifier used to look this adapter up in the registry — same "English camelCase uid" convention as `Diet.key`/`Unit.key`/`TechStep.key`. */
|
|
||||||
key: string;
|
|
||||||
/** Human-readable name, for display in a source picker. */
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* Whether this source is an official API (the site/publisher itself
|
|
||||||
* provides structured recipe data) versus unofficial web scraping (we
|
|
||||||
* parse HTML the site never committed to a stable shape for) — surfaced
|
|
||||||
* to households (`Source.official`, synced via `syncRecipeSources`) so
|
|
||||||
* they can tell the two apart when deciding which sources to enable
|
|
||||||
* (see `HouseSource`, schema.prisma). No default on purpose: every
|
|
||||||
* adapter author has to consciously pick one rather than silently
|
|
||||||
* inheriting a guess.
|
|
||||||
*/
|
|
||||||
official: boolean;
|
|
||||||
/** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */
|
|
||||||
iconUrl: string | null;
|
|
||||||
/**
|
|
||||||
* Language of the text this source produces (`ParsedRecipe.description`/
|
|
||||||
* `steps[].description`/`ingredients[].name`) — e.g. `"en"` for
|
|
||||||
* TheMealDB. Not a user preference: the language the source's own
|
|
||||||
* content is actually written in, regardless of who's browsing it.
|
|
||||||
* Determines which trained-classifier/ingredient-label locale
|
|
||||||
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
|
|
||||||
* recipes against when previewing/importing one.
|
|
||||||
*/
|
|
||||||
locale: string;
|
|
||||||
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
|
|
||||||
fetchDetail(externalId: string): Promise<TRawDetail>;
|
|
||||||
parse(raw: TRawDetail): ParsedRecipe;
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
/**
|
|
||||||
* Error vocabulary a {@link RecipeSourceAdapter} (recipe-source-adapter.ts)
|
|
||||||
* implementation throws when talking to its source fails — kept separate
|
|
||||||
* from `@batch-cooking/error-tools`'s `HttpError`/`ErrorCode` (used for
|
|
||||||
* *this API's* HTTP responses) since no route drives this module yet. A
|
|
||||||
* future import route would catch these and translate them into an
|
|
||||||
* `HttpError` with a dedicated `ErrorCode` the same way any other service
|
|
||||||
* error is; this module only needs a consistent shape to throw in the
|
|
||||||
* meantime, not that translation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Base class for every error a {@link RecipeSourceAdapter} can throw — lets a caller `catch (err) { if (err instanceof RecipeSourceError) ... }` regardless of which stage failed. */
|
|
||||||
export class RecipeSourceError extends Error {
|
|
||||||
/** The failing adapter's `key` (recipe-source-adapter.ts's `RecipeSourceAdapter.key`) — which source this error came from. */
|
|
||||||
readonly sourceKey: string;
|
|
||||||
|
|
||||||
constructor(sourceKey: string, message: string, options?: { cause?: unknown }) {
|
|
||||||
super(message, options);
|
|
||||||
this.sourceKey = sourceKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The source's `list`/`fetchDetail` failed — network error, non-2xx response, source unreachable, etc. */
|
|
||||||
export class RecipeSourceFetchError extends RecipeSourceError {
|
|
||||||
constructor(sourceKey: string, message: string, options?: { cause?: unknown }) {
|
|
||||||
super(sourceKey, message, options);
|
|
||||||
this.name = "RecipeSourceFetchError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The source responded, but `parse` couldn't make sense of the raw payload (unexpected shape, missing required field, …). */
|
|
||||||
export class RecipeSourceParseError extends RecipeSourceError {
|
|
||||||
constructor(sourceKey: string, message: string, options?: { cause?: unknown }) {
|
|
||||||
super(sourceKey, message, options);
|
|
||||||
this.name = "RecipeSourceParseError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import type { RecipeSourceAdapter } from "./recipe-source-adapter.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory registry of every {@link RecipeSourceAdapter} (recipe-source-adapter.ts)
|
|
||||||
* this process knows about, keyed by `adapter.key`. Deliberately not
|
|
||||||
* DB-backed — an adapter *is* code (a website's fetch/parse logic can't
|
|
||||||
* live in a database row) — but the `Source` table (schema.prisma) is kept
|
|
||||||
* in sync with it (see `syncRecipeSources`, recipe-source-sync.ts) so a
|
|
||||||
* saved `Recipe.sourceId` has a row to point at. Actually saving an
|
|
||||||
* imported recipe (setting `Recipe.sourceId`/`externalId`) is still future
|
|
||||||
* work for whichever module ends up driving the import pipeline — this
|
|
||||||
* registry only answers "which sources can we import from right now".
|
|
||||||
*
|
|
||||||
* No adapter is registered here yet — this file only provides the
|
|
||||||
* mechanism; `registerRecipeSource` is meant to be called once per adapter
|
|
||||||
* module, at whatever point a concrete source is added.
|
|
||||||
*/
|
|
||||||
const adapters = new Map<string, RecipeSourceAdapter>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers `adapter` under its own `key`. Throws if that key is already
|
|
||||||
* taken — two adapters silently overwriting each other would be a bug (a
|
|
||||||
* caller reaching for "marmiton" should never get a different adapter than
|
|
||||||
* the one it registered), not a case to swallow.
|
|
||||||
*/
|
|
||||||
export function registerRecipeSource<TRawDetail>(adapter: RecipeSourceAdapter<TRawDetail>): void {
|
|
||||||
if (adapters.has(adapter.key)) {
|
|
||||||
throw new Error(`Recipe source "${adapter.key}" is already registered`);
|
|
||||||
}
|
|
||||||
// `TRawDetail` only matters within one adapter's own list/fetchDetail/parse
|
|
||||||
// trio — once stored, callers look adapters up by key and drive the same
|
|
||||||
// three methods generically, so the registry itself doesn't need to know
|
|
||||||
// each adapter's raw type. This cast is the standard way to store a
|
|
||||||
// heterogeneous collection of otherwise-identically-shaped generics.
|
|
||||||
adapters.set(adapter.key, adapter as RecipeSourceAdapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The adapter registered under `key`, or `undefined` if none is. */
|
|
||||||
export function getRecipeSource(key: string): RecipeSourceAdapter | undefined {
|
|
||||||
return adapters.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every registered adapter — e.g. to offer a source picker. */
|
|
||||||
export function listRecipeSources(): RecipeSourceAdapter[] {
|
|
||||||
return [...adapters.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Empties the registry. Not meant for application code — `apps/api/src`
|
|
||||||
* never calls this — only for test isolation, the same role
|
|
||||||
* `test-support/reset-db.ts` plays for the database: without it, adapters
|
|
||||||
* registered by one test file would leak into the next.
|
|
||||||
*/
|
|
||||||
export function clearRecipeSources(): void {
|
|
||||||
adapters.clear();
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { errorHandlerService } from "@batch-cooking/error-tools";
|
|
||||||
import type { NextFunction, Request, Response } from "express";
|
|
||||||
import { logger } from "../lib/logger.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs every error that reaches Express's error-handling chain, then
|
|
||||||
* passes it straight on (`next(err)`) to the real error-to-response
|
|
||||||
* middleware (`createErrorMiddleware`, `@batch-cooking/express-tools`) —
|
|
||||||
* mounted immediately after this one in `app.ts`. Reuses
|
|
||||||
* `errorHandlerService.handle()` (`@batch-cooking/error-tools`) just to
|
|
||||||
* classify the error for logging purposes (its `status`/`body.code`) —
|
|
||||||
* `.handle()` is pure/stateless, so calling it here and then again in
|
|
||||||
* `createErrorMiddleware` right after is harmless, and it's the one place
|
|
||||||
* that already knows a bare `ZodError` maps to `400 VALIDATION_ERROR`, an
|
|
||||||
* `HttpError` maps to its own `status`/`code`, and anything else is a
|
|
||||||
* `500`. Doesn't build the actual response itself — that's still
|
|
||||||
* `createErrorMiddleware`'s job.
|
|
||||||
*
|
|
||||||
* A resulting `4xx` is routine, expected operation (a validation failure,
|
|
||||||
* a 404, an unauthenticated request) — logged at `warn`, not `error`, so a
|
|
||||||
* genuine `5xx` (an unhandled exception, a bug) stands out instead of
|
|
||||||
* being buried under normal client mistakes.
|
|
||||||
*/
|
|
||||||
export function errorLogger(err: unknown, req: Request, _res: Response, next: NextFunction): void {
|
|
||||||
const meta = { method: req.method, path: req.originalUrl };
|
|
||||||
const { status, body } = errorHandlerService.handle(err);
|
|
||||||
|
|
||||||
if (status >= 500) {
|
|
||||||
logger.error(err instanceof Error ? err.message : body.message, {
|
|
||||||
...meta,
|
|
||||||
status,
|
|
||||||
code: body.code,
|
|
||||||
stack: err instanceof Error ? err.stack : undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.warn(body.message, { ...meta, status, code: body.code });
|
|
||||||
}
|
|
||||||
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import type { NextFunction, Request, Response } from "express";
|
|
||||||
import { logger } from "../lib/logger.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs one line per request once it finishes — method, path, status code,
|
|
||||||
* and duration. Mounted first in `app.ts` (before every route, and before
|
|
||||||
* the error handler) so it wraps the whole request/response cycle,
|
|
||||||
* including requests that end in a 404 or an error response.
|
|
||||||
*
|
|
||||||
* Listens on `res`'s `"finish"` event rather than wrapping `next()` in a
|
|
||||||
* `try`/`finally`: this middleware calls `next()` immediately and returns,
|
|
||||||
* so it never itself sits on the stack waiting for the rest of the
|
|
||||||
* pipeline to resolve — `"finish"` fires once Express has actually flushed
|
|
||||||
* the response, whichever handler (or the error middleware) produced it.
|
|
||||||
*
|
|
||||||
* `4xx`/`5xx` responses log at `warn`/`error` respectively (status alone
|
|
||||||
* decides the level — this middleware has no idea *why* a request failed,
|
|
||||||
* just that it did); everything else logs at `info`. A route's own
|
|
||||||
* handler/the error middleware may log more detail about *why* separately
|
|
||||||
* (see `error-middleware` wiring in `app.ts`) — this line is just the
|
|
||||||
* "a request happened, here's the outcome" operational trace.
|
|
||||||
*/
|
|
||||||
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
|
|
||||||
const startedAt = process.hrtime.bigint();
|
|
||||||
|
|
||||||
res.on("finish", () => {
|
|
||||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
||||||
const meta = {
|
|
||||||
method: req.method,
|
|
||||||
path: req.originalUrl,
|
|
||||||
status: res.statusCode,
|
|
||||||
durationMs: Math.round(durationMs * 100) / 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (res.statusCode >= 500) {
|
|
||||||
logger.error("Request completed", meta);
|
|
||||||
} else if (res.statusCode >= 400) {
|
|
||||||
logger.warn("Request completed", meta);
|
|
||||||
} else {
|
|
||||||
logger.info("Request completed", meta);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { timingSafeEqual } from "node:crypto";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import type { NextFunction, Request, Response } from "express";
|
|
||||||
import { env } from "../config/env.js";
|
|
||||||
|
|
||||||
/** Header `services/tech-step-llm-worker` sends its shared secret on. Not `Authorization`/a bearer scheme — this isn't a user session, just one internal caller authenticating to another, same "one flat shared secret" shape as e.g. a webhook signing header. */
|
|
||||||
const INTERNAL_WORKER_SECRET_HEADER = "x-internal-worker-secret";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Express middleware guarding `/internal/tech-steps/*` — the surface
|
|
||||||
* `services/tech-step-llm-worker` (a process outside this monorepo, no
|
|
||||||
* Prisma access of its own, see that service's own README) reads
|
|
||||||
* low-confidence NLP clauses and pending `StepTechStepCorrection`s from,
|
|
||||||
* and posts `TechStepTrainingSuggestion`s back to. Never reachable by an
|
|
||||||
* end user's session cookie — deliberately a *different* auth mechanism
|
|
||||||
* than {@link requireAuth} (`require-auth.ts`), not layered on top of it,
|
|
||||||
* since the worker has no `UserProfile`/session of its own to authenticate
|
|
||||||
* as.
|
|
||||||
*
|
|
||||||
* Fails closed: an unset `INTERNAL_WORKER_SECRET` (the default in any
|
|
||||||
* environment that doesn't run the worker, see `config/env.ts`) rejects
|
|
||||||
* every request rather than leaving the surface open, same posture as a
|
|
||||||
* misconfigured `JWT_SECRET` would if it had a working fallback.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` if the header is missing,
|
|
||||||
* wrong, or the server has no secret configured at all — never
|
|
||||||
* distinguishes the reason, same posture as {@link requireAuth}.
|
|
||||||
*/
|
|
||||||
export function requireInternalWorker(req: Request, _res: Response, next: NextFunction): void {
|
|
||||||
const provided = req.header(INTERNAL_WORKER_SECRET_HEADER);
|
|
||||||
if (env.INTERNAL_WORKER_SECRET === undefined || provided === undefined) {
|
|
||||||
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `timingSafeEqual` throws on mismatched buffer lengths rather than
|
|
||||||
// returning `false` — checked separately first. A length mismatch alone
|
|
||||||
// already means "not equal", so this loses no timing-attack protection
|
|
||||||
// (an attacker learns nothing beyond what a differing length itself
|
|
||||||
// already reveals, no different from `!==` on the common case where the
|
|
||||||
// secret's real length isn't a secret worth protecting).
|
|
||||||
const expected = Buffer.from(env.INTERNAL_WORKER_SECRET);
|
|
||||||
const actual = Buffer.from(provided);
|
|
||||||
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
||||||
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||||
import type { CookieOptions, Response } from "express";
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import type { CookieOptions, Response } from "express";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import { deleteAccount, login, signup } from "./auth.service.js";
|
import { deleteAccount, login, signup } from "./auth.service.js";
|
||||||
|
|
@ -18,10 +18,8 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
/** Cookie options shared by every route that sets the session cookie. */
|
/** Cookie options shared by every route that sets the session cookie. */
|
||||||
const cookieOptions: CookieOptions = {
|
const cookieOptions: CookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
// Defaults to requiring HTTPS in production, but overridable via
|
// Only require HTTPS in production — local dev/CI serve over plain HTTP.
|
||||||
// COOKIE_SECURE — see its doc comment in config/env.ts for why this
|
secure: env.NODE_ENV === "production",
|
||||||
// can't just be `NODE_ENV === "production"`.
|
|
||||||
secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
maxAge: SEVEN_DAYS_MS,
|
maxAge: SEVEN_DAYS_MS,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -35,41 +35,28 @@ const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
||||||
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
||||||
*/
|
*/
|
||||||
export async function signup(input: SignupInput): Promise<AuthResult> {
|
export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||||
try {
|
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||||
const existing = await prisma.userProfile.findUnique({
|
if (existing) {
|
||||||
where: { email: input.email },
|
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await argon2.hash(input.password, hashOptions);
|
|
||||||
|
|
||||||
// No household is created here — it's now an optional step of the
|
|
||||||
// onboarding wizard (create or join one, or skip — see
|
|
||||||
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
|
|
||||||
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
|
|
||||||
const profile = await prisma.userProfile.create({
|
|
||||||
data: {
|
|
||||||
firstName: input.firstName,
|
|
||||||
lastName: input.lastName,
|
|
||||||
email: input.email,
|
|
||||||
passwordHash,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -87,21 +74,15 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||||
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
||||||
*/
|
*/
|
||||||
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
||||||
try {
|
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
|
||||||
const profile = await prisma.userProfile.findUnique({
|
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
||||||
where: { id: profileId },
|
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
||||||
});
|
|
||||||
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
|
||||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.houseId !== null) {
|
|
||||||
await leaveCurrentHouse(profile.id, profile.houseId);
|
|
||||||
}
|
|
||||||
await prisma.userProfile.delete({ where: { id: profile.id } });
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see signup()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (profile.houseId !== null) {
|
||||||
|
await leaveCurrentHouse(profile.id, profile.houseId);
|
||||||
|
}
|
||||||
|
await prisma.userProfile.delete({ where: { id: profile.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -112,21 +93,12 @@ export async function deleteAccount(profileId: number, password: string): Promis
|
||||||
* caller can never learn whether a given email has an account.
|
* caller can never learn whether a given email has an account.
|
||||||
*/
|
*/
|
||||||
export async function login(input: LoginInput): Promise<AuthResult> {
|
export async function login(input: LoginInput): Promise<AuthResult> {
|
||||||
try {
|
const profile = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||||
const profile = await prisma.userProfile.findUnique({
|
|
||||||
where: { email: input.email },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
||||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
||||||
}
|
|
||||||
|
|
||||||
const token = signAuthToken({
|
|
||||||
userProfileId: profile.id,
|
|
||||||
tokenVersion: profile.tokenVersion,
|
|
||||||
});
|
|
||||||
return { profile: toSafeProfile(profile), token };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see signup()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||||
|
return { profile: toSafeProfile(profile), token };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import {
|
import {
|
||||||
createHouseSchema,
|
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
createHouseSchema,
|
||||||
joinHouseSchema,
|
joinHouseSchema,
|
||||||
renameHouseSchema,
|
renameHouseSchema,
|
||||||
updateHouseSourcesSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
|
|
@ -13,12 +12,10 @@ import {
|
||||||
createHouse,
|
createHouse,
|
||||||
deleteHouse,
|
deleteHouse,
|
||||||
getCurrentHouse,
|
getCurrentHouse,
|
||||||
getHouseSourceIds,
|
|
||||||
joinHouse,
|
joinHouse,
|
||||||
leaveCurrentHouse,
|
leaveCurrentHouse,
|
||||||
removeMember,
|
removeMember,
|
||||||
renameHouse,
|
renameHouse,
|
||||||
updateHouseSources,
|
|
||||||
} from "./house.service.js";
|
} from "./house.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */
|
/** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */
|
||||||
|
|
@ -94,27 +91,6 @@ houseRouter.delete(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Which recipe sources the household currently sees in its recipe tabs — the source step of the onboarding wizard and the `/parametres/foyer` settings page both call this. */
|
|
||||||
houseRouter.get(
|
|
||||||
"/current/sources",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
|
||||||
const sourceIds = await getHouseSourceIds(res.locals.userProfile.houseId);
|
|
||||||
res.status(200).json(sourceIds);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Replaces the household's enabled-source set — same callers as the GET above. */
|
|
||||||
houseRouter.patch(
|
|
||||||
"/current/sources",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const input = updateHouseSourcesSchema.parse(req.body);
|
|
||||||
const sourceIds = await updateHouseSources(res.locals.userProfile.houseId, input.sourceIds);
|
|
||||||
res.status(200).json(sourceIds);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */
|
/** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */
|
||||||
houseRouter.delete(
|
houseRouter.delete(
|
||||||
"/members/:memberId",
|
"/members/:memberId",
|
||||||
|
|
|
||||||
|
|
@ -44,18 +44,10 @@ const houseWithMembers = {
|
||||||
|
|
||||||
/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */
|
/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */
|
||||||
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
|
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return toHouseView(await findHouseOrThrow(houseId));
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
|
||||||
// this service layer just isn't allowed a bare `await` per the repo's
|
|
||||||
// async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
return toHouseView(await findHouseOrThrow(houseId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -66,20 +58,16 @@ export async function getCurrentHouse(houseId: number | null): Promise<HouseView
|
||||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||||
*/
|
*/
|
||||||
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
|
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
await findHouseOrThrow(houseId);
|
|
||||||
const house = await prisma.house.update({
|
|
||||||
where: { id: houseId },
|
|
||||||
data: { name },
|
|
||||||
include: houseWithMembers,
|
|
||||||
});
|
|
||||||
return toHouseView(house);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
await findHouseOrThrow(houseId);
|
||||||
|
const house = await prisma.house.update({
|
||||||
|
where: { id: houseId },
|
||||||
|
data: { name },
|
||||||
|
include: houseWithMembers,
|
||||||
|
});
|
||||||
|
return toHouseView(house);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -93,45 +81,30 @@ export async function createHouse(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<HouseView> {
|
): Promise<HouseView> {
|
||||||
try {
|
if (houseId !== null) {
|
||||||
if (houseId !== null) {
|
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
|
||||||
throw new HttpError(
|
|
||||||
409,
|
|
||||||
ErrorCode.ALREADY_HAS_HOUSE,
|
|
||||||
"Profile already belongs to a household",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Astronomically unlikely to collide (33^8 possibilities), but retried
|
|
||||||
// rather than assumed — a `@unique` constraint failure is the only fully
|
|
||||||
// reliable way to detect it.
|
|
||||||
const maxAttempts = 5;
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
const house = await prisma.$transaction(async (tx) => {
|
|
||||||
const created = await tx.house.create({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
adminId: profileId,
|
|
||||||
inviteCode: generateInviteCode(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.userProfile.update({
|
|
||||||
where: { id: profileId },
|
|
||||||
data: { houseId: created.id },
|
|
||||||
});
|
|
||||||
return created;
|
|
||||||
});
|
|
||||||
return await getCurrentHouseOrThrow(house.id);
|
|
||||||
} catch (err) {
|
|
||||||
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("Failed to generate a unique invite code after several attempts");
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Astronomically unlikely to collide (33^8 possibilities), but retried
|
||||||
|
// rather than assumed — a `@unique` constraint failure is the only fully
|
||||||
|
// reliable way to detect it.
|
||||||
|
const maxAttempts = 5;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const house = await prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.house.create({
|
||||||
|
data: { name, adminId: profileId, inviteCode: generateInviteCode() },
|
||||||
|
});
|
||||||
|
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } });
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
return getCurrentHouseOrThrow(house.id);
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Failed to generate a unique invite code after several attempts");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -145,32 +118,21 @@ export async function joinHouse(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
inviteCode: string,
|
inviteCode: string,
|
||||||
): Promise<HouseView> {
|
): Promise<HouseView> {
|
||||||
try {
|
if (houseId !== null) {
|
||||||
if (houseId !== null) {
|
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
|
||||||
throw new HttpError(
|
|
||||||
409,
|
|
||||||
ErrorCode.ALREADY_HAS_HOUSE,
|
|
||||||
"Profile already belongs to a household",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const house = await prisma.house.findUnique({ where: { inviteCode } });
|
|
||||||
if (!house) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.INVITE_CODE_NOT_FOUND,
|
|
||||||
"No household matches this invite code",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.userProfile.update({
|
|
||||||
where: { id: profileId },
|
|
||||||
data: { houseId: house.id },
|
|
||||||
});
|
|
||||||
return await getCurrentHouseOrThrow(house.id);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const house = await prisma.house.findUnique({ where: { inviteCode } });
|
||||||
|
if (!house) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.INVITE_CODE_NOT_FOUND,
|
||||||
|
"No household matches this invite code",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } });
|
||||||
|
return getCurrentHouseOrThrow(house.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -187,38 +149,28 @@ export async function joinHouse(
|
||||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
|
||||||
*/
|
*/
|
||||||
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
|
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
|
|
||||||
const house = await findHouseOrThrow(houseId);
|
|
||||||
const remainingMembers = house.members.filter((member) => member.id !== profileId);
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.userProfile.update({
|
|
||||||
where: { id: profileId },
|
|
||||||
data: { houseId: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (house.adminId !== profileId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (remainingMembers.length === 0) {
|
|
||||||
await tx.house.delete({ where: { id: house.id } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextAdmin = remainingMembers.reduce((oldest, member) =>
|
|
||||||
member.id < oldest.id ? member : oldest,
|
|
||||||
);
|
|
||||||
await tx.house.update({
|
|
||||||
where: { id: house.id },
|
|
||||||
data: { adminId: nextAdmin.id },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const house = await findHouseOrThrow(houseId);
|
||||||
|
const remainingMembers = house.members.filter((member) => member.id !== profileId);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } });
|
||||||
|
|
||||||
|
if (house.adminId !== profileId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (remainingMembers.length === 0) {
|
||||||
|
await tx.house.delete({ where: { id: house.id } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextAdmin = remainingMembers.reduce((oldest, member) =>
|
||||||
|
member.id < oldest.id ? member : oldest,
|
||||||
|
);
|
||||||
|
await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -231,32 +183,21 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
|
||||||
* @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
|
* @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
|
||||||
*/
|
*/
|
||||||
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
|
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
const house = await findHouseOrThrow(houseId);
|
|
||||||
if (house.adminId !== profileId) {
|
|
||||||
throw new HttpError(
|
|
||||||
403,
|
|
||||||
ErrorCode.NOT_HOUSE_ADMIN,
|
|
||||||
"Only the household's admin can delete it",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
|
|
||||||
// but clearing it explicitly first keeps the outcome obvious without
|
|
||||||
// relying on that FK behavior being read alongside this function.
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.userProfile.updateMany({
|
|
||||||
where: { houseId: house.id },
|
|
||||||
data: { houseId: null },
|
|
||||||
}),
|
|
||||||
prisma.house.delete({ where: { id: house.id } }),
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
const house = await findHouseOrThrow(houseId);
|
||||||
|
if (house.adminId !== profileId) {
|
||||||
|
throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
|
||||||
|
// but clearing it explicitly first keeps the outcome obvious without
|
||||||
|
// relying on that FK behavior being read alongside this function.
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }),
|
||||||
|
prisma.house.delete({ where: { id: house.id } }),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -274,115 +215,35 @@ export async function removeMember(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
targetMemberId: number,
|
targetMemberId: number,
|
||||||
): Promise<HouseView> {
|
): Promise<HouseView> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
const house = await findHouseOrThrow(houseId);
|
|
||||||
if (house.adminId !== profileId) {
|
|
||||||
throw new HttpError(
|
|
||||||
403,
|
|
||||||
ErrorCode.NOT_HOUSE_ADMIN,
|
|
||||||
"Only the household's admin can remove a member",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (targetMemberId === profileId) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
"Use POST /house/leave to remove yourself",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!house.members.some((member) => member.id === targetMemberId)) {
|
|
||||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.userProfile.update({
|
|
||||||
where: { id: targetMemberId },
|
|
||||||
data: { houseId: null },
|
|
||||||
});
|
|
||||||
return await getCurrentHouseOrThrow(house.id);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
}
|
const house = await findHouseOrThrow(houseId);
|
||||||
|
if (house.adminId !== profileId) {
|
||||||
/**
|
throw new HttpError(
|
||||||
* Current enabled-source ids for a household — an empty array is normal
|
403,
|
||||||
* and is this household's starting state (opt-in: see `HouseSource` in
|
ErrorCode.NOT_HOUSE_ADMIN,
|
||||||
* schema.prisma), not just "no preference set yet".
|
"Only the household's admin can remove a member",
|
||||||
*
|
);
|
||||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
|
||||||
*/
|
|
||||||
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
|
|
||||||
try {
|
|
||||||
if (houseId === null) {
|
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
const rows = await prisma.houseSource.findMany({
|
|
||||||
where: { houseId },
|
|
||||||
select: { sourceId: true },
|
|
||||||
});
|
|
||||||
return rows.map((row) => row.sourceId);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
}
|
if (targetMemberId === profileId) {
|
||||||
|
throw new HttpError(
|
||||||
/**
|
400,
|
||||||
* Replaces a household's full set of enabled recipe sources (not a merge —
|
ErrorCode.VALIDATION_ERROR,
|
||||||
* same "replace, not merge" contract as `profile.service.ts`'s
|
"Use POST /house/leave to remove yourself",
|
||||||
* `updateAllergies`). Every recipe-catalog tab (`recipe.service.ts`'s
|
);
|
||||||
* `listRecipes`) filters against this set — a source left out here simply
|
|
||||||
* never shows its recipes to this household, in any tab.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
|
||||||
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if any `sourceId` doesn't match a reference `Source` row.
|
|
||||||
*/
|
|
||||||
export async function updateHouseSources(
|
|
||||||
houseId: number | null,
|
|
||||||
sourceIds: number[],
|
|
||||||
): Promise<number[]> {
|
|
||||||
try {
|
|
||||||
if (houseId === null) {
|
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
if (sourceIds.length > 0) {
|
|
||||||
const found = await prisma.source.findMany({
|
|
||||||
where: { id: { in: sourceIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((source) => source.id));
|
|
||||||
const missing = sourceIds.filter((id) => !foundIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.SOURCE_NOT_FOUND,
|
|
||||||
`Unknown source id(s): ${missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.houseSource.deleteMany({ where: { houseId } }),
|
|
||||||
prisma.houseSource.createMany({
|
|
||||||
data: sourceIds.map((sourceId) => ({ houseId, sourceId })),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return sourceIds;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
if (!house.members.some((member) => member.id === targetMemberId)) {
|
||||||
|
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userProfile.update({ where: { id: targetMemberId }, data: { houseId: null } });
|
||||||
|
return getCurrentHouseOrThrow(house.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
|
/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
|
||||||
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
|
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
|
||||||
try {
|
return toHouseView(await findHouseOrThrow(houseId));
|
||||||
return toHouseView(await findHouseOrThrow(houseId));
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
|
/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
|
||||||
|
|
@ -404,16 +265,12 @@ function isUniqueInviteCodeViolation(err: unknown): boolean {
|
||||||
* `HOUSE_NOT_FOUND` HttpError.
|
* `HOUSE_NOT_FOUND` HttpError.
|
||||||
*/
|
*/
|
||||||
async function findHouseOrThrow(houseId: number) {
|
async function findHouseOrThrow(houseId: number) {
|
||||||
try {
|
const house = await prisma.house.findUnique({
|
||||||
const house = await prisma.house.findUnique({
|
where: { id: houseId },
|
||||||
where: { id: houseId },
|
include: houseWithMembers,
|
||||||
include: houseWithMembers,
|
});
|
||||||
});
|
if (!house) {
|
||||||
if (!house) {
|
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
||||||
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
|
||||||
}
|
|
||||||
return house;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getCurrentHouse()'s catch comment above
|
|
||||||
}
|
}
|
||||||
|
return house;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
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));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
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 { parseDateOnly } from "@batch-cooking/date-tools";
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { addPlanningItemSchema, ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
|
import { ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js";
|
import { getPlanningForDate } from "./planning.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/planning` in app.ts. */
|
/** Router mounted at `/planning` in app.ts. */
|
||||||
export const planningRouter = Router();
|
export const planningRouter = Router();
|
||||||
|
|
@ -34,48 +34,3 @@ planningRouter.get(
|
||||||
res.status(200).json(planning);
|
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,13 +1,6 @@
|
||||||
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
import { type DateTime, toDateOnly } from "@batch-cooking/date-tools";
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import type { PlanningView } from "@batch-cooking/shared";
|
||||||
import {
|
|
||||||
type AddPlanningItemInput,
|
|
||||||
ErrorCode,
|
|
||||||
type PlanningItemView,
|
|
||||||
type PlanningView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
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
|
* Finds the household's planning that covers `date` and shapes it into a
|
||||||
|
|
@ -19,175 +12,54 @@ import { assertRecipeVisible } from "../recipe/recipe.service.js";
|
||||||
* Returns `null` for two distinct, both entirely normal states — a
|
* Returns `null` for two distinct, both entirely normal states — a
|
||||||
* `houseId` of `null` (the profile has no household yet — households are no
|
* `houseId` of `null` (the profile has no household yet — households are no
|
||||||
* longer created automatically at signup, see `auth.service.ts`) and "no
|
* longer created automatically at signup, see `auth.service.ts`) and "no
|
||||||
* planning row covers this date yet" (e.g. a week nobody has added a recipe
|
* planning row covers this date" (the expected case until planning
|
||||||
* to via {@link addPlanningItem}) — neither is an error, so both collapse
|
* creation is built) — neither is an error, so both collapse to the same
|
||||||
* to the same "nothing to show yet" result rather than throwing.
|
* "nothing to show yet" result rather than throwing.
|
||||||
*/
|
*/
|
||||||
export async function getPlanningForDate(
|
export async function getPlanningForDate(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
date: DateTime,
|
date: DateTime,
|
||||||
): Promise<PlanningView | null> {
|
): Promise<PlanningView | null> {
|
||||||
try {
|
if (houseId === null) {
|
||||||
if (houseId === null) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
|
||||||
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
|
||||||
// how Postgres stores/returns them, regardless of the server's local
|
|
||||||
// timezone.
|
|
||||||
const dateOnly = toDateOnly(date).toJSDate();
|
|
||||||
|
|
||||||
const planning = await prisma.planning.findFirst({
|
|
||||||
where: {
|
|
||||||
houseId,
|
|
||||||
startDate: { lte: dateOnly },
|
|
||||||
finishDate: { gte: dateOnly },
|
|
||||||
},
|
|
||||||
// A household should never have two plannings covering the same day,
|
|
||||||
// but nothing in the schema enforces that yet — pick the most recently
|
|
||||||
// started one rather than letting the query fail if it ever happens.
|
|
||||||
orderBy: { startDate: "desc" },
|
|
||||||
include: {
|
|
||||||
items: {
|
|
||||||
include: { recipe: { select: { id: true, name: true } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!planning) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: planning.id,
|
|
||||||
startDate: planning.startDate.toISOString(),
|
|
||||||
finishDate: planning.finishDate.toISOString(),
|
|
||||||
items: planning.items.map((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;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
||||||
* Finds the household's `Planning` covering the week `weekStart` (a Monday)
|
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
||||||
* starts, creating it on the fly (spanning the full Monday-to-Sunday week)
|
// how Postgres stores/returns them, regardless of the server's local
|
||||||
* if none exists yet. Matched by exact `startDate`, not a covering range
|
// timezone.
|
||||||
* like {@link getPlanningForDate} — that query is for "what covers today",
|
const dateOnly = toDateOnly(date).toJSDate();
|
||||||
* 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
|
const planning = await prisma.planning.findFirst({
|
||||||
* than risk matching some other overlapping planning.
|
where: {
|
||||||
*
|
houseId,
|
||||||
* Not wrapped in a transaction with the `findFirst` — no unique constraint
|
startDate: { lte: dateOnly },
|
||||||
* exists on `(houseId, startDate)` (see {@link getPlanningForDate}'s doc
|
finishDate: { gte: dateOnly },
|
||||||
* 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
|
// A household should never have two plannings covering the same day,
|
||||||
* scale rather than adding a migration + retry-on-conflict loop for it.
|
// but nothing in the schema enforces that yet — pick the most recently
|
||||||
*/
|
// started one rather than letting the query fail if it ever happens.
|
||||||
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
|
orderBy: { startDate: "desc" },
|
||||||
try {
|
include: {
|
||||||
const startDate = weekStart.toJSDate();
|
items: {
|
||||||
const existing = await prisma.planning.findFirst({
|
include: { recipe: { select: { id: true, name: true } } },
|
||||||
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
|
|
||||||
|
if (!planning) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return {
|
||||||
* Adds a recipe to one (day, meal) slot of `houseId`'s planning for the
|
id: planning.id,
|
||||||
* week containing `date`, creating that week's `Planning` row first if it
|
startDate: planning.startDate.toISOString(),
|
||||||
* doesn't exist yet (see {@link findOrCreatePlanningForWeek}) — this is the
|
finishDate: planning.finishDate.toISOString(),
|
||||||
* only place a `Planning` row gets created at all today, there's no
|
items: planning.items.map((item) => ({
|
||||||
* 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,
|
id: item.id,
|
||||||
weekDay: item.weekDay,
|
weekDay: item.weekDay,
|
||||||
meal: item.meal,
|
meal: item.meal,
|
||||||
portions: item.portions,
|
|
||||||
recipe: item.recipe,
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,8 @@ import { prisma } from "../../db/prisma.js";
|
||||||
* just to read it.
|
* just to read it.
|
||||||
*/
|
*/
|
||||||
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
||||||
try {
|
const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
|
||||||
const preferences = await prisma.userPreference.findUnique({
|
return { theme: preferences?.theme ?? "SYSTEM" };
|
||||||
where: { userProfileId },
|
|
||||||
});
|
|
||||||
return { theme: preferences?.theme ?? "SYSTEM" };
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
|
||||||
// this service layer just isn't allowed a bare `await` per the repo's
|
|
||||||
// async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,14 +22,10 @@ export async function updatePreferences(
|
||||||
userProfileId: number,
|
userProfileId: number,
|
||||||
theme: ThemePreference,
|
theme: ThemePreference,
|
||||||
): Promise<PreferencesView> {
|
): Promise<PreferencesView> {
|
||||||
try {
|
const preferences = await prisma.userPreference.upsert({
|
||||||
const preferences = await prisma.userPreference.upsert({
|
where: { userProfileId },
|
||||||
where: { userProfileId },
|
create: { userProfileId, theme },
|
||||||
create: { userProfileId, theme },
|
update: { theme },
|
||||||
update: { theme },
|
});
|
||||||
});
|
return { theme: preferences.theme };
|
||||||
return { theme: preferences.theme };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getPreferences()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,8 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import {
|
import { updateAllergiesSchema, updateDietSchema } from "@batch-cooking/shared";
|
||||||
updateAllergiesSchema,
|
|
||||||
updateDietSchema,
|
|
||||||
updateDislikedIngredientsSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import {
|
import { getAllergyIds, updateAllergies, updateDiet } from "./profile.service.js";
|
||||||
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. */
|
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
|
||||||
export const profileRouter = Router();
|
export const profileRouter = Router();
|
||||||
|
|
@ -47,26 +37,3 @@ profileRouter.patch(
|
||||||
res.status(200).json(allergyIds);
|
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,39 +14,27 @@ export async function updateDiet(
|
||||||
userProfileId: number,
|
userProfileId: number,
|
||||||
dietId: number | null,
|
dietId: number | null,
|
||||||
): Promise<SafeUserProfile> {
|
): Promise<SafeUserProfile> {
|
||||||
try {
|
if (dietId !== null) {
|
||||||
if (dietId !== null) {
|
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
|
||||||
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
|
if (!diet) {
|
||||||
if (!diet) {
|
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
|
||||||
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await prisma.userProfile.update({
|
|
||||||
where: { id: userProfileId },
|
|
||||||
data: { dietId },
|
|
||||||
});
|
|
||||||
return toSafeProfile(profile);
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
|
||||||
// this service layer just isn't allowed a bare `await` per the repo's
|
|
||||||
// async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profile = await prisma.userProfile.update({
|
||||||
|
where: { id: userProfileId },
|
||||||
|
data: { dietId },
|
||||||
|
});
|
||||||
|
return toSafeProfile(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */
|
/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */
|
||||||
export async function getAllergyIds(userProfileId: number): Promise<number[]> {
|
export async function getAllergyIds(userProfileId: number): Promise<number[]> {
|
||||||
try {
|
const rows = await prisma.userProfileAllergy.findMany({
|
||||||
const rows = await prisma.userProfileAllergy.findMany({
|
where: { userProfileId },
|
||||||
where: { userProfileId },
|
select: { allergyId: true },
|
||||||
select: { allergyId: true },
|
});
|
||||||
});
|
return rows.map((row) => row.allergyId);
|
||||||
return rows.map((row) => row.allergyId);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see updateDiet()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,91 +49,28 @@ export async function updateAllergies(
|
||||||
userProfileId: number,
|
userProfileId: number,
|
||||||
allergyIds: number[],
|
allergyIds: number[],
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
try {
|
if (allergyIds.length > 0) {
|
||||||
if (allergyIds.length > 0) {
|
const found = await prisma.allergy.findMany({
|
||||||
const found = await prisma.allergy.findMany({
|
where: { id: { in: allergyIds } },
|
||||||
where: { id: { in: allergyIds } },
|
select: { id: true },
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((allergy) => allergy.id));
|
|
||||||
const 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);
|
const foundIds = new Set(found.map((allergy) => allergy.id));
|
||||||
} catch (err) {
|
const missing = allergyIds.filter((id) => !foundIds.has(id));
|
||||||
throw err; // see updateDiet()'s catch comment above
|
if (missing.length > 0) {
|
||||||
}
|
throw new HttpError(
|
||||||
}
|
404,
|
||||||
|
ErrorCode.ALLERGY_NOT_FOUND,
|
||||||
/**
|
`Unknown allergy id(s): ${missing.join(", ")}`,
|
||||||
* 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,506 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
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));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,893 +0,0 @@
|
||||||
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,23 +1,13 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import { getAllergies, getDiets } from "./reference.service.js";
|
||||||
getAllergies,
|
|
||||||
getDiets,
|
|
||||||
getIngredients,
|
|
||||||
getSources,
|
|
||||||
getTechSteps,
|
|
||||||
getUnits,
|
|
||||||
getUtensils,
|
|
||||||
} from "./reference.service.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router mounted at `/reference` in app.ts. Every route is deliberately
|
* Router mounted at `/reference` in app.ts. Both routes are deliberately
|
||||||
* public (no `requireAuth`) — this is static reference data, not
|
* public (no `requireAuth`) — this is static reference data, not
|
||||||
* per-household state, and the signup wizard (household/regime/allergen
|
* per-household state, and the signup wizard (household/regime/allergen
|
||||||
* steps) needs to read it before an account — and therefore a session —
|
* steps) needs to read it before an account — and therefore a session —
|
||||||
* exists. `/ingredients` follows the same reasoning even though it's only
|
* exists.
|
||||||
* 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();
|
export const referenceRouter = Router();
|
||||||
|
|
||||||
|
|
@ -34,38 +24,3 @@ referenceRouter.get(
|
||||||
res.status(200).json(await getAllergies());
|
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,166 +1,25 @@
|
||||||
import type {
|
import type { AllergyView, DietView } from "@batch-cooking/shared";
|
||||||
AllergyView,
|
|
||||||
DietView,
|
|
||||||
IngredientView,
|
|
||||||
SourceView,
|
|
||||||
TechStepView,
|
|
||||||
UnitView,
|
|
||||||
UtensilView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
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[]> {
|
export async function getDiets(): Promise<DietView[]> {
|
||||||
try {
|
return prisma.diet.findMany({ orderBy: { name: "asc" } });
|
||||||
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, ordered by key (see {@link getDiets} for why key,
|
* All reference allergens, alphabetically. `Allergy` carries no `name` of
|
||||||
* not label). `Allergy` carries no `key` of its own — it's the selectable
|
* its own — it's the selectable instance of a named `Category` (see
|
||||||
* instance of a keyed `Category` (see schema.prisma) — so this resolves
|
* schema.prisma) — so this resolves each allergen's display name from its
|
||||||
* each allergen's key from its category and flattens the split away for
|
* category and flattens the split away for callers.
|
||||||
* callers.
|
|
||||||
*/
|
*/
|
||||||
export async function getAllergies(): Promise<AllergyView[]> {
|
export async function getAllergies(): Promise<AllergyView[]> {
|
||||||
try {
|
const allergies = await prisma.allergy.findMany({
|
||||||
const allergies = await prisma.allergy.findMany({
|
include: { category: { select: { name: true, kind: true } } },
|
||||||
include: { category: { select: { key: true, kind: true } } },
|
orderBy: { category: { name: "asc" } },
|
||||||
orderBy: { category: { key: "asc" } },
|
});
|
||||||
});
|
return allergies.map((allergy) => ({
|
||||||
return allergies.map((allergy) => ({
|
id: allergy.id,
|
||||||
id: allergy.id,
|
name: allergy.category.name,
|
||||||
key: allergy.category.key,
|
kind: allergy.category.kind,
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { ErrorCode, 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);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,55 +1,8 @@
|
||||||
import { createServer } from "./app.js";
|
import { createServer } from "./app.js";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { logger } from "./lib/logger.service.js";
|
|
||||||
import { techStepClassifier } from "./lib/recipe-matching/tech-step-matcher.js";
|
|
||||||
import { registerAllRecipeSources } from "./sources/index.js";
|
|
||||||
|
|
||||||
// Populates the recipe-source registry (recipe-source-registry.ts) before
|
|
||||||
// the app starts — see registerAllRecipeSources' doc comment for why this
|
|
||||||
// doesn't happen inside app.ts/createServer() itself.
|
|
||||||
registerAllRecipeSources();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trains the tech-step classifier (a `POST /v1/train` round-trip per locale
|
|
||||||
* to `services/tech-step-intent-service` — see
|
|
||||||
* `TechStepClassifierService.warmUp`) before accepting any traffic, so the
|
|
||||||
* first real recipe save/preview isn't the one stuck waiting for it.
|
|
||||||
*
|
|
||||||
* Retried with exponential backoff: in Docker Compose, `app`'s own
|
|
||||||
* `depends_on: tech-step-intent-service: condition: service_healthy`
|
|
||||||
* (`docker-compose.yml`) already means that service is up by the time this
|
|
||||||
* runs, but native dev (`pnpm dev:api`, no Compose ordering at all) can
|
|
||||||
* easily start this before the intent service has finished loading its
|
|
||||||
* spaCy models — a transient connection failure here shouldn't need a
|
|
||||||
* manual restart. Still non-fatal after every attempt is exhausted: the
|
|
||||||
* *next* real call retries training itself (see `_ensureTrained`'s own
|
|
||||||
* retry-on-failure comment), same graceful-degrade posture as before this
|
|
||||||
* retry loop existed.
|
|
||||||
*/
|
|
||||||
async function warmUpTechStepClassifier(): Promise<void> {
|
|
||||||
const maxAttempts = 5;
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
await techStepClassifier.warmUp();
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
if (attempt === maxAttempts) {
|
|
||||||
logger.error("Tech-step classifier warm-up failed after retries", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
attempts: attempt,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const delayMs = 1000 * 2 ** (attempt - 1);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await warmUpTechStepClassifier();
|
|
||||||
|
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
|
|
||||||
server.listen(env.PORT, () => {
|
server.listen(env.PORT, () => {
|
||||||
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
|
console.log(`API listening on http://localhost:${env.PORT}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue